From 3f1b8e1d9b6467bd0b7eb45536eceba0711815fb Mon Sep 17 00:00:00 2001 From: Ben Beckford Date: Thu, 28 May 2026 23:54:14 -0700 Subject: [PATCH] paginate searchMemories --- mobile/openapi/README.md | 1 + mobile/openapi/lib/api.dart | 1 + mobile/openapi/lib/api/memories_api.dart | 37 ++++-- mobile/openapi/lib/api_client.dart | 2 + .../lib/model/memory_search_response_dto.dart | 120 ++++++++++++++++++ open-api/immich-openapi-specs.json | 53 +++++++- packages/sdk/src/fetch-client.ts | 17 ++- server/src/controllers/memory.controller.ts | 3 +- server/src/dtos/memory.dto.ts | 10 ++ server/src/repositories/memory.repository.ts | 10 +- server/src/services/memory.service.spec.ts | 19 ++- server/src/services/memory.service.ts | 12 +- .../specs/services/memory.service.spec.ts | 12 +- web/src/lib/managers/memory-manager.svelte.ts | 50 ++++++-- .../[[assetId=id]]/+page.svelte | 37 +++++- .../[[photos=photos]]/[[assetId=id]]/+page.ts | 1 - 16 files changed, 328 insertions(+), 57 deletions(-) create mode 100644 mobile/openapi/lib/model/memory_search_response_dto.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 23987073dd..0ffd076e74 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -450,6 +450,7 @@ Class | Method | HTTP request | Description - [MemoryCreateDto](doc//MemoryCreateDto.md) - [MemoryResponseDto](doc//MemoryResponseDto.md) - [MemorySearchOrder](doc//MemorySearchOrder.md) + - [MemorySearchResponseDto](doc//MemorySearchResponseDto.md) - [MemoryStatisticsResponseDto](doc//MemoryStatisticsResponseDto.md) - [MemoryType](doc//MemoryType.md) - [MemoryUpdateDto](doc//MemoryUpdateDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index d5a6f483dc..3305710053 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -195,6 +195,7 @@ part 'model/memories_update.dart'; part 'model/memory_create_dto.dart'; part 'model/memory_response_dto.dart'; part 'model/memory_search_order.dart'; +part 'model/memory_search_response_dto.dart'; part 'model/memory_statistics_response_dto.dart'; part 'model/memory_type.dart'; part 'model/memory_update_dto.dart'; diff --git a/mobile/openapi/lib/api/memories_api.dart b/mobile/openapi/lib/api/memories_api.dart index 0cd96ac442..b6b769404c 100644 --- a/mobile/openapi/lib/api/memories_api.dart +++ b/mobile/openapi/lib/api/memories_api.dart @@ -261,11 +261,14 @@ class MemoriesApi { /// /// * [MemorySearchOrder] order: /// + /// * [int] page: + /// Page number + /// /// * [int] size: /// Number of memories to return /// /// * [MemoryType] type: - Future memoriesStatisticsWithHttpInfo({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? size, MemoryType? type, }) async { + Future memoriesStatisticsWithHttpInfo({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? page, int? size, MemoryType? type, }) async { // ignore: prefer_const_declarations final apiPath = r'/memories/statistics'; @@ -288,6 +291,9 @@ class MemoriesApi { if (order != null) { queryParams.addAll(_queryParams('', 'order', order)); } + if (page != null) { + queryParams.addAll(_queryParams('', 'page', page)); + } if (size != null) { queryParams.addAll(_queryParams('', 'size', size)); } @@ -326,12 +332,15 @@ class MemoriesApi { /// /// * [MemorySearchOrder] order: /// + /// * [int] page: + /// Page number + /// /// * [int] size: /// Number of memories to return /// /// * [MemoryType] type: - Future memoriesStatistics({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? size, MemoryType? type, }) async { - final response = await memoriesStatisticsWithHttpInfo( for_: for_, isSaved: isSaved, isTrashed: isTrashed, order: order, size: size, type: type, ); + Future memoriesStatistics({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? page, int? size, MemoryType? type, }) async { + final response = await memoriesStatisticsWithHttpInfo( for_: for_, isSaved: isSaved, isTrashed: isTrashed, order: order, page: page, size: size, type: type, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -428,11 +437,14 @@ class MemoriesApi { /// /// * [MemorySearchOrder] order: /// + /// * [int] page: + /// Page number + /// /// * [int] size: /// Number of memories to return /// /// * [MemoryType] type: - Future searchMemoriesWithHttpInfo({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? size, MemoryType? type, }) async { + Future searchMemoriesWithHttpInfo({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? page, int? size, MemoryType? type, }) async { // ignore: prefer_const_declarations final apiPath = r'/memories'; @@ -455,6 +467,9 @@ class MemoriesApi { if (order != null) { queryParams.addAll(_queryParams('', 'order', order)); } + if (page != null) { + queryParams.addAll(_queryParams('', 'page', page)); + } if (size != null) { queryParams.addAll(_queryParams('', 'size', size)); } @@ -493,12 +508,15 @@ class MemoriesApi { /// /// * [MemorySearchOrder] order: /// + /// * [int] page: + /// Page number + /// /// * [int] size: /// Number of memories to return /// /// * [MemoryType] type: - Future?> searchMemories({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? size, MemoryType? type, }) async { - final response = await searchMemoriesWithHttpInfo( for_: for_, isSaved: isSaved, isTrashed: isTrashed, order: order, size: size, type: type, ); + Future searchMemories({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? page, int? size, MemoryType? type, }) async { + final response = await searchMemoriesWithHttpInfo( for_: for_, isSaved: isSaved, isTrashed: isTrashed, order: order, page: page, size: size, type: type, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -506,11 +524,8 @@ class MemoriesApi { // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" // FormatException when trying to decode an empty string. if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - final responseBody = await _decodeBodyBytes(response); - return (await apiClient.deserializeAsync(responseBody, 'List') as List) - .cast() - .toList(growable: false); - + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MemorySearchResponseDto',) as MemorySearchResponseDto; + } return null; } diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index a3c2369c1d..58503e0ca1 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -436,6 +436,8 @@ class ApiClient { return MemoryResponseDto.fromJson(value); case 'MemorySearchOrder': return MemorySearchOrderTypeTransformer().decode(value); + case 'MemorySearchResponseDto': + return MemorySearchResponseDto.fromJson(value); case 'MemoryStatisticsResponseDto': return MemoryStatisticsResponseDto.fromJson(value); case 'MemoryType': diff --git a/mobile/openapi/lib/model/memory_search_response_dto.dart b/mobile/openapi/lib/model/memory_search_response_dto.dart new file mode 100644 index 0000000000..82e04564cf --- /dev/null +++ b/mobile/openapi/lib/model/memory_search_response_dto.dart @@ -0,0 +1,120 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class MemorySearchResponseDto { + /// Returns a new [MemorySearchResponseDto] instance. + MemorySearchResponseDto({ + required this.hasNextPage, + this.items = const [], + required this.total, + }); + + /// Whether there are more pages + bool hasNextPage; + + List items; + + /// Total number of matching memories + /// + /// Minimum value: 0 + /// Maximum value: 9007199254740991 + int total; + + @override + bool operator ==(Object other) => identical(this, other) || other is MemorySearchResponseDto && + other.hasNextPage == hasNextPage && + _deepEquality.equals(other.items, items) && + other.total == total; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (hasNextPage.hashCode) + + (items.hashCode) + + (total.hashCode); + + @override + String toString() => 'MemorySearchResponseDto[hasNextPage=$hasNextPage, items=$items, total=$total]'; + + Map toJson() { + final json = {}; + json[r'hasNextPage'] = this.hasNextPage; + json[r'items'] = this.items; + json[r'total'] = this.total; + return json; + } + + /// Returns a new [MemorySearchResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static MemorySearchResponseDto? fromJson(dynamic value) { + upgradeDto(value, "MemorySearchResponseDto"); + if (value is Map) { + final json = value.cast(); + + return MemorySearchResponseDto( + hasNextPage: mapValueOfType(json, r'hasNextPage')!, + items: MemoryResponseDto.listFromJson(json[r'items']), + total: mapValueOfType(json, r'total')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = MemorySearchResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = MemorySearchResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of MemorySearchResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = MemorySearchResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'hasNextPage', + 'items', + 'total', + }; +} + diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 9fda205b9a..74b11e5c8b 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -6363,6 +6363,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, @@ -6388,10 +6399,7 @@ "content": { "application/json": { "schema": { - "items": { - "$ref": "#/components/schemas/MemoryResponseDto" - }, - "type": "array" + "$ref": "#/components/schemas/MemorySearchResponseDto" } } }, @@ -6532,6 +6540,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, @@ -18807,6 +18826,32 @@ ], "type": "string" }, + "MemorySearchResponseDto": { + "properties": { + "hasNextPage": { + "description": "Whether there are more pages", + "type": "boolean" + }, + "items": { + "items": { + "$ref": "#/components/schemas/MemoryResponseDto" + }, + "type": "array" + }, + "total": { + "description": "Total number of matching memories", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "hasNextPage", + "items", + "total" + ], + "type": "object" + }, "MemoryStatisticsResponseDto": { "properties": { "total": { diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index 3f328088ee..1ad39a0d54 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -1316,6 +1316,13 @@ export type MemoryResponseDto = { /** Last update date */ updatedAt: string; }; +export type MemorySearchResponseDto = { + /** Whether there are more pages */ + hasNextPage: boolean; + items: MemoryResponseDto[]; + /** Total number of matching memories */ + total: number; +}; export type MemoryCreateDto = { /** Asset IDs to associate with memory */ assetIds?: string[]; @@ -4678,22 +4685,24 @@ export function reverseGeocode({ lat, lon }: { /** * Retrieve memories */ -export function searchMemories({ $for, isSaved, isTrashed, order, size, $type }: { +export function searchMemories({ $for, isSaved, isTrashed, order, page, size, $type }: { $for?: string; isSaved?: boolean; isTrashed?: boolean; order?: MemorySearchOrder; + page?: number; size?: number; $type?: MemoryType; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; - data: MemoryResponseDto[]; + data: MemorySearchResponseDto; }>(`/memories${QS.query(QS.explode({ "for": $for, isSaved, isTrashed, order, + page, size, "type": $type }))}`, { @@ -4718,11 +4727,12 @@ export function createMemory({ memoryCreateDto }: { /** * Retrieve memories statistics */ -export function memoriesStatistics({ $for, isSaved, isTrashed, order, size, $type }: { +export function memoriesStatistics({ $for, isSaved, isTrashed, order, page, size, $type }: { $for?: string; isSaved?: boolean; isTrashed?: boolean; order?: MemorySearchOrder; + page?: number; size?: number; $type?: MemoryType; }, opts?: Oazapfts.RequestOpts) { @@ -4734,6 +4744,7 @@ export function memoriesStatistics({ $for, isSaved, isTrashed, order, size, $typ isSaved, isTrashed, order, + page, size, "type": $type }))}`, { diff --git a/server/src/controllers/memory.controller.ts b/server/src/controllers/memory.controller.ts index cbf86199bb..8de20a66c2 100644 --- a/server/src/controllers/memory.controller.ts +++ b/server/src/controllers/memory.controller.ts @@ -7,6 +7,7 @@ import { MemoryCreateDto, MemoryResponseDto, MemorySearchDto, + MemorySearchResponseDto, MemoryStatisticsResponseDto, MemoryUpdateDto, } from 'src/dtos/memory.dto'; @@ -28,7 +29,7 @@ export class MemoryController { 'Retrieve a list of memories. Memories are sorted descending by creation date by default, although they can also be sorted in ascending order, or randomly.', history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), }) - searchMemories(@Auth() auth: AuthDto, @Query() dto: MemorySearchDto): Promise { + searchMemories(@Auth() auth: AuthDto, @Query() dto: MemorySearchDto): Promise { return this.service.search(auth, dto); } diff --git a/server/src/dtos/memory.dto.ts b/server/src/dtos/memory.dto.ts index ce2e9fda6c..a0c8e192b4 100644 --- a/server/src/dtos/memory.dto.ts +++ b/server/src/dtos/memory.dto.ts @@ -14,6 +14,7 @@ const MemorySearchSchema = z isTrashed: stringToBool.optional().describe('Include trashed memories'), isSaved: stringToBool.optional().describe('Filter by saved status'), size: z.coerce.number().int().min(1).optional().describe('Number of memories to return'), + page: z.coerce.number().int().min(1).optional().describe('Page number'), order: AssetOrderWithRandomSchema.optional(), }) .meta({ id: 'MemorySearchDto' }); @@ -75,11 +76,20 @@ const MemoryResponseSchema = z }) .meta({ id: 'MemoryResponseDto' }); +const MemorySearchResponseSchema = z + .object({ + total: z.int().min(0).describe('Total number of matching memories'), + items: z.array(MemoryResponseSchema), + hasNextPage: z.boolean().describe('Whether there are more pages'), + }) + .meta({ id: 'MemorySearchResponseDto' }); + export class MemorySearchDto extends createZodDto(MemorySearchSchema) {} export class MemoryUpdateDto extends createZodDto(MemoryUpdateSchema) {} export class MemoryCreateDto extends createZodDto(MemoryCreateSchema) {} export class MemoryStatisticsResponseDto extends createZodDto(MemoryStatisticsResponseSchema) {} export class MemoryResponseDto extends createZodDto(MemoryResponseSchema) {} +export class MemorySearchResponseDto extends createZodDto(MemorySearchResponseSchema) {} export const mapMemory = (entity: Memory, auth: AuthDto): MemoryResponseDto => { return { diff --git a/server/src/repositories/memory.repository.ts b/server/src/repositories/memory.repository.ts index 09aa5ad880..b95cb495ad 100644 --- a/server/src/repositories/memory.repository.ts +++ b/server/src/repositories/memory.repository.ts @@ -9,6 +9,7 @@ import { AssetOrderWithRandom, AssetVisibility } from 'src/enum'; import { DB } from 'src/schema'; import { MemoryTable } from 'src/schema/tables/memory.table'; import { IBulkAsset } from 'src/types'; +import { paginationHelper } from 'src/utils/pagination'; @Injectable() export class MemoryRepository implements IBulkAsset { @@ -57,8 +58,8 @@ export class MemoryRepository implements IBulkAsset { { params: [DummyValue.UUID, {}] }, { name: 'date filter', params: [DummyValue.UUID, { for: DummyValue.DATE }] }, ) - search(ownerId: string, dto: MemorySearchDto) { - return this.searchBuilder(ownerId, dto) + async search(ownerId: string, dto: MemorySearchDto) { + const items = await this.searchBuilder(ownerId, dto) .select((eb) => jsonArrayFrom( eb @@ -89,8 +90,11 @@ export class MemoryRepository implements IBulkAsset { ? qb.orderBy(sql`RANDOM()`) : qb.orderBy('memoryAt', (dto.order?.toLowerCase() || 'desc') as OrderByDirection), ) - .$if(dto.size !== undefined, (qb) => qb.limit(dto.size!)) + .$if(dto.size !== undefined, (qb) => qb.limit(dto.size! + 1)) + .$if(dto.page !== undefined && dto.size !== undefined, (qb) => qb.offset((dto.page! - 1) * dto.size!)) .execute(); + + return paginationHelper(items, dto.size ?? items.length); } @GenerateSql({ params: [DummyValue.UUID] }) diff --git a/server/src/services/memory.service.spec.ts b/server/src/services/memory.service.spec.ts index 9976189e39..cda4184e1f 100644 --- a/server/src/services/memory.service.spec.ts +++ b/server/src/services/memory.service.spec.ts @@ -34,21 +34,28 @@ describe(MemoryService.name, () => { const asset = AssetFactory.create(); const memory1 = MemoryFactory.from({ ownerId: userId }).asset(asset).build(); const memory2 = MemoryFactory.create({ ownerId: userId }); - mocks.memory.search.mockResolvedValue([getForMemory(memory1), getForMemory(memory2)]); + mocks.memory.search.mockResolvedValue({ + items: [getForMemory(memory1), getForMemory(memory2)], + hasNextPage: false, + }); + mocks.memory.statistics.mockResolvedValue({ total: 2 }); - await expect(sut.search(factory.auth({ user: { id: userId } }), {})).resolves.toEqual( - expect.arrayContaining([ + await expect(sut.search(factory.auth({ user: { id: userId } }), {})).resolves.toMatchObject({ + items: expect.arrayContaining([ expect.objectContaining({ id: memory1.id, assets: expect.arrayContaining([expect.objectContaining({ id: asset.id })]), }), ]), - ); + hasNextPage: false, + total: 2, + }); }); it('should map empty result', async () => { - mocks.memory.search.mockResolvedValue([]); - await expect(sut.search(factory.auth(), {})).resolves.toEqual([]); + mocks.memory.search.mockResolvedValue({ items: [], hasNextPage: false }); + mocks.memory.statistics.mockResolvedValue({ total: 0 }); + await expect(sut.search(factory.auth(), {})).resolves.toMatchObject({ items: [], hasNextPage: false, total: 0 }); }); }); diff --git a/server/src/services/memory.service.ts b/server/src/services/memory.service.ts index ac8f88ad87..0fef2c9b5c 100644 --- a/server/src/services/memory.service.ts +++ b/server/src/services/memory.service.ts @@ -71,10 +71,14 @@ export class MemoryService extends BaseService { } async search(auth: AuthDto, dto: MemorySearchDto) { - const memories = await this.memoryRepository.search(auth.user.id, dto); - return memories - .filter((memory: Memory) => memory.assets && memory.assets.length > 0) - .map((memory: Memory) => mapMemory(memory, auth)); + const { items, hasNextPage } = await this.memoryRepository.search(auth.user.id, dto); + const { total } = await this.memoryRepository.statistics(auth.user.id, dto); + + return { + total, + items: items.map((memory: Memory) => mapMemory(memory, auth)), + hasNextPage, + }; } statistics(auth: AuthDto, dto: MemorySearchDto) { diff --git a/server/test/medium/specs/services/memory.service.spec.ts b/server/test/medium/specs/services/memory.service.spec.ts index b3a3da6010..a134e0735d 100644 --- a/server/test/medium/specs/services/memory.service.spec.ts +++ b/server/test/medium/specs/services/memory.service.spec.ts @@ -133,8 +133,8 @@ describe(MemoryService.name, () => { await sut.onMemoriesCreate(); const memories = await memoryRepo.search(user.id, {}); - expect(memories.length).toBe(1); - expect(memories[0]).toEqual( + expect(memories.items.length).toBe(1); + expect(memories.items[0]).toEqual( expect.objectContaining({ id: expect.any(String), createdAt: expect.any(Date), @@ -173,8 +173,8 @@ describe(MemoryService.name, () => { await sut.onMemoriesCreate(); const memories = await memoryRepo.search(user.id, {}); - expect(memories.length).toBe(1); - expect(memories[0]).toEqual( + expect(memories.items.length).toBe(1); + expect(memories.items[0]).toEqual( expect.objectContaining({ id: expect.any(String), createdAt: expect.any(Date), @@ -228,12 +228,12 @@ describe(MemoryService.name, () => { await sut.onMemoriesCreate(); const memories = await memoryRepo.search(user.id, {}); - expect(memories.length).toBe(1); + expect(memories.items.length).toBe(1); await sut.onMemoriesCreate(); const memoriesAfter = await memoryRepo.search(user.id, {}); - expect(memoriesAfter.length).toBe(1); + expect(memoriesAfter.items.length).toBe(1); }); }); diff --git a/web/src/lib/managers/memory-manager.svelte.ts b/web/src/lib/managers/memory-manager.svelte.ts index d617364caa..b1e14ca5a2 100644 --- a/web/src/lib/managers/memory-manager.svelte.ts +++ b/web/src/lib/managers/memory-manager.svelte.ts @@ -27,6 +27,8 @@ export type MemoryAsset = MemoryIndex & { nextMemory?: MemoryResponseDto; }; +const PAGE_SIZE = 250; + class MemoryManager { #loading: Promise | undefined; #filters: @@ -40,9 +42,15 @@ class MemoryManager { $type?: MemoryType; } | undefined; + #hasNextPage: boolean; + #page: number; + #total: number; constructor() { this.#filters = undefined; + this.#hasNextPage = true; + this.#page = 1; + this.#total = 0; eventManager.on({ AuthLogout: () => this.clearCache(), @@ -64,11 +72,7 @@ class MemoryManager { set filters(filters) { this.#filters = filters; this.clearCache(); - if (this.#loading === undefined) { - this.#loading = this.load(); - } else { - void this.#loading.then(() => (this.#loading = this.load())); - } + void this.loadNextPage(); } ready() { @@ -151,23 +155,45 @@ class MemoryManager { } } + loadNextPage() { + if (this.#hasNextPage) { + if (this.#loading === undefined) { + this.#loading = this.load(this.#page++); + } else { + void this.#loading.then(() => (this.#loading = this.load(this.#page++))); + } + } + } + + get hasNextPage() { + return this.#hasNextPage; + } + + get total() { + return this.#total; + } + private clearCache() { this.#loading = undefined; + this.#hasNextPage = true; + this.#page = 1; this.memories = []; } private initialize() { if (!this.#loading) { - this.#loading = this.load(); + this.#loading = this.load(this.#page++); } return this.#loading; } - private async load() { + private async load(page: number) { if (this.#filters !== undefined) { - const memories = await searchMemories(this.#filters); - this.memories = memories.filter((memory) => memory.assets.length > 0); + const { items, hasNextPage, total } = await searchMemories({ ...this.#filters, page, size: PAGE_SIZE }); + this.memories.push(...items); + this.#hasNextPage = hasNextPage; + this.#total = total; } } @@ -182,12 +208,14 @@ class MemoryManager { const initialDelay = nextEvent.diff(now).as('milliseconds'); setTimeout(() => { - this.#loading = this.load(); + this.clearCache(); + this.#loading = this.load(0); // Schedule subsequent events hourly setInterval( () => { - this.#loading = this.load(); + this.clearCache(); + this.#loading = this.load(0); }, 60 * 60 * 1000, ); diff --git a/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.svelte index 6531b6684e..526981426c 100644 --- a/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -20,15 +20,13 @@ } let { data }: Props = $props(); - - let memories = $derived(data.memories); let onlyFavorites = $state(page.url.searchParams.get('favorites') === 'true'); + let lastElement: HTMLElement | undefined = $state(); const toggleFavorites = async () => { onlyFavorites = !onlyFavorites; memoryManager.filters = onlyFavorites ? { isSaved: true } : {}; await memoryManager.ready(); - memories = memoryManager.memories; if (onlyFavorites) { void setQueryValue('favorites', 'true'); @@ -36,12 +34,26 @@ void clearQueryParam('favorites', page.url); } }; + + const intersectionObserver = new IntersectionObserver((entries) => { + const entry = entries.find((entry) => entry.target === lastElement); + if (entry?.isIntersecting && memoryManager.hasNextPage) { + void memoryManager.loadNextPage(); + } + }); + + $effect(() => { + if (lastElement) { + intersectionObserver.disconnect(); + intersectionObserver.observe(lastElement); + } + }); {#if page.url.searchParams.has(QueryParameter.ID)} {:else} - + {#snippet buttons()}
{/snippet} - {#if memories.length > 0} + {#if memoryManager.memories.length > 0}