feat: memories view (#28675)
* wip web memories view * paginate searchMemories * improve memories ui * feat(mobile): memories view * improve memories web ui * fix(web): avoid unnecessary memory refreshes * refactor(web): move timline memory manager filter * fix(web): memories infinitely loading on 0 results * fix(web): memories resetting * feat(web): animated memory previews * feat(web): memory page dark styling * chore(mobile): fix failing memory lane tests * chore(mobile): remove old import * chore(e2e): fix failing memory lane tests * chore(server): revert searchMemories changes * chore(server): lint * chore(mobile): fix bad merge * chore(e2e): fix memory ui tests * chore(web): rename /memory to /memories * chore(mobile): linting * chore(web): linting * fix(web): prevent duplicate memory page loading * fix(web): lint * fix(web): memories page improvements * chore(mobile): lint * fix(mobile): fix translations * fix(mobile): build errors * fix(web): prevent memories from infinitely loading * chore(e2e)): fix failng memory viewer test * chore(web): clean up types in memory manager * chore(web): lint * feat: update list and detail memory page layouts * onlyFavorites through drift * rename to get all * mobile: use widgetWhen in memory list page * remove redundant check * fix: e2e --------- Co-authored-by: Timon <mail@timonrieger.de> Co-authored-by: Jason Rasmussen <jason@rasm.me> Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
This commit is contained in:
co-authored by
Timon
Jason Rasmussen
shenlong-tanwen
parent
8dcfd36fa5
commit
351be95769
@@ -80,5 +80,7 @@ export function generateMemoriesFromTimeline(
|
||||
);
|
||||
}
|
||||
|
||||
memories.sort((a, b) => new Date(b.memoryAt).getMilliseconds() - new Date(a.memoryAt).getMilliseconds());
|
||||
|
||||
return memories;
|
||||
}
|
||||
|
||||
@@ -62,4 +62,14 @@ export const setupMemoryMockApiRoutes = async (
|
||||
|
||||
await route.fallback();
|
||||
});
|
||||
|
||||
await context.route('**/api/memories/statistics*', async (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: {
|
||||
total: memories.length,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -79,7 +79,7 @@ test.describe('Memory Viewer - Gallery Asset Viewer Navigation', () => {
|
||||
const firstMemory = memories[0];
|
||||
const middleAsset = firstMemory.assets[1];
|
||||
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, middleAsset.id);
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, firstMemory.id, middleAsset.id);
|
||||
await memoryGalleryUtils.clickThumbnail(page, middleAsset.id);
|
||||
|
||||
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||
@@ -93,7 +93,7 @@ test.describe('Memory Viewer - Gallery Asset Viewer Navigation', () => {
|
||||
const firstMemory = memories[0];
|
||||
const lastAssetOfFirstMemory = firstMemory.assets.at(-1)!;
|
||||
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, lastAssetOfFirstMemory.id);
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, firstMemory.id, lastAssetOfFirstMemory.id);
|
||||
await memoryGalleryUtils.clickThumbnail(page, lastAssetOfFirstMemory.id);
|
||||
|
||||
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||
@@ -107,7 +107,7 @@ test.describe('Memory Viewer - Gallery Asset Viewer Navigation', () => {
|
||||
const lastMemory = memories.at(-1)!;
|
||||
const firstAssetOfLastMemory = lastMemory.assets[0];
|
||||
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, firstAssetOfLastMemory.id);
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, lastMemory.id, firstAssetOfLastMemory.id);
|
||||
await memoryGalleryUtils.clickThumbnail(page, firstAssetOfLastMemory.id);
|
||||
|
||||
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||
@@ -123,7 +123,7 @@ test.describe('Memory Viewer - Gallery Asset Viewer Navigation', () => {
|
||||
const lastAssetOfFirst = firstMemory.assets.at(-1)!;
|
||||
const firstAssetOfSecond = secondMemory.assets[0];
|
||||
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, lastAssetOfFirst.id);
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, firstMemory.id, lastAssetOfFirst.id);
|
||||
await memoryGalleryUtils.clickThumbnail(page, lastAssetOfFirst.id);
|
||||
|
||||
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||
@@ -141,7 +141,7 @@ test.describe('Memory Viewer - Gallery Asset Viewer Navigation', () => {
|
||||
const lastAssetOfFirst = firstMemory.assets.at(-1)!;
|
||||
const firstAssetOfSecond = secondMemory.assets[0];
|
||||
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, firstAssetOfSecond.id);
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, secondMemory.id, firstAssetOfSecond.id);
|
||||
await memoryGalleryUtils.clickThumbnail(page, firstAssetOfSecond.id);
|
||||
|
||||
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||
@@ -155,7 +155,7 @@ test.describe('Memory Viewer - Gallery Asset Viewer Navigation', () => {
|
||||
const firstMemory = memories[0];
|
||||
const veryFirstAsset = firstMemory.assets[0];
|
||||
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, veryFirstAsset.id);
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, firstMemory.id, veryFirstAsset.id);
|
||||
await memoryGalleryUtils.clickThumbnail(page, veryFirstAsset.id);
|
||||
|
||||
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||
@@ -169,7 +169,7 @@ test.describe('Memory Viewer - Gallery Asset Viewer Navigation', () => {
|
||||
const lastMemory = memories.at(-1)!;
|
||||
const veryLastAsset = lastMemory.assets.at(-1)!;
|
||||
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, veryLastAsset.id);
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, lastMemory.id, veryLastAsset.id);
|
||||
await memoryGalleryUtils.clickThumbnail(page, veryLastAsset.id);
|
||||
|
||||
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||
@@ -187,7 +187,7 @@ test.describe('Memory Viewer - Gallery Asset Viewer Navigation', () => {
|
||||
const lastAssetOfFirst = firstMemory.assets.at(-1)!;
|
||||
const firstAssetOfSecond = secondMemory.assets[0];
|
||||
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, firstAssetOfSecond.id);
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, secondMemory.id, firstAssetOfSecond.id);
|
||||
await memoryGalleryUtils.clickThumbnail(page, firstAssetOfSecond.id);
|
||||
|
||||
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||
@@ -203,7 +203,7 @@ test.describe('Memory Viewer - Gallery Asset Viewer Navigation', () => {
|
||||
const lastAssetOfFirst = firstMemory.assets.at(-1)!;
|
||||
const firstAssetOfSecond = secondMemory.assets[0];
|
||||
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, lastAssetOfFirst.id);
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, firstMemory.id, lastAssetOfFirst.id);
|
||||
await memoryGalleryUtils.clickThumbnail(page, lastAssetOfFirst.id);
|
||||
|
||||
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||
@@ -277,7 +277,7 @@ test.describe('Memory Viewer - Single Asset Memory Edge Cases', () => {
|
||||
const singleAssetMemory = memories[1];
|
||||
const singleAsset = singleAssetMemory.assets[0];
|
||||
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, singleAsset.id);
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, singleAssetMemory.id, singleAsset.id);
|
||||
await memoryGalleryUtils.clickThumbnail(page, singleAsset.id);
|
||||
|
||||
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||
|
||||
@@ -3,11 +3,11 @@ import type { AssetResponseDto } from '@immich/sdk';
|
||||
import { expect, Page } from '@playwright/test';
|
||||
|
||||
function getAssetIdFromUrl(url: URL): string | null {
|
||||
const pathMatch = url.pathname.match(/\/memory\/photos\/([^/]+)/);
|
||||
const pathMatch = url.pathname.match(/\/memories\/[^/]+\/photos\/([^/]+)/);
|
||||
if (pathMatch) {
|
||||
return pathMatch[1];
|
||||
}
|
||||
return url.searchParams.get('id');
|
||||
return url.searchParams.get('assetId');
|
||||
}
|
||||
|
||||
export const memoryViewerUtils = {
|
||||
@@ -20,13 +20,8 @@ export const memoryViewerUtils = {
|
||||
await expect(page.locator('#memory-viewer img').first()).toBeVisible();
|
||||
},
|
||||
|
||||
async openMemoryPage(page: Page) {
|
||||
await page.goto('/memory');
|
||||
await this.waitForMemoryLoad(page);
|
||||
},
|
||||
|
||||
async openMemoryPageWithAsset(page: Page, assetId: string) {
|
||||
await page.goto(`/memory?id=${assetId}`);
|
||||
async openMemoryPageWithAsset(page: Page, memoryId: string, assetId: string) {
|
||||
await page.goto(`/memories/${memoryId}?assetId=${assetId}`);
|
||||
await this.waitForMemoryLoad(page);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1453,9 +1453,12 @@
|
||||
"memories": "Memories",
|
||||
"memories_all_caught_up": "All caught up",
|
||||
"memories_check_back_tomorrow": "Check back tomorrow for more memories",
|
||||
"memories_current": "Current",
|
||||
"memories_setting_description": "Manage what you see in your memories",
|
||||
"memories_show_upcoming": "Show upcoming memories",
|
||||
"memories_start_over": "Start Over",
|
||||
"memories_swipe_to_close": "Swipe up to close",
|
||||
"memories_upcoming": "Upcoming",
|
||||
"memory": "Memory",
|
||||
"memory_lane_title": "Memory Lane {title}",
|
||||
"menu": "Menu",
|
||||
|
||||
@@ -91,18 +91,6 @@ url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets
|
||||
version = "7.1.3-6"
|
||||
backend = "github:jellyfin/jellyfin-ffmpeg"
|
||||
|
||||
[tools."github:jellyfin/jellyfin-ffmpeg".options]
|
||||
asset_pattern = "jellyfin-ffmpeg_*_portable_macarm64-gpl.tar.xz"
|
||||
|
||||
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.macos-arm64"]
|
||||
checksum = "sha256:e024d5e78d5414e75f0181036cd21373fafb9270c72894dfd7dbda2572439820"
|
||||
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_macarm64-gpl.tar.xz"
|
||||
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/408995838"
|
||||
|
||||
[[tools."github:jellyfin/jellyfin-ffmpeg"]]
|
||||
version = "7.1.3-6"
|
||||
backend = "github:jellyfin/jellyfin-ffmpeg"
|
||||
|
||||
[tools."github:jellyfin/jellyfin-ffmpeg".options]
|
||||
asset_pattern = "jellyfin-ffmpeg_*_portable_linux64-gpl.tar.xz"
|
||||
|
||||
@@ -145,6 +133,18 @@ checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952
|
||||
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz"
|
||||
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876"
|
||||
|
||||
[[tools."github:jellyfin/jellyfin-ffmpeg"]]
|
||||
version = "7.1.3-6"
|
||||
backend = "github:jellyfin/jellyfin-ffmpeg"
|
||||
|
||||
[tools."github:jellyfin/jellyfin-ffmpeg".options]
|
||||
asset_pattern = "jellyfin-ffmpeg_*_portable_macarm64-gpl.tar.xz"
|
||||
|
||||
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.macos-arm64"]
|
||||
checksum = "sha256:e024d5e78d5414e75f0181036cd21373fafb9270c72894dfd7dbda2572439820"
|
||||
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_macarm64-gpl.tar.xz"
|
||||
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/408995838"
|
||||
|
||||
[[tools."github:webassembly/binaryen"]]
|
||||
version = "version_124"
|
||||
backend = "github:webassembly/binaryen"
|
||||
|
||||
@@ -13,6 +13,10 @@ class DriftMemoryService {
|
||||
return _repository.getAll(ownerId);
|
||||
}
|
||||
|
||||
Future<List<DriftMemory>> getAll(String ownerId, {bool onlyFavorites = false}) {
|
||||
return _repository.getAll(ownerId, onlyToday: false, onlyFavorites: onlyFavorites);
|
||||
}
|
||||
|
||||
Future<DriftMemory?> get(String memoryId) {
|
||||
return _repository.get(memoryId);
|
||||
}
|
||||
|
||||
@@ -12,10 +12,7 @@ class MemoryRepository extends DatabaseAccessor<Drift> with $MemoryRepositoryMix
|
||||
|
||||
Drift get _db => attachedDatabase;
|
||||
|
||||
Future<List<DriftMemory>> getAll(String ownerId) async {
|
||||
final now = DateTime.now();
|
||||
final localUtc = DateTime.utc(now.year, now.month, now.day, 0, 0, 0);
|
||||
|
||||
Future<List<DriftMemory>> getAll(String ownerId, {bool onlyToday = true, bool onlyFavorites = false}) async {
|
||||
final query =
|
||||
_db.select(_db.memoryEntity).join([
|
||||
innerJoin(_db.memoryAssetEntity, _db.memoryAssetEntity.memoryId.equalsExp(_db.memoryEntity.id)),
|
||||
@@ -27,10 +24,21 @@ class MemoryRepository extends DatabaseAccessor<Drift> with $MemoryRepositoryMix
|
||||
),
|
||||
])
|
||||
..where(_db.memoryEntity.ownerId.equals(ownerId))
|
||||
..where(_db.memoryEntity.deletedAt.isNull())
|
||||
..where(_db.memoryEntity.showAt.isNull() | _db.memoryEntity.showAt.isSmallerOrEqualValue(localUtc))
|
||||
..where(_db.memoryEntity.hideAt.isNull() | _db.memoryEntity.hideAt.isBiggerOrEqualValue(localUtc))
|
||||
..orderBy([OrderingTerm.desc(_db.memoryEntity.memoryAt), OrderingTerm.asc(_db.remoteAssetEntity.createdAt)]);
|
||||
..where(_db.memoryEntity.deletedAt.isNull());
|
||||
|
||||
if (onlyFavorites) {
|
||||
query.where(_db.memoryEntity.isSaved.equals(true));
|
||||
}
|
||||
|
||||
if (onlyToday) {
|
||||
final now = DateTime.now();
|
||||
final localUtc = DateTime.utc(now.year, now.month, now.day, 0, 0, 0);
|
||||
|
||||
query.where(_db.memoryEntity.showAt.isNull() | _db.memoryEntity.showAt.isSmallerOrEqualValue(localUtc));
|
||||
query.where(_db.memoryEntity.hideAt.isNull() | _db.memoryEntity.hideAt.isBiggerOrEqualValue(localUtc));
|
||||
}
|
||||
|
||||
query.orderBy([OrderingTerm.desc(_db.memoryEntity.memoryAt), OrderingTerm.asc(_db.remoteAssetEntity.createdAt)]);
|
||||
|
||||
final rows = await query.get();
|
||||
if (rows.isEmpty) {
|
||||
|
||||
@@ -111,7 +111,7 @@ void _onNavigationSelected(TabsRouter router, int index, WidgetRef ref) {
|
||||
}
|
||||
|
||||
if (index == kPhotoTabIndex) {
|
||||
ref.invalidate(driftMemoryFutureProvider);
|
||||
ref.invalidate(driftMemoryLaneProvider);
|
||||
}
|
||||
|
||||
if (router.activeIndex != kSearchTabIndex && index == kSearchTabIndex) {
|
||||
|
||||
@@ -39,7 +39,7 @@ class _MainTimelinePageState extends ConsumerState<MainTimelinePage> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasMemories = ref.watch(driftMemoryFutureProvider.select((state) => state.value?.isNotEmpty ?? false));
|
||||
final hasMemories = ref.watch(driftMemoryLaneProvider.select((state) => state.value?.isNotEmpty ?? false));
|
||||
return Timeline(
|
||||
topSliverWidget: const SliverToBoxAdapter(child: DriftMemoryLane()),
|
||||
topSliverWidgetHeight: hasMemories ? 200 : 0,
|
||||
|
||||
@@ -7,8 +7,10 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/local_album_thumbnail.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/people/partner_user_avatar.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
@@ -133,7 +135,12 @@ class _CollectionCards extends StatelessWidget {
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [_PeopleCollectionCard(), _PlacesCollectionCard(), _LocalAlbumsCollectionCard()],
|
||||
children: [
|
||||
_PeopleCollectionCard(),
|
||||
_PlacesCollectionCard(),
|
||||
_LocalAlbumsCollectionCard(),
|
||||
_MemoriesCollectionCard(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -329,6 +336,76 @@ class _LocalAlbumsCollectionCard extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _MemoriesCollectionCard extends ConsumerWidget {
|
||||
const _MemoriesCollectionCard();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final memories = ref.watch(driftAllMemoriesProvider(false));
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final isTablet = constraints.maxWidth > 600;
|
||||
final widthFactor = isTablet ? 0.25 : 0.5;
|
||||
final size = context.width * widthFactor - 20.0;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => context.pushRoute(const DriftMemoryListRoute()),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
height: size,
|
||||
width: size,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(20)),
|
||||
gradient: LinearGradient(
|
||||
colors: [context.colorScheme.primary.withAlpha(30), context.colorScheme.primary.withAlpha(25)],
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
),
|
||||
),
|
||||
child: memories.widgetWhen(
|
||||
onLoading: () => const Center(child: CircularProgressIndicator()),
|
||||
onData: (memories) {
|
||||
return GridView.count(
|
||||
crossAxisCount: 2,
|
||||
padding: const EdgeInsets.all(12),
|
||||
crossAxisSpacing: 8,
|
||||
mainAxisSpacing: 8,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
children: memories.take(4).map((memory) {
|
||||
return ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(10)),
|
||||
child: Thumbnail.remote(
|
||||
remoteId: memory.assets[0].id,
|
||||
thumbhash: memory.assets[0].thumbHash ?? "",
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
context.t.memories,
|
||||
style: context.textTheme.titleSmall?.copyWith(
|
||||
color: context.colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
final sharedWithPartnerProvider = StreamProvider.autoDispose<Iterable<Partner>>((ref) {
|
||||
final currentUser = ref.watch(currentUserProvider);
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_memory.page.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
||||
@RoutePage()
|
||||
class DriftMemoryListPage extends ConsumerStatefulWidget {
|
||||
const DriftMemoryListPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<DriftMemoryListPage> createState() => _DriftMemoryListPageState();
|
||||
}
|
||||
|
||||
class _DriftMemoryListPageState extends ConsumerState<DriftMemoryListPage> {
|
||||
bool _onlyFavorites = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final memories = ref.watch(driftAllMemoriesProvider(_onlyFavorites));
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(context.t.memories),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(_onlyFavorites ? Icons.favorite : Icons.favorite_outline),
|
||||
onPressed: () {
|
||||
setState(() => _onlyFavorites = !_onlyFavorites);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: memories.widgetWhen(
|
||||
onData: (memories) {
|
||||
return GridView.builder(
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: constraints.maxWidth > 600 ? 4 : 2,
|
||||
childAspectRatio: 0.5625,
|
||||
mainAxisSpacing: 16,
|
||||
crossAxisSpacing: 16,
|
||||
),
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: memories.length,
|
||||
itemBuilder: (context, index) => GestureDetector(
|
||||
onTap: () {
|
||||
DriftMemoryPage.setMemory(ref, memories[index]);
|
||||
unawaited(context.pushRoute(DriftMemoryRoute(memories: memories, memoryIndex: index)));
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(10)),
|
||||
child: ColorFiltered(
|
||||
colorFilter: ColorFilter.mode(Colors.black.withValues(alpha: 0.2), BlendMode.darken),
|
||||
child: AbsorbPointer(
|
||||
child: Thumbnail.remote(
|
||||
remoteId: memories[index].assets[0].id,
|
||||
thumbhash: memories[index].assets[0].thumbHash ?? "",
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 16,
|
||||
left: 16,
|
||||
child: Text(
|
||||
DateFormat.yMMMMd().format(memories[index].memoryAt),
|
||||
style: const TextStyle(fontWeight: FontWeight.w600, color: Colors.white, fontSize: 15),
|
||||
),
|
||||
),
|
||||
if (memories[index].isSaved)
|
||||
const Positioned(
|
||||
top: 16,
|
||||
right: 16,
|
||||
child: Icon(Icons.favorite, color: Colors.white, size: 24),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ class DriftMemoryLane extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final memoryLaneProvider = ref.watch(driftMemoryFutureProvider);
|
||||
final memoryLaneProvider = ref.watch(driftMemoryLaneProvider);
|
||||
final memories = memoryLaneProvider.value ?? const [];
|
||||
if (memories.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
|
||||
@@ -128,7 +128,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
|
||||
syncSuccess = await backgroundManager.syncRemote();
|
||||
}, "syncRemote"),
|
||||
]);
|
||||
_ref.invalidate(driftMemoryFutureProvider);
|
||||
_ref.invalidate(driftAllMemoriesProvider);
|
||||
if (syncSuccess) {
|
||||
await Future.wait([
|
||||
_safeRun(backgroundManager.hashAssets, "hashAssets").then((_) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import 'package:immich_mobile/domain/services/memory.service.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
|
||||
final driftMemoryFutureProvider = FutureProvider.autoDispose<List<DriftMemory>>((ref) {
|
||||
final driftMemoryLaneProvider = FutureProvider.autoDispose<List<DriftMemory>>((ref) {
|
||||
final (userId, enabled) = ref.watch(currentUserProvider.select((user) => (user?.id, user?.memoryEnabled ?? true)));
|
||||
if (userId == null || !enabled) {
|
||||
return const [];
|
||||
@@ -20,3 +20,13 @@ final driftMemoryFutureProvider = FutureProvider.autoDispose<List<DriftMemory>>(
|
||||
final service = DriftMemoryService(ref.watch(driftProvider).memoryRepository);
|
||||
return service.getMemoryLane(userId);
|
||||
});
|
||||
|
||||
final driftAllMemoriesProvider = FutureProvider.autoDispose.family<List<DriftMemory>, bool>((ref, onlyFavorites) {
|
||||
final (userId, enabled) = ref.watch(currentUserProvider.select((user) => (user?.id, user?.memoryEnabled ?? true)));
|
||||
if (userId == null || !enabled) {
|
||||
return const [];
|
||||
}
|
||||
|
||||
final service = DriftMemoryService(ref.watch(driftProvider).memoryRepository);
|
||||
return service.getAll(userId, onlyFavorites: onlyFavorites);
|
||||
});
|
||||
|
||||
@@ -52,6 +52,7 @@ import 'package:immich_mobile/presentation/pages/drift_local_album.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_locked_folder.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_map.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_memory.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_memory_list.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_partner_detail.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_people_collection.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_person.page.dart';
|
||||
@@ -191,6 +192,7 @@ class AppRouter extends RootStackRouter {
|
||||
AutoRoute(page: DownloadInfoRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
AutoRoute(page: CleanupPreviewRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
AutoRoute(page: DriftSlideshowRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
AutoRoute(page: DriftMemoryListRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
// required to handle all deeplinks in deep_link.service.dart
|
||||
// auto_route_library#1722
|
||||
RedirectRoute(path: '*', redirectTo: '/'),
|
||||
|
||||
@@ -19,7 +19,7 @@ final Map<String, Map<String, Object?>> openApiPatches = {
|
||||
'UserPreferencesResponseDto': {
|
||||
'download.includeEmbeddedVideos': false,
|
||||
'folders': FoldersResponse(enabled: false, sidebarWeb: false).toJson(),
|
||||
'memories': MemoriesResponse(enabled: true, duration: 5).toJson(),
|
||||
'memories': MemoriesResponse(enabled: true, duration: 5, sidebarWeb: false).toJson(),
|
||||
'ratings': RatingsResponse(enabled: false).toJson(),
|
||||
'people': PeopleResponse(enabled: true, sidebarWeb: false).toJson(),
|
||||
'tags': TagsResponse(enabled: false, sidebarWeb: false).toJson(),
|
||||
@@ -40,7 +40,7 @@ final Map<String, Map<String, Object?>> openApiPatches = {
|
||||
'SyncUserV1': {'profileChangedAt': _now, 'hasProfileImage': false},
|
||||
'SyncAssetV1': {'isEdited': false},
|
||||
'ServerFeaturesDto': {'ocr': false, 'realtimeTranscoding': false},
|
||||
'MemoriesResponse': {'duration': 5},
|
||||
'MemoriesResponse': {'duration': 5, 'sidebarWeb': false},
|
||||
'WorkflowResponseDto': {'logging': false},
|
||||
};
|
||||
|
||||
|
||||
@@ -52,11 +52,11 @@ void main() {
|
||||
when(() => userService.watchMyUser()).thenAnswer((_) => const Stream.empty());
|
||||
});
|
||||
|
||||
group('driftMemoryFutureProvider', () {
|
||||
group('driftMemoryLaneProvider', () {
|
||||
test('re-queries after local midnight', () {
|
||||
fakeAsync((async) {
|
||||
final container = makeContainer();
|
||||
container.listen(driftMemoryFutureProvider, (_, _) {});
|
||||
container.listen(driftMemoryLaneProvider, (_, _) {});
|
||||
async.flushMicrotasks();
|
||||
|
||||
verify(() => memoryRepository.getAll('user-1')).called(1);
|
||||
@@ -74,7 +74,7 @@ void main() {
|
||||
test('cancels the midnight timer when disposed', () {
|
||||
fakeAsync((async) {
|
||||
final container = makeContainer();
|
||||
final subscription = container.listen(driftMemoryFutureProvider, (_, _) {});
|
||||
final subscription = container.listen(driftMemoryLaneProvider, (_, _) {});
|
||||
async.flushMicrotasks();
|
||||
verify(() => memoryRepository.getAll('user-1')).called(1);
|
||||
|
||||
@@ -91,7 +91,7 @@ void main() {
|
||||
|
||||
fakeAsync((async) {
|
||||
final container = makeContainer();
|
||||
container.listen(driftMemoryFutureProvider, (_, _) {});
|
||||
container.listen(driftMemoryLaneProvider, (_, _) {});
|
||||
async.flushMicrotasks();
|
||||
|
||||
async.elapse(const Duration(hours: 25));
|
||||
|
||||
@@ -8064,6 +8064,17 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "id",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Memory ID",
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "isSaved",
|
||||
"required": false,
|
||||
@@ -8082,6 +8093,15 @@
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "isUpcoming",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Filter by memories that have not been shown yet",
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "order",
|
||||
"required": false,
|
||||
@@ -8090,6 +8110,17 @@
|
||||
"$ref": "#/components/schemas/MemorySearchOrder"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "page",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Page number",
|
||||
"schema": {
|
||||
"minimum": 1,
|
||||
"maximum": 9007199254740991,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "size",
|
||||
"required": false,
|
||||
@@ -8233,6 +8264,17 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "id",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Memory ID",
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "isSaved",
|
||||
"required": false,
|
||||
@@ -8251,6 +8293,15 @@
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "isUpcoming",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Filter by memories that have not been shown yet",
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "order",
|
||||
"required": false,
|
||||
@@ -8259,6 +8310,17 @@
|
||||
"$ref": "#/components/schemas/MemorySearchOrder"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "page",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Page number",
|
||||
"schema": {
|
||||
"minimum": 1,
|
||||
"maximum": 9007199254740991,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "size",
|
||||
"required": false,
|
||||
@@ -22179,11 +22241,16 @@
|
||||
"enabled": {
|
||||
"description": "Whether memories are enabled",
|
||||
"type": "boolean"
|
||||
},
|
||||
"sidebarWeb": {
|
||||
"description": "Whether memories appear in web sidebar",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"duration",
|
||||
"enabled"
|
||||
"enabled",
|
||||
"sidebarWeb"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
@@ -22198,6 +22265,10 @@
|
||||
"enabled": {
|
||||
"description": "Whether memories are enabled",
|
||||
"type": "boolean"
|
||||
},
|
||||
"sidebarWeb": {
|
||||
"description": "Whether memories appear in web sidebar",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
|
||||
@@ -678,6 +678,8 @@ export type MemoriesResponse = {
|
||||
duration: number;
|
||||
/** Whether memories are enabled */
|
||||
enabled: boolean;
|
||||
/** Whether memories appear in web sidebar */
|
||||
sidebarWeb: boolean;
|
||||
};
|
||||
export type PeopleResponse = {
|
||||
/** Whether people are enabled */
|
||||
@@ -762,6 +764,8 @@ export type MemoriesUpdate = {
|
||||
duration?: number;
|
||||
/** Whether memories are enabled */
|
||||
enabled?: boolean;
|
||||
/** Whether memories appear in web sidebar */
|
||||
sidebarWeb?: boolean;
|
||||
};
|
||||
export type PeopleUpdate = {
|
||||
/** Whether people are enabled */
|
||||
@@ -5411,11 +5415,14 @@ export function reverseGeocode({ lat, lon }: {
|
||||
/**
|
||||
* Retrieve memories
|
||||
*/
|
||||
export function searchMemories({ $for, isSaved, isTrashed, order, size, $type }: {
|
||||
export function searchMemories({ $for, id, isSaved, isTrashed, isUpcoming, order, page, size, $type }: {
|
||||
$for?: string;
|
||||
id?: string;
|
||||
isSaved?: boolean;
|
||||
isTrashed?: boolean;
|
||||
isUpcoming?: boolean;
|
||||
order?: MemorySearchOrder;
|
||||
page?: number;
|
||||
size?: number;
|
||||
$type?: MemoryType;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
@@ -5424,9 +5431,12 @@ export function searchMemories({ $for, isSaved, isTrashed, order, size, $type }:
|
||||
data: MemoryResponseDto[];
|
||||
}>(`/memories${QS.query(QS.explode({
|
||||
"for": $for,
|
||||
id,
|
||||
isSaved,
|
||||
isTrashed,
|
||||
isUpcoming,
|
||||
order,
|
||||
page,
|
||||
size,
|
||||
"type": $type
|
||||
}))}`, {
|
||||
@@ -5451,11 +5461,14 @@ export function createMemory({ memoryCreateDto }: {
|
||||
/**
|
||||
* Retrieve memories statistics
|
||||
*/
|
||||
export function memoriesStatistics({ $for, isSaved, isTrashed, order, size, $type }: {
|
||||
export function memoriesStatistics({ $for, id, isSaved, isTrashed, isUpcoming, order, page, size, $type }: {
|
||||
$for?: string;
|
||||
id?: string;
|
||||
isSaved?: boolean;
|
||||
isTrashed?: boolean;
|
||||
isUpcoming?: boolean;
|
||||
order?: MemorySearchOrder;
|
||||
page?: number;
|
||||
size?: number;
|
||||
$type?: MemoryType;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
@@ -5464,9 +5477,12 @@ export function memoriesStatistics({ $for, isSaved, isTrashed, order, size, $typ
|
||||
data: MemoryStatisticsResponseDto;
|
||||
}>(`/memories/statistics${QS.query(QS.explode({
|
||||
"for": $for,
|
||||
id,
|
||||
isSaved,
|
||||
isTrashed,
|
||||
isUpcoming,
|
||||
order,
|
||||
page,
|
||||
size,
|
||||
"type": $type
|
||||
}))}`, {
|
||||
|
||||
@@ -9,11 +9,14 @@ import z from 'zod';
|
||||
|
||||
const MemorySearchSchema = z
|
||||
.object({
|
||||
id: z.uuidv4().optional().describe('Memory ID'),
|
||||
type: MemoryTypeSchema.optional(),
|
||||
for: isoDateToDate.optional().describe('Filter by date'),
|
||||
isTrashed: stringToBool.optional().describe('Include trashed memories'),
|
||||
isSaved: stringToBool.optional().describe('Filter by saved status'),
|
||||
isUpcoming: stringToBool.optional().describe('Filter by memories that have not been shown yet'),
|
||||
size: z.coerce.number().int().min(1).optional().describe('Number of memories to return'),
|
||||
page: z.coerce.number().int().min(1).optional().describe('Page number'),
|
||||
order: AssetOrderWithRandomSchema.optional(),
|
||||
})
|
||||
.meta({ id: 'MemorySearchDto' });
|
||||
|
||||
@@ -22,6 +22,7 @@ const MemoriesUpdateSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional().describe('Whether memories are enabled'),
|
||||
duration: z.int().min(1).optional().describe('Memory duration in seconds'),
|
||||
sidebarWeb: z.boolean().optional().describe('Whether memories appear in web sidebar'),
|
||||
})
|
||||
.optional()
|
||||
.meta({ id: 'MemoriesUpdate' });
|
||||
@@ -140,6 +141,7 @@ const MemoriesResponseSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().describe('Whether memories are enabled'),
|
||||
duration: z.int().describe('Memory duration in seconds'),
|
||||
sidebarWeb: z.boolean().describe('Whether memories appear in web sidebar'),
|
||||
})
|
||||
.meta({ id: 'MemoriesResponse' });
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ where
|
||||
"deletedAt" is null
|
||||
and "ownerId" = $3
|
||||
order by
|
||||
"showAt" desc nulls last,
|
||||
"memoryAt" desc
|
||||
|
||||
-- MemoryRepository.search (date filter)
|
||||
@@ -112,6 +113,94 @@ where
|
||||
and "deletedAt" is null
|
||||
and "ownerId" = $5
|
||||
order by
|
||||
"showAt" desc nulls last,
|
||||
"memoryAt" desc
|
||||
|
||||
-- MemoryRepository.search (upcoming filter)
|
||||
select
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
from
|
||||
(
|
||||
select
|
||||
"asset".*
|
||||
from
|
||||
"asset"
|
||||
inner join "memory_asset" on "asset"."id" = "memory_asset"."assetId"
|
||||
where
|
||||
"memory_asset"."memoriesId" = "memory"."id"
|
||||
and "asset"."visibility" = 'timeline'
|
||||
and "asset"."deletedAt" is null
|
||||
and not exists (
|
||||
select
|
||||
$1 as "one"
|
||||
from
|
||||
"asset_face"
|
||||
inner join "person" on "person"."personGroupId" = "asset_face"."personGroupId"
|
||||
and "person"."ownerId" = "asset"."ownerId"
|
||||
where
|
||||
"asset_face"."assetId" = "asset"."id"
|
||||
and "person"."isHidden" = $2
|
||||
)
|
||||
order by
|
||||
"asset"."fileCreatedAt" asc
|
||||
) as agg
|
||||
) as "assets",
|
||||
"memory".*
|
||||
from
|
||||
"memory"
|
||||
where
|
||||
"showAt" > $3
|
||||
and "deletedAt" is null
|
||||
and "ownerId" = $4
|
||||
order by
|
||||
"showAt" desc nulls last,
|
||||
"memoryAt" desc
|
||||
|
||||
-- MemoryRepository.search (not upcoming filter)
|
||||
select
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
from
|
||||
(
|
||||
select
|
||||
"asset".*
|
||||
from
|
||||
"asset"
|
||||
inner join "memory_asset" on "asset"."id" = "memory_asset"."assetId"
|
||||
where
|
||||
"memory_asset"."memoriesId" = "memory"."id"
|
||||
and "asset"."visibility" = 'timeline'
|
||||
and "asset"."deletedAt" is null
|
||||
and not exists (
|
||||
select
|
||||
$1 as "one"
|
||||
from
|
||||
"asset_face"
|
||||
inner join "person" on "person"."personGroupId" = "asset_face"."personGroupId"
|
||||
and "person"."ownerId" = "asset"."ownerId"
|
||||
where
|
||||
"asset_face"."assetId" = "asset"."id"
|
||||
and "person"."isHidden" = $2
|
||||
)
|
||||
order by
|
||||
"asset"."fileCreatedAt" asc
|
||||
) as agg
|
||||
) as "assets",
|
||||
"memory".*
|
||||
from
|
||||
"memory"
|
||||
where
|
||||
(
|
||||
"showAt" is null
|
||||
or "showAt" <= $3
|
||||
)
|
||||
and "deletedAt" is null
|
||||
and "ownerId" = $4
|
||||
order by
|
||||
"showAt" desc nulls last,
|
||||
"memoryAt" desc
|
||||
|
||||
-- MemoryRepository.get
|
||||
|
||||
@@ -39,6 +39,12 @@ export class MemoryRepository implements IBulkAsset {
|
||||
.where((where) => where.or([where('showAt', 'is', null), where('showAt', '<=', dto.for!)]))
|
||||
.where((where) => where.or([where('hideAt', 'is', null), where('hideAt', '>=', dto.for!)])),
|
||||
)
|
||||
.$if(dto.isUpcoming !== undefined, (qb) => {
|
||||
const now = DateTime.now().toJSDate();
|
||||
return dto.isUpcoming
|
||||
? qb.where('showAt', '>', now)
|
||||
: qb.where((where) => where.or([where('showAt', 'is', null), where('showAt', '<=', now)]));
|
||||
})
|
||||
.where('deletedAt', dto.isTrashed ? 'is not' : 'is', null)
|
||||
.where('ownerId', '=', ownerId);
|
||||
}
|
||||
@@ -56,6 +62,8 @@ export class MemoryRepository implements IBulkAsset {
|
||||
@GenerateSql(
|
||||
{ params: [DummyValue.UUID, {}] },
|
||||
{ name: 'date filter', params: [DummyValue.UUID, { for: DummyValue.DATE }] },
|
||||
{ name: 'upcoming filter', params: [DummyValue.UUID, { isUpcoming: true }] },
|
||||
{ name: 'not upcoming filter', params: [DummyValue.UUID, { isUpcoming: false }] },
|
||||
)
|
||||
search(ownerId: string, dto: MemorySearchDto) {
|
||||
return this.searchBuilder(ownerId, dto)
|
||||
@@ -88,12 +96,19 @@ export class MemoryRepository implements IBulkAsset {
|
||||
).as('assets'),
|
||||
)
|
||||
.selectAll('memory')
|
||||
.$call((qb) =>
|
||||
dto.order === AssetOrderWithRandom.Random
|
||||
? qb.orderBy(sql`RANDOM()`)
|
||||
: qb.orderBy('memoryAt', (dto.order?.toLowerCase() || 'desc') as OrderByDirection),
|
||||
)
|
||||
.$call((qb) => {
|
||||
if (dto.order === AssetOrderWithRandom.Random) {
|
||||
return qb.orderBy(sql`RANDOM()`);
|
||||
}
|
||||
|
||||
const direction = (dto.order?.toLowerCase() || 'desc') as OrderByDirection;
|
||||
return qb
|
||||
.orderBy('showAt', (ob) => (direction === 'asc' ? ob.asc() : ob.desc()).nullsLast())
|
||||
.orderBy('memoryAt', direction);
|
||||
})
|
||||
.$if(dto.id !== undefined, (qb) => qb.where('id', '=', dto.id!))
|
||||
.$if(dto.size !== undefined, (qb) => qb.limit(dto.size!))
|
||||
.$if(dto.page !== undefined && dto.size !== undefined, (qb) => qb.offset((dto.page! - 1) * dto.size!))
|
||||
.execute();
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ describe(MemoryService.name, () => {
|
||||
const memory1 = MemoryFactory.from({ ownerId: userId }).asset(asset).build();
|
||||
const memory2 = MemoryFactory.create({ ownerId: userId });
|
||||
mocks.memory.search.mockResolvedValue([getForMemory(memory1), getForMemory(memory2)]);
|
||||
mocks.memory.statistics.mockResolvedValue({ total: 2 });
|
||||
|
||||
await expect(sut.search(factory.auth({ user: { id: userId } }), {})).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
@@ -44,6 +45,8 @@ describe(MemoryService.name, () => {
|
||||
}),
|
||||
]),
|
||||
);
|
||||
mocks.memory.search.mockResolvedValue([]);
|
||||
await expect(sut.search(factory.auth(), {})).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('should map empty result', async () => {
|
||||
|
||||
@@ -557,6 +557,7 @@ export type UserPreferences = {
|
||||
memories: {
|
||||
enabled: boolean;
|
||||
duration: number;
|
||||
sidebarWeb: boolean;
|
||||
};
|
||||
people: {
|
||||
enabled: boolean;
|
||||
|
||||
@@ -17,6 +17,7 @@ const getDefaultPreferences = (): UserPreferences => {
|
||||
memories: {
|
||||
enabled: true,
|
||||
duration: 5,
|
||||
sidebarWeb: false,
|
||||
},
|
||||
people: {
|
||||
enabled: true,
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
mdiAccountOutline,
|
||||
mdiArchiveArrowDown,
|
||||
mdiArchiveArrowDownOutline,
|
||||
mdiCards,
|
||||
mdiCardsOutline,
|
||||
mdiFolderOutline,
|
||||
mdiHeart,
|
||||
mdiHeartOutline,
|
||||
@@ -48,6 +50,10 @@
|
||||
<NavbarItem title={$t('map')} href={Route.map()} icon={mdiMapOutline} activeIcon={mdiMap} />
|
||||
{/if}
|
||||
|
||||
{#if authManager.preferences.memories.enabled && authManager.preferences.memories.sidebarWeb}
|
||||
<NavbarItem title={$t('memories')} href={Route.memories()} icon={mdiCardsOutline} activeIcon={mdiCards} />
|
||||
{/if}
|
||||
|
||||
{#if authManager.preferences.people.enabled && authManager.preferences.people.sidebarWeb}
|
||||
<NavbarItem title={$t('people')} href={Route.people()} icon={mdiAccountOutline} activeIcon={mdiAccount} />
|
||||
{/if}
|
||||
|
||||
@@ -44,8 +44,10 @@ export const dateFormats = {
|
||||
|
||||
export enum QueryParameter {
|
||||
ACTION = 'action',
|
||||
ASSET_ID = 'assetId',
|
||||
ID = 'id',
|
||||
IS_OPEN = 'isOpen',
|
||||
IS_SAVED = 'isSaved',
|
||||
OPEN_SETTING = 'openSetting',
|
||||
PREVIOUS_ROUTE = 'previousRoute',
|
||||
QUERY = 'query',
|
||||
|
||||
@@ -2,11 +2,11 @@ import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
|
||||
import type { ZoomImageWheelState } from '@zoom-image/core';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { userPreferencesManager } from '$lib/managers/user-preferences-manager.svelte';
|
||||
import type { ImageLoaderStatus } from '$lib/utils/adaptive-image-loader.svelte';
|
||||
import { canCopyImageToClipboard } from '$lib/utils/asset-utils';
|
||||
import { BaseEventManager } from '$lib/utils/base-event-manager.svelte';
|
||||
import type { AssetGridRouteSearchParams } from '$lib/utils/navigation';
|
||||
import { PersistedLocalStorage } from '$lib/utils/persisted';
|
||||
|
||||
export interface Faces {
|
||||
id: string;
|
||||
@@ -18,9 +18,6 @@ export interface Faces {
|
||||
boundingBoxY2: number;
|
||||
}
|
||||
|
||||
const isShowDetailPanel = new PersistedLocalStorage<boolean>('asset-viewer-state', false);
|
||||
const isShowAssetPath = new PersistedLocalStorage<boolean>('asset-viewer-show-path', false);
|
||||
|
||||
const createDefaultZoomState = (): ZoomImageWheelState => ({
|
||||
currentRotation: 0,
|
||||
currentZoom: 1,
|
||||
@@ -75,11 +72,11 @@ class AssetViewerManager extends BaseEventManager<Events> {
|
||||
}
|
||||
|
||||
get isShowDetailPanel() {
|
||||
return isShowDetailPanel.current;
|
||||
return userPreferencesManager.showDetailPanel;
|
||||
}
|
||||
|
||||
get isShowAssetPath() {
|
||||
return isShowAssetPath.current;
|
||||
return userPreferencesManager.showAssetPath;
|
||||
}
|
||||
|
||||
get isFaceEditMode() {
|
||||
@@ -121,11 +118,11 @@ class AssetViewerManager extends BaseEventManager<Events> {
|
||||
}
|
||||
|
||||
private set isShowDetailPanel(value: boolean) {
|
||||
isShowDetailPanel.current = value;
|
||||
userPreferencesManager.showDetailPanel = value;
|
||||
}
|
||||
|
||||
private set isShowAssetPath(value: boolean) {
|
||||
isShowAssetPath.current = value;
|
||||
userPreferencesManager.showAssetPath = value;
|
||||
}
|
||||
|
||||
onZoomChange(state: ZoomImageWheelState) {
|
||||
|
||||
@@ -1,26 +1,58 @@
|
||||
import { deleteMemory, type MemoryResponseDto, removeMemoryAssets, searchMemories, updateMemory } from '@immich/sdk';
|
||||
import {
|
||||
type AssetResponseDto,
|
||||
MemorySearchOrder,
|
||||
deleteMemory,
|
||||
type MemoryResponseDto,
|
||||
removeMemoryAssets,
|
||||
searchMemories,
|
||||
updateMemory,
|
||||
memoriesStatistics,
|
||||
} from '@immich/sdk';
|
||||
import { toastManager } from '@immich/ui';
|
||||
import { isEqual, omitBy } from 'lodash-es';
|
||||
import { DateTime } from 'luxon';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import { fromStore, get } from 'svelte/store';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { QueryParameter } from '$lib/constants';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { userPreferencesManager } from '$lib/managers/user-preferences-manager.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { memoryLaneTitle } from '$lib/utils';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
|
||||
type MemoryIndex = {
|
||||
memoryIndex: number;
|
||||
assetIndex: number;
|
||||
type MemoriesSearchDto = Parameters<typeof searchMemories>[0];
|
||||
|
||||
type AdjacentMemory = {
|
||||
href: string;
|
||||
assetId: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type MemoryAsset = MemoryIndex & {
|
||||
/** Everything the memory viewer renders, for the position currently in the url. */
|
||||
export type MemoryState = {
|
||||
memory: MemoryResponseDto;
|
||||
asset: TimelineAsset;
|
||||
previousMemory?: MemoryResponseDto;
|
||||
previous?: MemoryAsset;
|
||||
next?: MemoryAsset;
|
||||
nextMemory?: MemoryResponseDto;
|
||||
/** The assets the asset viewer can navigate through, spanning the adjacent memories. */
|
||||
viewerAssets: AssetResponseDto[];
|
||||
previousMemory?: AdjacentMemory;
|
||||
nextMemory?: AdjacentMemory;
|
||||
previousHref?: string;
|
||||
nextHref?: string;
|
||||
getAssetHref: (assetId: string) => string | undefined;
|
||||
};
|
||||
|
||||
class MemoryManager {
|
||||
#loading: Promise<void> | undefined;
|
||||
#loading = $state<Promise<void>>();
|
||||
#filters = $state<MemoriesSearchDto>({ size: 250, order: MemorySearchOrder.Desc });
|
||||
#hasNextPage: boolean = true;
|
||||
#page: number = 1;
|
||||
#total: number | undefined = $state();
|
||||
#queued: boolean = false;
|
||||
|
||||
constructor() {
|
||||
eventManager.on({
|
||||
@@ -36,44 +68,158 @@ class MemoryManager {
|
||||
this.scheduleHourlyRefresh();
|
||||
}
|
||||
|
||||
ready() {
|
||||
get filters() {
|
||||
return this.#filters;
|
||||
}
|
||||
|
||||
setFilters(filters: MemoriesSearchDto) {
|
||||
filters = omitBy(filters, (item) => item === undefined);
|
||||
if (!isEqual(this.#filters, filters)) {
|
||||
this.#filters = filters;
|
||||
this.clearCache();
|
||||
void this.loadNextPage();
|
||||
}
|
||||
}
|
||||
|
||||
refresh() {
|
||||
return this.initialize();
|
||||
}
|
||||
|
||||
memories = $state<MemoryResponseDto[]>([]);
|
||||
private memoryAssets = $derived.by(() => {
|
||||
const memoryAssets: MemoryAsset[] = [];
|
||||
let previous: MemoryAsset | undefined;
|
||||
for (const [memoryIndex, memory] of this.memories.entries()) {
|
||||
for (const [assetIndex, asset] of memory.assets.entries()) {
|
||||
const current = {
|
||||
memory,
|
||||
memoryIndex,
|
||||
previousMemory: this.memories[memoryIndex - 1],
|
||||
nextMemory: this.memories[memoryIndex + 1],
|
||||
asset: toTimelineAsset(asset),
|
||||
assetIndex,
|
||||
previous,
|
||||
};
|
||||
applyPreferences() {
|
||||
const { showUpcoming, onlyFavorites } = userPreferencesManager.memories;
|
||||
this.setFilters({
|
||||
order: MemorySearchOrder.Desc,
|
||||
isSaved: onlyFavorites ? true : undefined,
|
||||
isUpcoming: showUpcoming ? undefined : false,
|
||||
});
|
||||
|
||||
memoryAssets.push(current);
|
||||
|
||||
if (previous) {
|
||||
previous.next = current;
|
||||
}
|
||||
|
||||
previous = current;
|
||||
}
|
||||
}
|
||||
|
||||
return memoryAssets;
|
||||
});
|
||||
|
||||
getMemoryAsset(assetId: string | undefined) {
|
||||
return this.memoryAssets.find((memoryAsset) => memoryAsset.asset.id === assetId) ?? this.memoryAssets[0];
|
||||
return this.refresh();
|
||||
}
|
||||
|
||||
hideAssetsFromMemory(ids: string[]) {
|
||||
memories = $state<MemoryResponseDto[]>([]);
|
||||
|
||||
#memoryLaneTitle = fromStore(memoryLaneTitle);
|
||||
|
||||
#url = $derived({
|
||||
memoryId: page.params.id,
|
||||
assetId: page.url.searchParams.get(QueryParameter.ASSET_ID) ?? undefined,
|
||||
isSaved: page.url.searchParams.get(QueryParameter.IS_SAVED) === 'true' || undefined,
|
||||
});
|
||||
|
||||
memoriesHref = $derived(Route.memories({ isSaved: this.#url.isSaved }));
|
||||
|
||||
#lookup = $derived.by(() => {
|
||||
const lookup = new SvelteMap<string, { memoryIndex: number; assetIndexes: Map<string, number> }>();
|
||||
|
||||
for (const [memoryIndex, memory] of this.memories.entries()) {
|
||||
const assetIndexes = new Map(memory.assets.map((asset, assetIndex) => [asset.id, assetIndex] as const));
|
||||
lookup.set(memory.id, { memoryIndex, assetIndexes });
|
||||
}
|
||||
|
||||
return lookup;
|
||||
});
|
||||
|
||||
current = $derived.by((): MemoryState | undefined => {
|
||||
const { memoryId, assetId } = this.#url;
|
||||
if (memoryId === undefined || assetId === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const location = this.#lookup.get(memoryId);
|
||||
if (!location) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { memoryIndex, assetIndexes } = location;
|
||||
const assetIndex = assetIndexes.get(assetId);
|
||||
if (assetIndex === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const memory = this.memories[memoryIndex];
|
||||
const previousMemory = this.memories[memoryIndex - 1];
|
||||
const nextMemory = this.memories[memoryIndex + 1];
|
||||
|
||||
return {
|
||||
memory,
|
||||
asset: toTimelineAsset(memory.assets[assetIndex]),
|
||||
viewerAssets: [...(previousMemory?.assets ?? []), ...memory.assets, ...(nextMemory?.assets ?? [])],
|
||||
previousMemory: this.#toAdjacentMemory(previousMemory),
|
||||
nextMemory: this.#toAdjacentMemory(nextMemory),
|
||||
previousHref:
|
||||
assetIndex > 0
|
||||
? this.#toHref(memory, memory.assets[assetIndex - 1])
|
||||
: this.#toHref(previousMemory, previousMemory?.assets.at(-1)),
|
||||
nextHref:
|
||||
assetIndex < memory.assets.length - 1
|
||||
? this.#toHref(memory, memory.assets[assetIndex + 1])
|
||||
: this.#toHref(nextMemory, nextMemory?.assets[0]),
|
||||
getAssetHref: (assetId: string) => this.#toHref(memory, { id: assetId }),
|
||||
};
|
||||
});
|
||||
|
||||
#toHref(memory: { id: string } | undefined, asset: { id: string } | undefined) {
|
||||
return memory && asset
|
||||
? Route.viewMemory({ id: memory.id, assetId: asset.id, isSaved: this.#url.isSaved })
|
||||
: undefined;
|
||||
}
|
||||
|
||||
#toAdjacentMemory(memory: MemoryResponseDto | undefined): AdjacentMemory | undefined {
|
||||
if (!memory) {
|
||||
return;
|
||||
}
|
||||
|
||||
const asset = memory.assets[0];
|
||||
const href = this.#toHref(memory, asset);
|
||||
if (!href) {
|
||||
return;
|
||||
}
|
||||
|
||||
return { href, assetId: asset.id, title: this.#memoryLaneTitle.current(memory) };
|
||||
}
|
||||
|
||||
// the memory holding an asset, checking the given memory first, then the ones next to it
|
||||
getMemoryWithAsset(assetId: string, memoryId: string) {
|
||||
const location = this.#lookup.get(memoryId);
|
||||
if (!location) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { memoryIndex } = location;
|
||||
return [memoryIndex, memoryIndex - 1, memoryIndex + 1]
|
||||
.map((index) => this.memories[index])
|
||||
.find((memory) => memory && this.#lookup.get(memory.id)?.assetIndexes.has(assetId));
|
||||
}
|
||||
|
||||
#getMemory(id: string) {
|
||||
const location = this.#lookup.get(id);
|
||||
return location ? this.memories[location.memoryIndex] : undefined;
|
||||
}
|
||||
|
||||
/** Fetch a memory that is not part of the loaded pages, i.e. when opening a link to an older one. */
|
||||
async loadMemory(id: string) {
|
||||
const memory = this.#getMemory(id);
|
||||
if (memory) {
|
||||
return memory;
|
||||
}
|
||||
|
||||
const [loaded] = await searchMemories({ id }).catch(() => []);
|
||||
if (!loaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
// memories are ordered by date, descending
|
||||
const index = this.memories.findIndex(({ memoryAt }) => memoryAt < loaded.memoryAt);
|
||||
this.memories.splice(index === -1 ? this.memories.length : index, 0, loaded);
|
||||
|
||||
return loaded;
|
||||
}
|
||||
|
||||
async hideAssets(ids: string[]) {
|
||||
if (this.current && ids.includes(this.current.asset.id)) {
|
||||
await this.#leaveCurrentAsset();
|
||||
}
|
||||
|
||||
const idSet = new Set<string>(ids);
|
||||
for (const memory of this.memories) {
|
||||
memory.assets = memory.assets.filter((asset) => !idSet.has(asset.id));
|
||||
@@ -82,56 +228,135 @@ class MemoryManager {
|
||||
this.memories = this.memories.filter((memory) => memory.assets.length > 0);
|
||||
}
|
||||
|
||||
async deleteMemory(id: string) {
|
||||
const memory = this.memories.find((memory) => memory.id === id);
|
||||
if (memory) {
|
||||
await deleteMemory({ id: memory.id });
|
||||
this.memories = this.memories.filter((memory) => memory.id !== id);
|
||||
async deleteCurrentAsset() {
|
||||
const current = this.current;
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const memoryId = current.memory.id;
|
||||
const assetId = current.asset.id;
|
||||
await this.#leaveCurrentAsset();
|
||||
await this.#deleteAsset(memoryId, assetId);
|
||||
}
|
||||
|
||||
async deleteAssetFromMemory(assetId: string) {
|
||||
const memoryWithAsset = this.memories.find((memory) => memory.assets.some((asset) => asset.id === assetId));
|
||||
async deleteCurrentMemory() {
|
||||
const current = this.current;
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (memoryWithAsset) {
|
||||
if (memoryWithAsset.assets.length === 1) {
|
||||
await this.deleteMemory(memoryWithAsset.id);
|
||||
const { id } = current.memory;
|
||||
await this.#goto(current.nextMemory?.href ?? current.previousMemory?.href ?? this.memoriesHref);
|
||||
await this.#deleteMemory(id);
|
||||
toastManager.primary(get(t)('removed_memory'));
|
||||
}
|
||||
|
||||
async toggleCurrentMemorySaved() {
|
||||
const memory = this.current?.memory;
|
||||
if (!memory) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isSaved = !memory.isSaved;
|
||||
await updateMemory({ id: memory.id, memoryUpdateDto: { isSaved } });
|
||||
memory.isSaved = isSaved;
|
||||
toastManager.primary(get(t)(isSaved ? 'added_to_favorites' : 'removed_from_favorites'));
|
||||
}
|
||||
|
||||
// navigate away before removing something, so the url never points at a deleted position
|
||||
#leaveCurrentAsset() {
|
||||
const { nextHref, previousHref } = this.current ?? {};
|
||||
return this.#goto(nextHref ?? previousHref ?? this.memoriesHref);
|
||||
}
|
||||
|
||||
#goto(href: string) {
|
||||
return goto(href, { replaceState: true, noScroll: true, keepFocus: true });
|
||||
}
|
||||
|
||||
async #deleteMemory(id: string) {
|
||||
if (!this.#getMemory(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await deleteMemory({ id });
|
||||
this.memories = this.memories.filter((memory) => memory.id !== id);
|
||||
}
|
||||
|
||||
async #deleteAsset(memoryId: string, assetId: string) {
|
||||
const memory = this.#getMemory(memoryId);
|
||||
if (!memory?.assets.some((asset) => asset.id === assetId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (memory.assets.length === 1) {
|
||||
await this.#deleteMemory(memoryId);
|
||||
return;
|
||||
}
|
||||
|
||||
await removeMemoryAssets({ id: memoryId, bulkIdsDto: { ids: [assetId] } });
|
||||
memory.assets = memory.assets.filter((asset) => asset.id !== assetId);
|
||||
}
|
||||
|
||||
loadNextPage() {
|
||||
if (this.#hasNextPage) {
|
||||
if (this.#loading === undefined) {
|
||||
this.#loading = this.load(this.#page++);
|
||||
} else {
|
||||
await removeMemoryAssets({ id: memoryWithAsset.id, bulkIdsDto: { ids: [assetId] } });
|
||||
memoryWithAsset.assets = memoryWithAsset.assets.filter((asset) => asset.id !== assetId);
|
||||
this.#queued = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async updateMemorySaved(id: string, isSaved: boolean) {
|
||||
const memory = this.memories.find((memory) => memory.id === id);
|
||||
if (memory) {
|
||||
await updateMemory({
|
||||
id,
|
||||
memoryUpdateDto: {
|
||||
isSaved,
|
||||
},
|
||||
});
|
||||
memory.isSaved = isSaved;
|
||||
}
|
||||
get hasNextPage() {
|
||||
return this.#hasNextPage;
|
||||
}
|
||||
|
||||
get total() {
|
||||
return this.#total;
|
||||
}
|
||||
|
||||
get loading() {
|
||||
return this.#loading;
|
||||
}
|
||||
|
||||
private clearCache() {
|
||||
this.#loading = undefined;
|
||||
this.#hasNextPage = true;
|
||||
this.#page = 1;
|
||||
this.#total = undefined;
|
||||
this.memories = [];
|
||||
}
|
||||
|
||||
private initialize() {
|
||||
if (!this.#loading) {
|
||||
this.#loading = this.load();
|
||||
this.#loading = this.load(this.#page++);
|
||||
}
|
||||
|
||||
return this.#loading;
|
||||
}
|
||||
|
||||
private async load() {
|
||||
const memories = await searchMemories({ $for: DateTime.now().toFormat('yyyy-MM-dd') });
|
||||
this.memories = memories.filter((memory) => memory.assets.length > 0);
|
||||
private async load(page: number) {
|
||||
const items = await searchMemories({ ...this.#filters, page });
|
||||
for (const item of items) {
|
||||
if (!this.#lookup.has(item.id)) {
|
||||
this.memories.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.#total === undefined) {
|
||||
const { total } = await memoriesStatistics(this.#filters);
|
||||
this.#total = total;
|
||||
}
|
||||
|
||||
this.#hasNextPage = this.memories.length < this.#total;
|
||||
this.#loading = undefined;
|
||||
|
||||
if (this.#queued) {
|
||||
this.#queued = false;
|
||||
this.#loading = this.load(this.#page++);
|
||||
await this.#loading;
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleHourlyRefresh() {
|
||||
@@ -145,12 +370,19 @@ class MemoryManager {
|
||||
const initialDelay = nextEvent.diff(now).as('milliseconds');
|
||||
|
||||
setTimeout(() => {
|
||||
this.#loading = this.load();
|
||||
if (this.#page <= 2) {
|
||||
this.clearCache();
|
||||
this.loadNextPage();
|
||||
}
|
||||
|
||||
// Schedule subsequent events hourly
|
||||
setInterval(
|
||||
() => {
|
||||
this.#loading = this.load();
|
||||
if (this.#page > 2) {
|
||||
return;
|
||||
}
|
||||
this.clearCache();
|
||||
this.loadNextPage();
|
||||
},
|
||||
60 * 60 * 1000,
|
||||
);
|
||||
|
||||
@@ -17,8 +17,8 @@ import {
|
||||
retrieveRange as retrieveRangeUtil,
|
||||
} from '$lib/managers/timeline-manager/internal/search-support.svelte';
|
||||
import { WebsocketSupport } from '$lib/managers/timeline-manager/internal/websocket-support.svelte';
|
||||
import { userPreferencesManager } from '$lib/managers/user-preferences-manager.svelte';
|
||||
import { CancellableTask } from '$lib/utils/cancellable-task';
|
||||
import { PersistedLocalStorage } from '$lib/utils/persisted';
|
||||
import {
|
||||
getOrderingDate,
|
||||
isAssetResponseDto,
|
||||
@@ -94,19 +94,18 @@ export class TimelineManager extends VirtualScrollManager {
|
||||
#options: TimelineManagerOptions = TimelineManager.#INIT_OPTIONS;
|
||||
#updatingViewportProximities = false;
|
||||
#scrollableElement: HTMLElement | undefined = $state();
|
||||
#showAssetOwners = new PersistedLocalStorage<boolean>('album-show-asset-owners', false);
|
||||
#unsubscribes: Array<() => void> = [];
|
||||
|
||||
get showAssetOwners() {
|
||||
return this.#showAssetOwners.current;
|
||||
return userPreferencesManager.showAssetOwners;
|
||||
}
|
||||
|
||||
setShowAssetOwners(value: boolean) {
|
||||
this.#showAssetOwners.current = value;
|
||||
userPreferencesManager.showAssetOwners = value;
|
||||
}
|
||||
|
||||
toggleShowAssetOwners() {
|
||||
this.#showAssetOwners.current = !this.#showAssetOwners.current;
|
||||
userPreferencesManager.showAssetOwners = !userPreferencesManager.showAssetOwners;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { PersistedLocalStorage } from '$lib/utils/persisted';
|
||||
|
||||
export type MemoriesPreferences = {
|
||||
showUpcoming: boolean;
|
||||
onlyFavorites: boolean;
|
||||
};
|
||||
|
||||
class UserPreferencesManager {
|
||||
#showDetailPanel = new PersistedLocalStorage<boolean>('asset-viewer-state', false);
|
||||
#showAssetPath = new PersistedLocalStorage<boolean>('asset-viewer-show-path', false);
|
||||
#showAssetOwners = new PersistedLocalStorage<boolean>('album-show-asset-owners', false);
|
||||
#defaultMemories: MemoriesPreferences = { showUpcoming: false, onlyFavorites: false };
|
||||
#memories = new PersistedLocalStorage<MemoriesPreferences>(
|
||||
'memories-settings',
|
||||
{ ...this.#defaultMemories },
|
||||
{
|
||||
upgrade: 'merge',
|
||||
},
|
||||
);
|
||||
|
||||
get showDetailPanel() {
|
||||
return this.#showDetailPanel.current;
|
||||
}
|
||||
|
||||
set showDetailPanel(value: boolean) {
|
||||
this.#showDetailPanel.current = value;
|
||||
}
|
||||
|
||||
get showAssetPath() {
|
||||
return this.#showAssetPath.current;
|
||||
}
|
||||
|
||||
set showAssetPath(value: boolean) {
|
||||
this.#showAssetPath.current = value;
|
||||
}
|
||||
|
||||
get showAssetOwners() {
|
||||
return this.#showAssetOwners.current;
|
||||
}
|
||||
|
||||
set showAssetOwners(value: boolean) {
|
||||
this.#showAssetOwners.current = value;
|
||||
}
|
||||
|
||||
get memories() {
|
||||
return this.#memories.current;
|
||||
}
|
||||
|
||||
set memories(value: MemoriesPreferences) {
|
||||
this.#memories.current = value;
|
||||
}
|
||||
|
||||
hasMemoryPreferences() {
|
||||
return !isEqual(this.memories, this.#defaultMemories);
|
||||
}
|
||||
}
|
||||
|
||||
export const userPreferencesManager = new UserPreferencesManager();
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { userPreferencesManager, type MemoriesPreferences } from '$lib/managers/user-preferences-manager.svelte';
|
||||
import { Field, FormModal, Stack, Switch } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
onClose: (settings?: MemoriesPreferences) => void;
|
||||
};
|
||||
|
||||
let { onClose }: Props = $props();
|
||||
let settings = $state({ ...userPreferencesManager.memories });
|
||||
|
||||
const onSubmit = () => {
|
||||
onClose(settings);
|
||||
};
|
||||
</script>
|
||||
|
||||
<FormModal title={$t('filters')} {onClose} {onSubmit} size="small">
|
||||
<Stack gap={4}>
|
||||
<Field label={$t('memories_show_upcoming')}>
|
||||
<Switch bind:checked={settings.showUpcoming} />
|
||||
</Field>
|
||||
<Field label={$t('only_favorites')}>
|
||||
<Switch bind:checked={settings.onlyFavorites} />
|
||||
</Field>
|
||||
</Stack>
|
||||
</FormModal>
|
||||
@@ -13,7 +13,7 @@ export const fromQueueSlug = (slug: string): QueueName | undefined => {
|
||||
}
|
||||
};
|
||||
|
||||
type QueryValue = number | string;
|
||||
type QueryValue = number | string | boolean;
|
||||
const asQueryString = (
|
||||
params?: Record<string, QueryValue | undefined>,
|
||||
options?: { skipEmptyStrings?: boolean; skipNullValues?: boolean },
|
||||
@@ -77,7 +77,11 @@ export const Route = {
|
||||
'/map' + (point ? `#${point.zoom}/${point.lat}/${point.lng}` : ''),
|
||||
|
||||
// memories
|
||||
memories: (params?: { id?: string }) => '/memory' + asQueryString(params),
|
||||
memories: (params?: { isSaved?: boolean }) => '/memories' + asQueryString(params),
|
||||
viewMemory: ({ id, ...params }: { id: string; assetId?: string; isSaved?: boolean }) =>
|
||||
`/memories/${id}` + asQueryString(params),
|
||||
viewMemoryAsset: ({ id, assetId, ...params }: { id: string; assetId: string; isSaved?: boolean }) =>
|
||||
`/memories/${id}/photos/${assetId}` + asQueryString({ assetId, ...params }),
|
||||
|
||||
// partners
|
||||
viewPartner: ({ id }: { id: string }) => `/partners/${id}`,
|
||||
|
||||
@@ -20,11 +20,12 @@ import {
|
||||
type UserResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { toastManager, type ActionItem, type IfLike } from '@immich/ui';
|
||||
import { DateTime } from 'luxon';
|
||||
import { init, register, t } from 'svelte-i18n';
|
||||
import { derived, get } from 'svelte/store';
|
||||
import { defaultLang, locales } from '$lib/constants';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { alwaysLoadOriginalFile, lang } from '$lib/stores/preferences.store';
|
||||
import { alwaysLoadOriginalFile, lang, locale } from '$lib/stores/preferences.store';
|
||||
import { isWebCompatibleImage } from '$lib/utils/asset-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { convertBCP47, langs } from '$lib/utils/i18n';
|
||||
@@ -387,9 +388,13 @@ export const handlePromiseError = <T>(promise: Promise<T>): void => {
|
||||
|
||||
export const memoryLaneTitle = derived(t, ($t) => {
|
||||
return (memory: MemoryResponseDto) => {
|
||||
const now = new Date();
|
||||
if (memory.type === MemoryType.OnThisDay) {
|
||||
return $t('years_ago', { values: { years: now.getFullYear() - memory.data.year } });
|
||||
const now = new Date();
|
||||
const memoryDate = new Date(memory.memoryAt);
|
||||
|
||||
return memoryDate.getUTCDate() === now.getDate() && memoryDate.getUTCMonth() === now.getMonth()
|
||||
? $t('years_ago', { values: { years: now.getFullYear() - memory.data.year } })
|
||||
: DateTime.fromJSDate(memoryDate).toLocaleString(DateTime.DATE_MED, { locale: get(locale) });
|
||||
}
|
||||
|
||||
return $t('unknown');
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
import SingleGridRow from '$lib/components/shared-components/SingleGridRow.svelte';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { getAssetMediaUrl, getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { getAssetMediaUrl, getPeopleThumbnailUrl, memoryLaneTitle } from '$lib/utils';
|
||||
import { getAssetInfo, AssetMediaSize, type SearchExploreResponseDto } from '@immich/sdk';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { Icon } from '@immich/ui';
|
||||
import { Icon, ImageCarousel } from '@immich/ui';
|
||||
import { mdiHeart } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { PageData } from './$types';
|
||||
@@ -28,13 +28,22 @@
|
||||
return targetField?.items || [];
|
||||
};
|
||||
|
||||
let places = $derived(getFieldItems(data.items, 'exifInfo.city'));
|
||||
let places = $derived(getFieldItems(data.explore, 'exifInfo.city'));
|
||||
let recents = $derived(
|
||||
getFieldItems(data.items, 'createdAt').sort((a, b) => new Date(b.value).getTime() - new Date(a.value).getTime()),
|
||||
getFieldItems(data.explore, 'createdAt').sort((a, b) => new Date(b.value).getTime() - new Date(a.value).getTime()),
|
||||
);
|
||||
let people = $state(data.people.people);
|
||||
let memories = $derived(
|
||||
data.memories.map((memory) => ({
|
||||
id: memory.id,
|
||||
title: $memoryLaneTitle(memory),
|
||||
href: Route.viewMemory({ id: memory.id, assetId: memory.assets[0].id }),
|
||||
alt: $t('memory_lane_title', { values: { title: $getAltText(toTimelineAsset(memory.assets[0])) } }),
|
||||
src: getAssetMediaUrl({ id: memory.assets[0].id }),
|
||||
})),
|
||||
);
|
||||
let people = $state(data.response.people);
|
||||
|
||||
let hasPeople = $derived(data.response.total > 0);
|
||||
let hasPeople = $derived(data.people.total > 0);
|
||||
|
||||
const onPersonThumbnailReady = ({ id }: { id: string }) => {
|
||||
for (const person of people) {
|
||||
@@ -124,6 +133,20 @@
|
||||
</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,16 +1,24 @@
|
||||
import { getAllPeople, getExploreData } from '@immich/sdk';
|
||||
import { getAllPeople, getExploreData, MemorySearchOrder } from '@immich/sdk';
|
||||
import { memoryManager } from '$lib/managers/memory-manager.svelte';
|
||||
import { authenticate } from '$lib/utils/auth';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async ({ url }) => {
|
||||
await authenticate(url);
|
||||
const [items, response] = await Promise.all([getExploreData(), getAllPeople({ withHidden: false })]);
|
||||
memoryManager.setFilters({ size: 12, order: MemorySearchOrder.Desc });
|
||||
|
||||
const [explore, people] = await Promise.all([
|
||||
getExploreData(),
|
||||
getAllPeople({ withHidden: false }),
|
||||
memoryManager.refresh(),
|
||||
]);
|
||||
const $t = await getFormatter();
|
||||
|
||||
return {
|
||||
items,
|
||||
response,
|
||||
explore,
|
||||
people,
|
||||
memories: memoryManager.memories,
|
||||
meta: {
|
||||
title: $t('explore'),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
<script lang="ts">
|
||||
import UserPageLayout from '$lib/components/layouts/UserPageLayout.svelte';
|
||||
import { memoryManager } from '$lib/managers/memory-manager.svelte';
|
||||
import { userPreferencesManager } from '$lib/managers/user-preferences-manager.svelte';
|
||||
import MemoriesSettingsModal from '$lib/modals/MemoriesSettingsModal.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { getAssetMediaUrl, memoryLaneTitle } from '$lib/utils';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import type { MemoryResponseDto } from '@immich/sdk';
|
||||
import { Icon, IconButton, LoadingSpinner, modalManager, type CarouselImageItem } from '@immich/ui';
|
||||
import { mdiHeart, mdiTune } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
type Props = {
|
||||
data: PageData;
|
||||
};
|
||||
|
||||
const { data }: Props = $props();
|
||||
|
||||
// memories are generated a few days ahead of time, and sorted by `showAt`, so the ones that have
|
||||
// not been shown yet come first
|
||||
const isUpcoming = (memory: MemoryResponseDto) => !!memory.showAt && new Date(memory.showAt).getTime() > Date.now();
|
||||
const upcomingCount = $derived(memoryManager.memories.filter((memory) => isUpcoming(memory)).length);
|
||||
const hasUpcoming = $derived(upcomingCount > 0);
|
||||
|
||||
const handleSettings = async () => {
|
||||
const settings = await modalManager.show(MemoriesSettingsModal);
|
||||
if (!settings) {
|
||||
return;
|
||||
}
|
||||
|
||||
userPreferencesManager.memories = settings;
|
||||
await memoryManager.applyPreferences();
|
||||
};
|
||||
</script>
|
||||
|
||||
{#snippet card(item: CarouselImageItem & { isSaved?: boolean })}
|
||||
<a
|
||||
class="item-card relative me-2 inline-block aspect-3/4 size-full overflow-hidden rounded-xl last:me-0 max-md:h-37.5 md:me-4 md:aspect-4/3 xl:aspect-video"
|
||||
href={item.href}
|
||||
>
|
||||
<img class="size-full rounded-xl object-cover" src={item.src} alt={item.alt ?? item.title} draggable="false" />
|
||||
{#if item.isSaved}
|
||||
<div class="absolute inset-s-2 top-2 p-1">
|
||||
<Icon data-icon-favorite icon={mdiHeart} size="32" class="text-white" />
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
class="absolute inset-s-0 top-0 size-full w-full rounded-xl bg-linear-to-t from-black/40 via-transparent to-transparent transition-all hover:bg-black/20"
|
||||
></div>
|
||||
<p class="absolute inset-s-4 bottom-2 text-lg text-white max-md:text-sm">
|
||||
{item.title}
|
||||
</p>
|
||||
</a>
|
||||
{/snippet}
|
||||
|
||||
<UserPageLayout
|
||||
title={data.meta.title}
|
||||
description={memoryManager.total === undefined ? undefined : `(${memoryManager.total.toLocaleString($locale)})`}
|
||||
>
|
||||
{#snippet buttons()}
|
||||
<div class="flex place-items-center gap-2">
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
indicator={userPreferencesManager.hasMemoryPreferences() ? 'primary' : undefined}
|
||||
icon={mdiTune}
|
||||
aria-label={$t('filters')}
|
||||
onclick={handleSettings}
|
||||
/>
|
||||
</div>
|
||||
{/snippet}
|
||||
{#if memoryManager.memories.length > 0}
|
||||
<div class="grid w-full grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||
{#if hasUpcoming}
|
||||
<div class="col-span-full outline-none">{$t('memories_upcoming')}</div>
|
||||
{/if}
|
||||
{#each memoryManager.memories as memory, index (memory.id)}
|
||||
{#if hasUpcoming && index === upcomingCount}
|
||||
<div class="col-span-full outline-none">{$t('memories_current')}</div>
|
||||
{/if}
|
||||
{@render card({
|
||||
title: $memoryLaneTitle(memory),
|
||||
href: Route.viewMemory({
|
||||
id: memory.id,
|
||||
assetId: memory.assets[0].id,
|
||||
isSaved: userPreferencesManager.memories.onlyFavorites || undefined,
|
||||
}),
|
||||
src: getAssetMediaUrl({ id: memory.assets[0].id }),
|
||||
alt: $getAltText(toTimelineAsset(memory.assets[0])),
|
||||
isSaved: memory.isSaved,
|
||||
id: memory.id,
|
||||
})}
|
||||
{/each}
|
||||
</div>
|
||||
{:else if memoryManager.loading}
|
||||
<div class="flex items-center justify-center py-16">
|
||||
<LoadingSpinner size="giant" />
|
||||
</div>
|
||||
{/if}
|
||||
</UserPageLayout>
|
||||
@@ -0,0 +1,18 @@
|
||||
import { memoryManager } from '$lib/managers/memory-manager.svelte';
|
||||
import { authenticate } from '$lib/utils/auth';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async ({ url }) => {
|
||||
const user = await authenticate(url);
|
||||
const $t = await getFormatter();
|
||||
|
||||
await memoryManager.applyPreferences();
|
||||
|
||||
return {
|
||||
user,
|
||||
meta: {
|
||||
title: $t('memories'),
|
||||
},
|
||||
};
|
||||
}) satisfies PageLoad;
|
||||
@@ -0,0 +1,51 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { QueryParameter, UUID_REGEX } from '$lib/constants';
|
||||
import { memoryManager } from '$lib/managers/memory-manager.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { authenticate } from '$lib/utils/auth';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async ({ url, params }) => {
|
||||
await authenticate(url);
|
||||
const $t = await getFormatter();
|
||||
|
||||
if (!UUID_REGEX.test(params.id)) {
|
||||
redirect(307, Route.memories());
|
||||
}
|
||||
|
||||
await memoryManager.loading;
|
||||
|
||||
const memory = await memoryManager.loadMemory(params.id);
|
||||
if (!memory) {
|
||||
redirect(307, memoryManager.memoriesHref);
|
||||
}
|
||||
|
||||
if (params.assetId) {
|
||||
const targetMemory = memoryManager.getMemoryWithAsset(params.assetId, memory.id);
|
||||
if (targetMemory && targetMemory.id !== memory.id) {
|
||||
redirect(307, Route.viewMemoryAsset({ id: targetMemory.id, assetId: params.assetId }));
|
||||
}
|
||||
}
|
||||
|
||||
const assetIds = new Set(memory.assets.map((asset) => asset.id));
|
||||
const assetId =
|
||||
[params.assetId, url.searchParams.get(QueryParameter.ASSET_ID)].find((id) => id && assetIds.has(id)) ??
|
||||
memory.assets[0]?.id;
|
||||
|
||||
if (!assetId) {
|
||||
redirect(307, memoryManager.memoriesHref);
|
||||
}
|
||||
|
||||
if (url.searchParams.get(QueryParameter.ASSET_ID) !== assetId) {
|
||||
const target = new URL(url);
|
||||
target.searchParams.set(QueryParameter.ASSET_ID, assetId);
|
||||
redirect(307, target.pathname + target.search);
|
||||
}
|
||||
|
||||
return {
|
||||
meta: {
|
||||
title: $t('memory'),
|
||||
},
|
||||
};
|
||||
}) satisfies PageLoad;
|
||||
+69
-162
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { afterNavigate, goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/ButtonContextMenu.svelte';
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
|
||||
@@ -15,11 +14,10 @@
|
||||
import FavoriteAction from '$lib/components/timeline/actions/FavoriteAction.svelte';
|
||||
import TagAction from '$lib/components/timeline/actions/TagAction.svelte';
|
||||
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
||||
import { QueryParameter } from '$lib/constants';
|
||||
import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { memoryManager, type MemoryAsset } from '$lib/managers/memory-manager.svelte';
|
||||
import { memoryManager } from '$lib/managers/memory-manager.svelte';
|
||||
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
|
||||
import { Route } from '$lib/route';
|
||||
import { getAssetBulkActions } from '$lib/services/asset.service';
|
||||
@@ -27,7 +25,7 @@
|
||||
import { getAssetMediaUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils';
|
||||
import { fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { AssetMediaSize, AssetTypeEnum, getAssetInfo } from '@immich/sdk';
|
||||
import { ActionButton, IconButton, Text, toastManager } from '@immich/ui';
|
||||
import { ActionButton, IconButton, Text } from '@immich/ui';
|
||||
import {
|
||||
mdiCardsOutline,
|
||||
mdiChevronDown,
|
||||
@@ -46,9 +44,9 @@
|
||||
mdiVolumeHigh,
|
||||
mdiVolumeOff,
|
||||
} from '@mdi/js';
|
||||
import type { NavigationTarget, Page } from '@sveltejs/kit';
|
||||
import { DateTime } from 'luxon';
|
||||
import 'media-chrome/media-mute-button';
|
||||
import { untrack } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { Attachment } from 'svelte/attachments';
|
||||
import { Tween } from 'svelte/motion';
|
||||
@@ -61,19 +59,15 @@
|
||||
let galleryFirstLoad = $state(true);
|
||||
let playerInitialized = $state(false);
|
||||
let paused = $state(false);
|
||||
let current = $state<MemoryAsset | undefined>(undefined);
|
||||
const current = $derived(memoryManager.current);
|
||||
const currentAssetId = $derived(current?.asset.id);
|
||||
// where the asset sits in its memory, for the progress bar and counter
|
||||
const assetIndex = $derived(current ? current.memory.assets.findIndex(({ id }) => id === currentAssetId) : -1);
|
||||
const currentMemoryAssetFull = $derived.by(async () =>
|
||||
currentAssetId ? await getAssetInfo({ ...authManager.params, id: currentAssetId }) : undefined,
|
||||
);
|
||||
let currentTimelineAssets = $derived(current?.memory.assets ?? []);
|
||||
let viewerAssets = $derived([
|
||||
...(current?.previousMemory?.assets ?? []),
|
||||
...(current?.memory.assets ?? []),
|
||||
...(current?.nextMemory?.assets ?? []),
|
||||
]);
|
||||
|
||||
let isSaved = $derived(current?.memory.isSaved);
|
||||
let viewerHeight = $state(0);
|
||||
|
||||
const viewport: Viewport = $state({ width: 0, height: 0 });
|
||||
@@ -81,18 +75,12 @@
|
||||
const galleryViewport: Viewport = $derived({ height: viewport.height, width: viewport.width - 32 });
|
||||
let progressBarController: Tween<number> | undefined = $state(undefined);
|
||||
let videoPlayer: HTMLVideoElement | undefined = $state();
|
||||
const asHref = (asset: { id: string }) => `?${QueryParameter.ID}=${asset.id}`;
|
||||
|
||||
const handleNavigate = async (asset?: { id: string }) => {
|
||||
if (assetViewerManager.isViewing) {
|
||||
return asset;
|
||||
}
|
||||
|
||||
if (!asset) {
|
||||
const handleNavigate = async (href: string | undefined, options?: { replaceState?: boolean }) => {
|
||||
if (assetViewerManager.isViewing || !href) {
|
||||
return;
|
||||
}
|
||||
|
||||
await goto(asHref(asset));
|
||||
await goto(href, { noScroll: true, keepFocus: true, ...options });
|
||||
};
|
||||
|
||||
const setProgressDuration = (asset: TimelineAsset) => {
|
||||
@@ -102,11 +90,7 @@
|
||||
});
|
||||
};
|
||||
|
||||
const handleNextAsset = () => handleNavigate(current?.next?.asset);
|
||||
const handlePreviousAsset = () => handleNavigate(current?.previous?.asset);
|
||||
const handleNextMemory = () => handleNavigate(current?.nextMemory?.assets[0]);
|
||||
const handlePreviousMemory = () => handleNavigate(current?.previousMemory?.assets[0]);
|
||||
const handleEscape = async () => goto(Route.photos());
|
||||
const handleEscape = async () => goto(memoryManager.memoriesHref);
|
||||
const handleSelectAll = () =>
|
||||
assetMultiSelectManager.selectAssets(current?.memory.assets.map((a) => toTimelineAsset(a)) || []);
|
||||
|
||||
@@ -156,60 +140,28 @@
|
||||
}
|
||||
|
||||
if (progress === 1 && !paused) {
|
||||
await (current?.next ? handleNextAsset() : handlePromiseError(handleAction('handleProgressLast', 'pause')));
|
||||
if (current?.nextHref) {
|
||||
await handleNavigate(current.nextHref, { replaceState: true });
|
||||
} else {
|
||||
await handleAction('handleProgressLast', 'pause');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const toProgressPercentage = (index: number) => {
|
||||
if (!progressBarController || current?.assetIndex === undefined) {
|
||||
if (!progressBarController || assetIndex === -1) {
|
||||
return 0;
|
||||
}
|
||||
if (index < current?.assetIndex) {
|
||||
if (index < assetIndex) {
|
||||
return 100;
|
||||
}
|
||||
if (index > current?.assetIndex) {
|
||||
if (index > assetIndex) {
|
||||
return 0;
|
||||
}
|
||||
return progressBarController.current * 100;
|
||||
};
|
||||
|
||||
const handleDeleteOrArchiveAssets = (ids: string[]) => {
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
memoryManager.hideAssetsFromMemory(ids);
|
||||
init(page);
|
||||
};
|
||||
|
||||
const handleDeleteMemoryAsset = async () => {
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
|
||||
await memoryManager.deleteAssetFromMemory(current.asset.id);
|
||||
init(page);
|
||||
};
|
||||
|
||||
const handleDeleteMemory = async () => {
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
|
||||
await memoryManager.deleteMemory(current.memory.id);
|
||||
toastManager.primary($t('removed_memory'));
|
||||
init(page);
|
||||
};
|
||||
|
||||
const handleSaveMemory = async () => {
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newSavedState = !current.memory.isSaved;
|
||||
await memoryManager.updateMemorySaved(current.memory.id, newSavedState);
|
||||
toastManager.primary(newSavedState ? $t('added_to_favorites') : $t('removed_from_favorites'));
|
||||
init(page);
|
||||
};
|
||||
const handleHideAssets = (ids: string[]) => handlePromiseError(memoryManager.hideAssets(ids));
|
||||
|
||||
const handleGalleryScrollsIntoView = () => {
|
||||
galleryInView = true;
|
||||
@@ -242,22 +194,13 @@
|
||||
return () => observer.disconnect();
|
||||
};
|
||||
|
||||
const loadFromParams = (page: Page | NavigationTarget | null) => {
|
||||
const assetId = page?.params?.assetId ?? page?.url.searchParams.get(QueryParameter.ID) ?? undefined;
|
||||
return memoryManager.getMemoryAsset(assetId);
|
||||
};
|
||||
|
||||
const init = (target: Page | NavigationTarget | null) => {
|
||||
if (memoryManager.memories.length === 0) {
|
||||
return handlePromiseError(goto(Route.photos()));
|
||||
}
|
||||
|
||||
current = loadFromParams(target);
|
||||
const initCurrent = () => {
|
||||
// Adjust the progress bar duration to the video length
|
||||
if (current) {
|
||||
setProgressDuration(current.asset);
|
||||
}
|
||||
playerInitialized = false;
|
||||
initPlayer();
|
||||
};
|
||||
|
||||
const resetAndPlay = () => {
|
||||
@@ -280,25 +223,18 @@
|
||||
playerInitialized = true;
|
||||
};
|
||||
|
||||
afterNavigate(({ from, to }) => {
|
||||
memoryManager
|
||||
.ready()
|
||||
.then(() => {
|
||||
let target;
|
||||
if (to?.params?.assetId) {
|
||||
target = to;
|
||||
} else if (from?.params?.assetId) {
|
||||
target = from;
|
||||
} else {
|
||||
target = page;
|
||||
}
|
||||
$effect(() => {
|
||||
// restart playback whenever the current asset changes
|
||||
void currentAssetId;
|
||||
untrack(initCurrent);
|
||||
});
|
||||
|
||||
init(target);
|
||||
initPlayer();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`Error loading memories: ${error}`);
|
||||
});
|
||||
$effect(() => {
|
||||
if (current || memoryManager.loading !== undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
handlePromiseError(goto(memoryManager.memoriesHref, { replaceState: true, noScroll: true }));
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
@@ -318,10 +254,10 @@
|
||||
use:shortcuts={assetViewerManager.isViewing
|
||||
? []
|
||||
: [
|
||||
{ shortcut: { key: 'ArrowRight' }, onShortcut: () => handleNextAsset() },
|
||||
{ shortcut: { key: 'd' }, onShortcut: () => handleNextAsset() },
|
||||
{ shortcut: { key: 'ArrowLeft' }, onShortcut: () => handlePreviousAsset() },
|
||||
{ shortcut: { key: 'a' }, onShortcut: () => handlePreviousAsset() },
|
||||
{ shortcut: { key: 'ArrowRight' }, onShortcut: () => handleNavigate(current?.nextHref) },
|
||||
{ shortcut: { key: 'd' }, onShortcut: () => handleNavigate(current?.nextHref) },
|
||||
{ shortcut: { key: 'ArrowLeft' }, onShortcut: () => handleNavigate(current?.previousHref) },
|
||||
{ shortcut: { key: 'a' }, onShortcut: () => handleNavigate(current?.previousHref) },
|
||||
{ shortcut: { key: 'Escape' }, onShortcut: () => handleEscape() },
|
||||
]}
|
||||
/>
|
||||
@@ -349,15 +285,11 @@
|
||||
<ChangeDate menuItem />
|
||||
<ChangeDescription menuItem />
|
||||
<ChangeLocation menuItem />
|
||||
<ArchiveAction
|
||||
menuItem
|
||||
unarchive={assetMultiSelectManager.isAllArchived}
|
||||
onArchive={handleDeleteOrArchiveAssets}
|
||||
/>
|
||||
<ArchiveAction menuItem unarchive={assetMultiSelectManager.isAllArchived} onArchive={handleHideAssets} />
|
||||
{#if authManager.preferences.tags.enabled && assetMultiSelectManager.isAllUserOwned}
|
||||
<TagAction menuItem />
|
||||
{/if}
|
||||
<DeleteAssets menuItem onAssetDelete={handleDeleteOrArchiveAssets} />
|
||||
<DeleteAssets menuItem onAssetDelete={handleHideAssets} />
|
||||
</ButtonContextMenu>
|
||||
</AssetSelectControlBar>
|
||||
</div>
|
||||
@@ -365,6 +297,7 @@
|
||||
|
||||
<section
|
||||
id="memory-viewer"
|
||||
data-sveltekit-noscroll
|
||||
class="dark w-full bg-immich-dark-gray text-white"
|
||||
bind:this={memoryWrapper}
|
||||
bind:clientHeight={viewport.height}
|
||||
@@ -381,7 +314,7 @@
|
||||
icon={mdiClose}
|
||||
aria-label={$t('close')}
|
||||
size="large"
|
||||
onclick={() => goto(Route.photos())}
|
||||
onclick={() => goto(memoryManager.memoriesHref)}
|
||||
/>
|
||||
<p class="text-lg">
|
||||
{$memoryLaneTitle(current.memory)}
|
||||
@@ -400,7 +333,7 @@
|
||||
/>
|
||||
|
||||
{#each current.memory.assets as asset, index (asset.id)}
|
||||
<a class="relative grow py-2" href={asHref(asset)} aria-label={$t('view')}>
|
||||
<a class="relative grow py-2" href={current.getAssetHref(asset.id)} aria-label={$t('view')}>
|
||||
<span class="absolute inset-s-0 h-0.5 w-full bg-gray-500"></span>
|
||||
<span class="absolute inset-s-0 h-0.5 bg-white" style:width={`${toProgressPercentage(index)}%`}></span>
|
||||
</a>
|
||||
@@ -409,7 +342,7 @@
|
||||
<Text size="small">
|
||||
{$t('x_of_total', {
|
||||
values: {
|
||||
x: (current.assetIndex + 1).toLocaleString($locale),
|
||||
x: (assetIndex + 1).toLocaleString($locale),
|
||||
total: current.memory.assets.length.toLocaleString($locale),
|
||||
},
|
||||
})}
|
||||
@@ -477,36 +410,21 @@
|
||||
>
|
||||
<!-- PREVIOUS MEMORY -->
|
||||
<div class="h-1/2 w-[20vw] rounded-2xl {current.previousMemory ? 'opacity-25 hover:opacity-70' : 'opacity-0'}">
|
||||
<button
|
||||
type="button"
|
||||
class="relative size-full rounded-2xl"
|
||||
disabled={!current.previousMemory}
|
||||
onclick={handlePreviousMemory}
|
||||
>
|
||||
{#if current.previousMemory && current.previousMemory.assets.length > 0}
|
||||
{#if current.previousMemory}
|
||||
<a class="relative block size-full rounded-2xl" href={current.previousMemory.href}>
|
||||
<img
|
||||
class="size-full rounded-2xl object-cover"
|
||||
src={getAssetMediaUrl({ id: current.previousMemory.assets[0].id, size: AssetMediaSize.Preview })}
|
||||
src={getAssetMediaUrl({ id: current.previousMemory.assetId, size: AssetMediaSize.Preview })}
|
||||
alt={$t('previous_memory')}
|
||||
draggable="false"
|
||||
/>
|
||||
{:else}
|
||||
<enhanced:img
|
||||
class="size-full rounded-2xl object-cover"
|
||||
src="$lib/assets/no-thumbnail.png"
|
||||
sizes="min(271px,186px)"
|
||||
alt={$t('previous_memory')}
|
||||
draggable="false"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if current.previousMemory}
|
||||
<div class="absolute inset-e-4 bottom-4 text-start text-white">
|
||||
<p class="text-xs font-semibold text-gray-200 uppercase">{$t('previous')}</p>
|
||||
<p class="text-xl">{$memoryLaneTitle(current.previousMemory)}</p>
|
||||
<p class="text-xl">{current.previousMemory.title}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- CURRENT MEMORY -->
|
||||
@@ -529,12 +447,12 @@
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<IconButton
|
||||
icon={isSaved ? mdiHeart : mdiHeartOutline}
|
||||
icon={current.memory.isSaved ? mdiHeart : mdiHeartOutline}
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
aria-label={isSaved ? $t('unfavorite') : $t('favorite')}
|
||||
onclick={() => handleSaveMemory()}
|
||||
aria-label={current.memory.isSaved ? $t('unfavorite') : $t('favorite')}
|
||||
onclick={() => memoryManager.toggleCurrentMemorySaved()}
|
||||
/>
|
||||
<!-- <IconButton
|
||||
icon={mdiShareVariantOutline}
|
||||
@@ -552,9 +470,13 @@
|
||||
size="medium"
|
||||
align="bottom-right"
|
||||
>
|
||||
<MenuOption onClick={() => handleDeleteMemory()} text={$t('remove_memory')} icon={mdiCardsOutline} />
|
||||
<MenuOption
|
||||
onClick={() => handleDeleteMemoryAsset()}
|
||||
onClick={() => memoryManager.deleteCurrentMemory()}
|
||||
text={$t('remove_memory')}
|
||||
icon={mdiCardsOutline}
|
||||
/>
|
||||
<MenuOption
|
||||
onClick={() => memoryManager.deleteCurrentAsset()}
|
||||
text={$t('remove_photo_from_memory')}
|
||||
icon={mdiImageMinusOutline}
|
||||
/>
|
||||
@@ -578,7 +500,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<!-- CONTROL BUTTONS -->
|
||||
{#if current.previous}
|
||||
{#if current.previousHref}
|
||||
<div class="dark absolute inset-s-0 top-1/2 ms-4">
|
||||
<IconButton
|
||||
shape="round"
|
||||
@@ -587,12 +509,12 @@
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="giant"
|
||||
onclick={handlePreviousAsset}
|
||||
href={current.previousHref}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if current.next}
|
||||
{#if current.nextHref}
|
||||
<div class="dark absolute inset-e-0 top-1/2 me-4">
|
||||
<IconButton
|
||||
shape="round"
|
||||
@@ -601,7 +523,7 @@
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="giant"
|
||||
onclick={handleNextAsset}
|
||||
href={current.nextHref}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -624,36 +546,21 @@
|
||||
|
||||
<!-- NEXT MEMORY -->
|
||||
<div class="h-1/2 w-[20vw] rounded-2xl {current.nextMemory ? 'opacity-25 hover:opacity-70' : 'opacity-0'}">
|
||||
<button
|
||||
type="button"
|
||||
class="relative size-full rounded-2xl"
|
||||
onclick={handleNextMemory}
|
||||
disabled={!current.nextMemory}
|
||||
>
|
||||
{#if current.nextMemory && current.nextMemory.assets.length > 0}
|
||||
{#if current.nextMemory}
|
||||
<a class="relative block size-full rounded-2xl" href={current.nextMemory.href}>
|
||||
<img
|
||||
class="size-full rounded-2xl object-cover"
|
||||
src={getAssetMediaUrl({ id: current.nextMemory.assets[0].id, size: AssetMediaSize.Preview })}
|
||||
src={getAssetMediaUrl({ id: current.nextMemory.assetId, size: AssetMediaSize.Preview })}
|
||||
alt={$t('next_memory')}
|
||||
draggable="false"
|
||||
/>
|
||||
{:else}
|
||||
<enhanced:img
|
||||
class="size-full rounded-2xl object-cover"
|
||||
src="$lib/assets/no-thumbnail.png"
|
||||
sizes="min(271px,186px)"
|
||||
alt={$t('next_memory')}
|
||||
draggable="false"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if current.nextMemory}
|
||||
<div class="absolute inset-s-4 bottom-4 text-start text-white">
|
||||
<p class="text-xs font-semibold text-gray-200 uppercase">{$t('up_next')}</p>
|
||||
<p class="text-xl">{$memoryLaneTitle(current.nextMemory)}</p>
|
||||
<p class="text-xl">{current.nextMemory.title}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -680,7 +587,7 @@
|
||||
<div id="gallery-memory" {@attach galleryObserver} bind:this={memoryGallery}>
|
||||
<GalleryViewer
|
||||
assets={currentTimelineAssets}
|
||||
{viewerAssets}
|
||||
viewerAssets={current.viewerAssets}
|
||||
viewport={galleryViewport}
|
||||
assetInteraction={assetMultiSelectManager}
|
||||
slidingWindowOffset={viewerHeight}
|
||||
@@ -1,15 +1,5 @@
|
||||
import { authenticate } from '$lib/utils/auth';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { Route } from '$lib/route';
|
||||
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;
|
||||
export const load = (() => redirect(307, Route.memories())) satisfies PageLoad;
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
import { AssetVisibility } from '@immich/sdk';
|
||||
import { ActionButton, CommandPaletteDefaultProvider, ImageCarousel } from '@immich/ui';
|
||||
import { mdiDotsVertical } from '@mdi/js';
|
||||
import { DateTime } from 'luxon';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
let timelineManager = $state<TimelineManager>() as TimelineManager;
|
||||
@@ -85,11 +86,13 @@
|
||||
memoryManager.memories.map((memory) => ({
|
||||
id: memory.id,
|
||||
title: $memoryLaneTitle(memory),
|
||||
href: Route.memories({ id: memory.assets[0].id }),
|
||||
href: Route.viewMemory({ id: memory.id, assetId: memory.assets[0].id }),
|
||||
alt: $t('memory_lane_title', { values: { title: $getAltText(toTimelineAsset(memory.assets[0])) } }),
|
||||
src: getAssetMediaUrl({ id: memory.assets[0].id }),
|
||||
})),
|
||||
);
|
||||
|
||||
memoryManager.setFilters({ $for: DateTime.now().toISODate() });
|
||||
</script>
|
||||
|
||||
<UserPageLayout hideNavbar={assetMultiSelectManager.selectionActive} scrollbar={false}>
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
// Memories
|
||||
let memoriesEnabled = $state(authManager.preferences.memories?.enabled ?? true);
|
||||
let memoriesDuration = $state(authManager.preferences.memories?.duration ?? 5);
|
||||
let memoriesSidebar = $state(authManager.preferences.memories?.sidebarWeb ?? false);
|
||||
|
||||
// People
|
||||
let peopleEnabled = $state(authManager.preferences.people?.enabled ?? false);
|
||||
@@ -47,7 +48,7 @@
|
||||
userPreferencesUpdateDto: {
|
||||
albums: { defaultAssetOrder },
|
||||
folders: { enabled: foldersEnabled, sidebarWeb: foldersSidebar },
|
||||
memories: { enabled: memoriesEnabled, duration: memoriesDuration },
|
||||
memories: { enabled: memoriesEnabled, duration: memoriesDuration, sidebarWeb: memoriesSidebar },
|
||||
people: { enabled: peopleEnabled, sidebarWeb: peopleSidebar, minimumFaces: peopleMinFaces },
|
||||
ratings: { enabled: ratingsEnabled },
|
||||
sharedLinks: { enabled: sharedLinksEnabled, sidebarWeb: sharedLinkSidebar },
|
||||
@@ -107,6 +108,12 @@
|
||||
<Switch bind:checked={memoriesEnabled} />
|
||||
</Field>
|
||||
|
||||
{#if memoriesEnabled}
|
||||
<Field label={$t('sidebar')} description={$t('sidebar_display_description')}>
|
||||
<Switch bind:checked={memoriesSidebar} />
|
||||
</Field>
|
||||
{/if}
|
||||
|
||||
<Field label={$t('duration')} description={$t('time_based_memories_duration')}>
|
||||
<NumberInput bind:value={memoriesDuration} />
|
||||
</Field>
|
||||
|
||||
@@ -24,6 +24,7 @@ export const preferencesFactory = Sync.makeFactory<UserPreferencesResponseDto>({
|
||||
memories: {
|
||||
enabled: false,
|
||||
duration: 5,
|
||||
sidebarWeb: false,
|
||||
},
|
||||
people: {
|
||||
enabled: false,
|
||||
|
||||
Reference in New Issue
Block a user