From d416e225e6cb702992bedb3f3ac7ee0984143bd0 Mon Sep 17 00:00:00 2001 From: Thomas Way Date: Tue, 24 Feb 2026 10:28:56 +0000 Subject: [PATCH] feat: sort smart search Smart search currently returns a list of assets by their score. It would be nice if we could instead filter assets, and then list them by date. This is the default behaviour of other platforms. --- i18n/en.json | 4 + .../repositories/search_api.repository.dart | 2 + .../models/search/search_filter.model.dart | 13 +- mobile/lib/pages/search/search.page.dart | 59 ++- .../search/paginated_search.provider.dart | 5 +- .../search/paginated_search.provider.g.dart | 2 +- .../search_filter/sort_order_picker.dart | 42 ++ .../openapi/lib/model/smart_search_dto.dart | 20 +- open-api/immich-openapi-specs.json | 8 + open-api/typescript-sdk/src/fetch-client.ts | 2 + server/src/dtos/search.dto.ts | 8 + server/src/repositories/search.repository.ts | 8 +- server/src/services/search.service.ts | 2 +- .../date-grouped-gallery-viewer.svelte | 459 ++++++++++++++++++ .../search-bar/search-sort-section.svelte | 41 ++ web/src/lib/modals/SearchFilterModal.svelte | 18 +- .../[[assetId=id]]/+page.svelte | 33 +- 17 files changed, 705 insertions(+), 21 deletions(-) create mode 100644 mobile/lib/widgets/search/search_filter/sort_order_picker.dart create mode 100644 web/src/lib/components/shared-components/gallery-viewer/date-grouped-gallery-viewer.svelte create mode 100644 web/src/lib/components/shared-components/search-bar/search-sort-section.svelte diff --git a/i18n/en.json b/i18n/en.json index 95e9584032..934d3595be 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -689,6 +689,7 @@ "backup_settings_subtitle": "Manage upload settings", "backup_upload_details_page_more_details": "Tap for more details", "backward": "Backward", + "best_match": "Best match", "biometric_auth_enabled": "Biometric authentication enabled", "biometric_locked_out": "You are locked out of biometric authentication", "biometric_no_options": "No biometric options available", @@ -1945,6 +1946,8 @@ "search_filter_media_type_title": "Select media type", "search_filter_ocr": "Search by OCR", "search_filter_people_title": "Select people", + "search_filter_sort_order": "Sort Order", + "search_filter_sort_order_title": "Select sort order", "search_filter_star_rating": "Star Rating", "search_filter_tags_title": "Select tags", "search_for": "Search for", @@ -2160,6 +2163,7 @@ "sort_modified": "Date modified", "sort_newest": "Newest photo", "sort_oldest": "Oldest photo", + "sort_order": "Sort order", "sort_people_by_similarity": "Sort people by similarity", "sort_recent": "Most recent photo", "sort_title": "Title", diff --git a/mobile/lib/infrastructure/repositories/search_api.repository.dart b/mobile/lib/infrastructure/repositories/search_api.repository.dart index bcfddfce6e..89a3bb8ae9 100644 --- a/mobile/lib/infrastructure/repositories/search_api.repository.dart +++ b/mobile/lib/infrastructure/repositories/search_api.repository.dart @@ -37,6 +37,7 @@ class SearchApiRepository extends ApiRepository { personIds: filter.people.map((e) => e.id).toList(), tagIds: filter.tagIds, type: type, + order: filter.order, page: page, size: 100, ), @@ -62,6 +63,7 @@ class SearchApiRepository extends ApiRepository { personIds: filter.people.map((e) => e.id).toList(), tagIds: filter.tagIds, type: type, + order: filter.order ?? AssetOrder.desc, page: page, size: 1000, ), diff --git a/mobile/lib/models/search/search_filter.model.dart b/mobile/lib/models/search/search_filter.model.dart index 1b730e0c68..73021bf13d 100644 --- a/mobile/lib/models/search/search_filter.model.dart +++ b/mobile/lib/models/search/search_filter.model.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:immich_mobile/domain/models/person.model.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:openapi/api.dart' show AssetOrder; class SearchLocationFilter { String? country; @@ -221,6 +222,7 @@ class SearchFilter { SearchDateFilter date; SearchRatingFilter rating; SearchDisplayFilters display; + AssetOrder? order; // Enum AssetType mediaType; @@ -233,6 +235,7 @@ class SearchFilter { this.language, this.assetId, this.tagIds, + this.order, required this.people, required this.location, required this.camera, @@ -279,6 +282,7 @@ class SearchFilter { SearchDisplayFilters? display, SearchRatingFilter? rating, AssetType? mediaType, + AssetOrder? Function()? order, }) { return SearchFilter( context: context ?? this.context, @@ -295,12 +299,13 @@ class SearchFilter { rating: rating ?? this.rating, mediaType: mediaType ?? this.mediaType, tagIds: tagIds ?? this.tagIds, + order: order != null ? order() : this.order, ); } @override String toString() { - return 'SearchFilter(context: $context, filename: $filename, description: $description, language: $language, ocr: $ocr, people: $people, location: $location, tagIds: $tagIds, camera: $camera, date: $date, display: $display, rating: $rating, mediaType: $mediaType, assetId: $assetId)'; + return 'SearchFilter(context: $context, filename: $filename, description: $description, language: $language, ocr: $ocr, people: $people, location: $location, tagIds: $tagIds, camera: $camera, date: $date, display: $display, rating: $rating, mediaType: $mediaType, assetId: $assetId, order: $order)'; } @override @@ -320,7 +325,8 @@ class SearchFilter { other.date == date && other.display == display && other.rating == rating && - other.mediaType == mediaType; + other.mediaType == mediaType && + other.order == order; } @override @@ -338,6 +344,7 @@ class SearchFilter { date.hashCode ^ display.hashCode ^ rating.hashCode ^ - mediaType.hashCode; + mediaType.hashCode ^ + order.hashCode; } } diff --git a/mobile/lib/pages/search/search.page.dart b/mobile/lib/pages/search/search.page.dart index dbd32ac94b..28ddcc774f 100644 --- a/mobile/lib/pages/search/search.page.dart +++ b/mobile/lib/pages/search/search.page.dart @@ -11,6 +11,7 @@ import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart'; import 'package:immich_mobile/providers/search/paginated_search.provider.dart'; +import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; import 'package:immich_mobile/providers/search/search_input_focus.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart'; @@ -23,6 +24,8 @@ import 'package:immich_mobile/widgets/search/search_filter/media_type_picker.dar import 'package:immich_mobile/widgets/search/search_filter/people_picker.dart'; import 'package:immich_mobile/widgets/search/search_filter/search_filter_chip.dart'; import 'package:immich_mobile/widgets/search/search_filter/search_filter_utils.dart'; +import 'package:immich_mobile/widgets/search/search_filter/sort_order_picker.dart'; +import 'package:openapi/api.dart' show AssetOrder; @RoutePage() class SearchPage extends HookConsumerWidget { @@ -56,6 +59,7 @@ class SearchPage extends HookConsumerWidget { final locationCurrentFilterWidget = useState(null); final mediaTypeCurrentFilterWidget = useState(null); final displayOptionCurrentFilterWidget = useState(null); + final sortOrderCurrentFilterWidget = useState(null); final isSearching = useState(false); @@ -78,6 +82,8 @@ class SearchPage extends HookConsumerWidget { } isSearching.value = true; + ref.read(searchGroupByProvider.notifier).state = + filter.value.order != null ? GroupAssetsBy.day : GroupAssetsBy.none; ref.watch(paginatedSearchProvider.notifier).clear(); final hasResult = await ref.watch(paginatedSearchProvider.notifier).search(filter.value); @@ -387,6 +393,37 @@ class SearchPage extends HookConsumerWidget { ); } + // SORT ORDER + showSortOrderPicker() { + handleOnSelect(AssetOrder? value) { + filter.value = filter.value.copyWith(order: () => value); + + if (value == null) { + sortOrderCurrentFilterWidget.value = null; + } else if (value == AssetOrder.desc) { + sortOrderCurrentFilterWidget.value = Text('newest_first'.tr(), style: context.textTheme.labelLarge); + } else { + sortOrderCurrentFilterWidget.value = Text('oldest_first'.tr(), style: context.textTheme.labelLarge); + } + } + + handleClear() { + filter.value = filter.value.copyWith(order: () => null); + sortOrderCurrentFilterWidget.value = null; + search(); + } + + showFilterBottomSheet( + context: context, + child: FilterBottomSheetScaffold( + title: 'search_filter_sort_order_title'.tr(), + onSearch: search, + onClear: handleClear, + child: SortOrderPicker(onSelect: handleOnSelect, order: filter.value.order), + ), + ); + } + handleTextSubmitted(String value) { switch (textSearchType.value) { case TextSearchType.context: @@ -594,6 +631,12 @@ class SearchPage extends HookConsumerWidget { label: 'search_filter_display_options'.tr(), currentFilter: displayOptionCurrentFilterWidget.value, ), + SearchFilterChip( + icon: Icons.sort_outlined, + onTap: showSortOrderPicker, + label: 'search_filter_sort_order'.tr(), + currentFilter: sortOrderCurrentFilterWidget.value, + ), ], ), ), @@ -601,7 +644,11 @@ class SearchPage extends HookConsumerWidget { if (isSearching.value) const Expanded(child: Center(child: CircularProgressIndicator())) else - SearchResultGrid(onScrollEnd: loadMoreSearchResult, isSearching: isSearching.value), + SearchResultGrid( + onScrollEnd: loadMoreSearchResult, + isSearching: isSearching.value, + dragScrollLabelEnabled: filter.value.order != null, + ), ], ), ); @@ -611,8 +658,14 @@ class SearchPage extends HookConsumerWidget { class SearchResultGrid extends StatelessWidget { final VoidCallback onScrollEnd; final bool isSearching; + final bool dragScrollLabelEnabled; - const SearchResultGrid({super.key, required this.onScrollEnd, this.isSearching = false}); + const SearchResultGrid({ + super.key, + required this.onScrollEnd, + this.isSearching = false, + this.dragScrollLabelEnabled = false, + }); @override Widget build(BuildContext context) { @@ -640,7 +693,7 @@ class SearchResultGrid extends StatelessWidget { editEnabled: true, favoriteEnabled: true, stackEnabled: false, - dragScrollLabelEnabled: false, + dragScrollLabelEnabled: dragScrollLabelEnabled, emptyIndicator: Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: !isSearching ? const SearchEmptyContent() : const SizedBox.shrink(), diff --git a/mobile/lib/providers/search/paginated_search.provider.dart b/mobile/lib/providers/search/paginated_search.provider.dart index 9a37d83320..1e5a76159e 100644 --- a/mobile/lib/providers/search/paginated_search.provider.dart +++ b/mobile/lib/providers/search/paginated_search.provider.dart @@ -8,6 +8,8 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'paginated_search.provider.g.dart'; +final searchGroupByProvider = StateProvider((ref) => GroupAssetsBy.none); + final paginatedSearchProvider = StateNotifierProvider( (ref) => PaginatedSearchNotifier(ref.watch(searchServiceProvider)), ); @@ -41,6 +43,7 @@ class PaginatedSearchNotifier extends StateNotifier { @riverpod Future paginatedSearchRenderList(Ref ref) { final result = ref.watch(paginatedSearchProvider); + final groupBy = ref.watch(searchGroupByProvider); final timelineService = ref.watch(timelineServiceProvider); - return timelineService.getTimelineFromAssets(result.assets, GroupAssetsBy.none); + return timelineService.getTimelineFromAssets(result.assets, groupBy); } diff --git a/mobile/lib/providers/search/paginated_search.provider.g.dart b/mobile/lib/providers/search/paginated_search.provider.g.dart index e984997967..f089506c3d 100644 --- a/mobile/lib/providers/search/paginated_search.provider.g.dart +++ b/mobile/lib/providers/search/paginated_search.provider.g.dart @@ -7,7 +7,7 @@ part of 'paginated_search.provider.dart'; // ************************************************************************** String _$paginatedSearchRenderListHash() => - r'22d715ff7864e5a946be38322ce7813616f899c2'; + r'bb1ea9153b2a186778420426f1fb1add6d6a9140'; /// See also [paginatedSearchRenderList]. @ProviderFor(paginatedSearchRenderList) diff --git a/mobile/lib/widgets/search/search_filter/sort_order_picker.dart b/mobile/lib/widgets/search/search_filter/sort_order_picker.dart new file mode 100644 index 0000000000..7753b47622 --- /dev/null +++ b/mobile/lib/widgets/search/search_filter/sort_order_picker.dart @@ -0,0 +1,42 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:openapi/api.dart' show AssetOrder; + +enum _SortOption { bestMatch, newest, oldest } + +class SortOrderPicker extends HookWidget { + const SortOrderPicker({super.key, required this.onSelect, this.order}); + + final Function(AssetOrder?) onSelect; + final AssetOrder? order; + + @override + Widget build(BuildContext context) { + final selected = useState<_SortOption>(switch (order) { + AssetOrder.desc => _SortOption.newest, + AssetOrder.asc => _SortOption.oldest, + _ => _SortOption.bestMatch, + }); + + return RadioGroup<_SortOption>( + onChanged: (value) { + if (value == null) return; + selected.value = value; + onSelect(switch (value) { + _SortOption.bestMatch => null, + _SortOption.newest => AssetOrder.desc, + _SortOption.oldest => AssetOrder.asc, + }); + }, + groupValue: selected.value, + child: Column( + children: [ + RadioListTile(title: const Text('best_match').tr(), value: _SortOption.bestMatch), + RadioListTile(title: const Text('newest_first').tr(), value: _SortOption.newest), + RadioListTile(title: const Text('oldest_first').tr(), value: _SortOption.oldest), + ], + ), + ); + } +} diff --git a/mobile/openapi/lib/model/smart_search_dto.dart b/mobile/openapi/lib/model/smart_search_dto.dart index 7d43cea872..4476205ec3 100644 --- a/mobile/openapi/lib/model/smart_search_dto.dart +++ b/mobile/openapi/lib/model/smart_search_dto.dart @@ -30,6 +30,7 @@ class SmartSearchDto { this.make, this.model, this.ocr, + this.order, this.page, this.personIds = const [], this.query, @@ -167,6 +168,15 @@ class SmartSearchDto { /// String? ocr; + /// Sort order by date. If not provided, results are sorted by relevance. + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + AssetOrder? order; + /// Page number /// /// Minimum value: 1 @@ -338,6 +348,7 @@ class SmartSearchDto { other.make == make && other.model == model && other.ocr == ocr && + other.order == order && other.page == page && _deepEquality.equals(other.personIds, personIds) && other.query == query && @@ -377,6 +388,7 @@ class SmartSearchDto { (make == null ? 0 : make!.hashCode) + (model == null ? 0 : model!.hashCode) + (ocr == null ? 0 : ocr!.hashCode) + + (order == null ? 0 : order!.hashCode) + (page == null ? 0 : page!.hashCode) + (personIds.hashCode) + (query == null ? 0 : query!.hashCode) + @@ -397,7 +409,7 @@ class SmartSearchDto { (withExif == null ? 0 : withExif!.hashCode); @override - String toString() => 'SmartSearchDto[albumIds=$albumIds, city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, language=$language, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, ocr=$ocr, page=$page, personIds=$personIds, query=$query, queryAssetId=$queryAssetId, rating=$rating, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, visibility=$visibility, withDeleted=$withDeleted, withExif=$withExif]'; + String toString() => 'SmartSearchDto[albumIds=$albumIds, city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, language=$language, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, ocr=$ocr, order=$order, page=$page, personIds=$personIds, query=$query, queryAssetId=$queryAssetId, rating=$rating, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, visibility=$visibility, withDeleted=$withDeleted, withExif=$withExif]'; Map toJson() { final json = {}; @@ -482,6 +494,11 @@ class SmartSearchDto { } else { // json[r'ocr'] = null; } + if (this.order != null) { + json[r'order'] = this.order; + } else { + // json[r'order'] = null; + } if (this.page != null) { json[r'page'] = this.page; } else { @@ -599,6 +616,7 @@ class SmartSearchDto { make: mapValueOfType(json, r'make'), model: mapValueOfType(json, r'model'), ocr: mapValueOfType(json, r'ocr'), + order: AssetOrder.fromJson(json[r'order']), page: num.parse('${json[r'page']}'), personIds: json[r'personIds'] is Iterable ? (json[r'personIds'] as Iterable).cast().toList(growable: false) diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 0e57fc4819..9312c99016 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -22063,6 +22063,14 @@ "description": "Filter by OCR text content", "type": "string" }, + "order": { + "allOf": [ + { + "$ref": "#/components/schemas/AssetOrder" + } + ], + "description": "Sort order by date. If not provided, results are sorted by relevance." + }, "page": { "description": "Page number", "minimum": 1, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index acd8109cd3..d2f30f2f7f 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1893,6 +1893,8 @@ export type SmartSearchDto = { model?: string | null; /** Filter by OCR text content */ ocr?: string; + /** Sort order by date. If not provided, results are sorted by relevance. */ + order?: AssetOrder; /** Page number */ page?: number; /** Filter by person IDs */ diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index 47a1889e47..a43a2bda6d 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -237,6 +237,14 @@ export class SmartSearchDto extends BaseSearchWithResultsDto { @Type(() => Number) @Optional() page?: number; + + @ValidateEnum({ + enum: AssetOrder, + name: 'AssetOrder', + optional: true, + description: 'Sort order by date. If not provided, results are sorted by relevance.', + }) + order?: AssetOrder; } export class SearchPlacesDto { diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index 615b35c417..18802a3722 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -129,6 +129,7 @@ export type SmartSearchOptions = SearchDateOptions & SearchEmbeddingOptions & SearchExifOptions & SearchOneToOneRelationOptions & + SearchOrderOptions & SearchStatusOptions & SearchUserIdOptions & SearchPeopleOptions & @@ -300,7 +301,12 @@ export class SearchRepository { const items = await searchAssetBuilder(trx, options) .selectAll('asset') .innerJoin('smart_search', 'asset.id', 'smart_search.assetId') - .orderBy(sql`smart_search.embedding <=> ${options.embedding}`) + .$if(!options.orderDirection, (qb) => qb.orderBy(sql`smart_search.embedding <=> ${options.embedding}`)) + .$if(!!options.orderDirection, (qb) => + qb + .where(sql`(smart_search.embedding <=> ${options.embedding}) <= 0.9`) + .orderBy('asset.fileCreatedAt', options.orderDirection as OrderByDirection), + ) .limit(pagination.size + 1) .offset((pagination.page - 1) * pagination.size) .execute(); diff --git a/server/src/services/search.service.ts b/server/src/services/search.service.ts index 9a6f8321a9..d27105f3bf 100644 --- a/server/src/services/search.service.ts +++ b/server/src/services/search.service.ts @@ -139,7 +139,7 @@ export class SearchService extends BaseService { const size = dto.size || 100; const { hasNextPage, items } = await this.searchRepository.searchSmart( { page, size }, - { ...dto, userIds: await userIds, embedding }, + { ...dto, userIds: await userIds, embedding, orderDirection: dto.order }, ); return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null, { auth }); diff --git a/web/src/lib/components/shared-components/gallery-viewer/date-grouped-gallery-viewer.svelte b/web/src/lib/components/shared-components/gallery-viewer/date-grouped-gallery-viewer.svelte new file mode 100644 index 0000000000..a22feca9e4 --- /dev/null +++ b/web/src/lib/components/shared-components/gallery-viewer/date-grouped-gallery-viewer.svelte @@ -0,0 +1,459 @@ + + + updateSlidingWindow()} +/> + +{#if assets.length > 0} +
+ {#each dateGroups as group (group.date.toISODate())} + {#if isGroupVisible(group)} + +
+ {group.title} +
+ + +
+ {#each group.assets as asset, i (asset.id)} + {#if isAssetVisible(group, i)} + {@const currentAsset = toTimelineAsset(asset)} +
+ { + if (assetInteraction.selectionActive) { + handleSelectAssets(currentAsset); + return; + } + void navigateToAsset(asset); + }} + onSelect={() => handleSelectAssets(currentAsset)} + onMouseEvent={() => assetMouseEventHandler(currentAsset)} + {showArchiveIcon} + asset={currentAsset} + selected={assetInteraction.hasSelectedAsset(currentAsset.id)} + selectionCandidate={assetInteraction.hasSelectionCandidate(currentAsset.id)} + thumbnailWidth={group.geometry.getWidth(i)} + thumbnailHeight={group.geometry.getHeight(i)} + /> +
+ {/if} + {/each} +
+ {/if} + {/each} +
+{/if} + + +{#if $isViewerOpen} + + {#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }} + { + assetViewingStore.showAssetViewer(false); + handlePromiseError(navigate({ targetRoute: 'current', assetId: null })); + }} + /> + {/await} + +{/if} diff --git a/web/src/lib/components/shared-components/search-bar/search-sort-section.svelte b/web/src/lib/components/shared-components/search-bar/search-sort-section.svelte new file mode 100644 index 0000000000..964d23a273 --- /dev/null +++ b/web/src/lib/components/shared-components/search-bar/search-sort-section.svelte @@ -0,0 +1,41 @@ + + +
+
+ {$t('sort_order')} + +
+ + + +
+
+
diff --git a/web/src/lib/modals/SearchFilterModal.svelte b/web/src/lib/modals/SearchFilterModal.svelte index 50a6fdd4e9..8a3425fb22 100644 --- a/web/src/lib/modals/SearchFilterModal.svelte +++ b/web/src/lib/modals/SearchFilterModal.svelte @@ -16,6 +16,7 @@ display: SearchDisplayFilters; mediaType: MediaType; rating?: number; + sortOrder: 'best-match' | 'newest' | 'oldest'; }; @@ -28,13 +29,14 @@ import SearchLocationSection from '$lib/components/shared-components/search-bar/search-location-section.svelte'; import SearchMediaSection from '$lib/components/shared-components/search-bar/search-media-section.svelte'; import SearchPeopleSection from '$lib/components/shared-components/search-bar/search-people-section.svelte'; + import SearchSortSection from '$lib/components/shared-components/search-bar/search-sort-section.svelte'; import SearchRatingsSection from '$lib/components/shared-components/search-bar/search-ratings-section.svelte'; import SearchTagsSection from '$lib/components/shared-components/search-bar/search-tags-section.svelte'; import SearchTextSection from '$lib/components/shared-components/search-bar/search-text-section.svelte'; import { preferences } from '$lib/stores/user.store'; import { parseUtcDate } from '$lib/utils/date-time'; import { generateId } from '$lib/utils/generate-id'; - import { AssetTypeEnum, AssetVisibility, type MetadataSearchDto, type SmartSearchDto } from '@immich/sdk'; + import { AssetOrder, AssetTypeEnum, AssetVisibility, type MetadataSearchDto, type SmartSearchDto } from '@immich/sdk'; import { Button, HStack, Modal, ModalBody, ModalFooter } from '@immich/ui'; import { mdiTune } from '@mdi/js'; import type { DateTime } from 'luxon'; @@ -111,6 +113,12 @@ ? MediaType.Video : MediaType.All, rating: searchQuery.rating, + sortOrder: + 'order' in searchQuery && searchQuery.order + ? searchQuery.order === AssetOrder.Asc + ? 'oldest' + : 'newest' + : 'best-match', }); const resetForm = () => { @@ -130,6 +138,7 @@ }, mediaType: MediaType.All, rating: undefined, + sortOrder: 'best-match', }; }; @@ -143,6 +152,9 @@ const query = filter.query || undefined; + const order = + filter.sortOrder === 'newest' ? AssetOrder.Desc : filter.sortOrder === 'oldest' ? AssetOrder.Asc : undefined; + let payload: SmartSearchDto | MetadataSearchDto = { query: filter.queryType === 'smart' ? query : undefined, ocr: filter.queryType === 'ocr' ? query : undefined, @@ -163,6 +175,7 @@ tagIds: filter.tagIds === null ? null : filter.tagIds.size > 0 ? [...filter.tagIds] : undefined, type, rating: filter.rating, + order, }; onClose(payload); @@ -218,6 +231,9 @@ + + + diff --git a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte index d43cdcd5bb..8789581632 100644 --- a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -5,6 +5,7 @@ import OnEvents from '$lib/components/OnEvents.svelte'; import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte'; + import DateGroupedGalleryViewer from '$lib/components/shared-components/gallery-viewer/date-grouped-gallery-viewer.svelte'; import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte'; import SearchBar from '$lib/components/shared-components/search-bar/search-bar.svelte'; import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte'; @@ -68,6 +69,7 @@ let searchQuery = $derived(page.url.searchParams.get(QueryParameter.QUERY)); let smartSearchEnabled = $derived(featureFlagsManager.value.smartSearch); let terms = $derived(searchQuery ? JSON.parse(searchQuery) : {}); + let isSortedByDate = $derived(!!terms.order); const isAllUserOwned = $derived( $user && assetInteraction.selectedAssets.every((asset) => asset.ownerId === $user.id), @@ -196,6 +198,7 @@ description: $t('description'), queryAssetId: $t('query_asset_id'), ocr: $t('ocr'), + order: $t('sort_order'), }; return keyMap[key] || key; } @@ -296,15 +299,27 @@ >
{#if searchResultAssets.length > 0} - + {#if isSortedByDate} + + {:else} + + {/if} {:else if !isLoading}