mirror of
https://github.com/immich-app/immich.git
synced 2026-03-22 14:29:26 +03:00
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.
This commit is contained in:
@@ -689,6 +689,7 @@
|
|||||||
"backup_settings_subtitle": "Manage upload settings",
|
"backup_settings_subtitle": "Manage upload settings",
|
||||||
"backup_upload_details_page_more_details": "Tap for more details",
|
"backup_upload_details_page_more_details": "Tap for more details",
|
||||||
"backward": "Backward",
|
"backward": "Backward",
|
||||||
|
"best_match": "Best match",
|
||||||
"biometric_auth_enabled": "Biometric authentication enabled",
|
"biometric_auth_enabled": "Biometric authentication enabled",
|
||||||
"biometric_locked_out": "You are locked out of biometric authentication",
|
"biometric_locked_out": "You are locked out of biometric authentication",
|
||||||
"biometric_no_options": "No biometric options available",
|
"biometric_no_options": "No biometric options available",
|
||||||
@@ -1945,6 +1946,8 @@
|
|||||||
"search_filter_media_type_title": "Select media type",
|
"search_filter_media_type_title": "Select media type",
|
||||||
"search_filter_ocr": "Search by OCR",
|
"search_filter_ocr": "Search by OCR",
|
||||||
"search_filter_people_title": "Select people",
|
"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_star_rating": "Star Rating",
|
||||||
"search_filter_tags_title": "Select tags",
|
"search_filter_tags_title": "Select tags",
|
||||||
"search_for": "Search for",
|
"search_for": "Search for",
|
||||||
@@ -2160,6 +2163,7 @@
|
|||||||
"sort_modified": "Date modified",
|
"sort_modified": "Date modified",
|
||||||
"sort_newest": "Newest photo",
|
"sort_newest": "Newest photo",
|
||||||
"sort_oldest": "Oldest photo",
|
"sort_oldest": "Oldest photo",
|
||||||
|
"sort_order": "Sort order",
|
||||||
"sort_people_by_similarity": "Sort people by similarity",
|
"sort_people_by_similarity": "Sort people by similarity",
|
||||||
"sort_recent": "Most recent photo",
|
"sort_recent": "Most recent photo",
|
||||||
"sort_title": "Title",
|
"sort_title": "Title",
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ class SearchApiRepository extends ApiRepository {
|
|||||||
personIds: filter.people.map((e) => e.id).toList(),
|
personIds: filter.people.map((e) => e.id).toList(),
|
||||||
tagIds: filter.tagIds,
|
tagIds: filter.tagIds,
|
||||||
type: type,
|
type: type,
|
||||||
|
order: filter.order,
|
||||||
page: page,
|
page: page,
|
||||||
size: 100,
|
size: 100,
|
||||||
),
|
),
|
||||||
@@ -62,6 +63,7 @@ class SearchApiRepository extends ApiRepository {
|
|||||||
personIds: filter.people.map((e) => e.id).toList(),
|
personIds: filter.people.map((e) => e.id).toList(),
|
||||||
tagIds: filter.tagIds,
|
tagIds: filter.tagIds,
|
||||||
type: type,
|
type: type,
|
||||||
|
order: filter.order ?? AssetOrder.desc,
|
||||||
page: page,
|
page: page,
|
||||||
size: 1000,
|
size: 1000,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'dart:convert';
|
|||||||
|
|
||||||
import 'package:immich_mobile/domain/models/person.model.dart';
|
import 'package:immich_mobile/domain/models/person.model.dart';
|
||||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
|
import 'package:openapi/api.dart' show AssetOrder;
|
||||||
|
|
||||||
class SearchLocationFilter {
|
class SearchLocationFilter {
|
||||||
String? country;
|
String? country;
|
||||||
@@ -221,6 +222,7 @@ class SearchFilter {
|
|||||||
SearchDateFilter date;
|
SearchDateFilter date;
|
||||||
SearchRatingFilter rating;
|
SearchRatingFilter rating;
|
||||||
SearchDisplayFilters display;
|
SearchDisplayFilters display;
|
||||||
|
AssetOrder? order;
|
||||||
|
|
||||||
// Enum
|
// Enum
|
||||||
AssetType mediaType;
|
AssetType mediaType;
|
||||||
@@ -233,6 +235,7 @@ class SearchFilter {
|
|||||||
this.language,
|
this.language,
|
||||||
this.assetId,
|
this.assetId,
|
||||||
this.tagIds,
|
this.tagIds,
|
||||||
|
this.order,
|
||||||
required this.people,
|
required this.people,
|
||||||
required this.location,
|
required this.location,
|
||||||
required this.camera,
|
required this.camera,
|
||||||
@@ -279,6 +282,7 @@ class SearchFilter {
|
|||||||
SearchDisplayFilters? display,
|
SearchDisplayFilters? display,
|
||||||
SearchRatingFilter? rating,
|
SearchRatingFilter? rating,
|
||||||
AssetType? mediaType,
|
AssetType? mediaType,
|
||||||
|
AssetOrder? Function()? order,
|
||||||
}) {
|
}) {
|
||||||
return SearchFilter(
|
return SearchFilter(
|
||||||
context: context ?? this.context,
|
context: context ?? this.context,
|
||||||
@@ -295,12 +299,13 @@ class SearchFilter {
|
|||||||
rating: rating ?? this.rating,
|
rating: rating ?? this.rating,
|
||||||
mediaType: mediaType ?? this.mediaType,
|
mediaType: mediaType ?? this.mediaType,
|
||||||
tagIds: tagIds ?? this.tagIds,
|
tagIds: tagIds ?? this.tagIds,
|
||||||
|
order: order != null ? order() : this.order,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
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
|
@override
|
||||||
@@ -320,7 +325,8 @@ class SearchFilter {
|
|||||||
other.date == date &&
|
other.date == date &&
|
||||||
other.display == display &&
|
other.display == display &&
|
||||||
other.rating == rating &&
|
other.rating == rating &&
|
||||||
other.mediaType == mediaType;
|
other.mediaType == mediaType &&
|
||||||
|
other.order == order;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -338,6 +344,7 @@ class SearchFilter {
|
|||||||
date.hashCode ^
|
date.hashCode ^
|
||||||
display.hashCode ^
|
display.hashCode ^
|
||||||
rating.hashCode ^
|
rating.hashCode ^
|
||||||
mediaType.hashCode;
|
mediaType.hashCode ^
|
||||||
|
order.hashCode;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/models/search/search_filter.model.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/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/providers/search/search_input_focus.provider.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.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/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_chip.dart';
|
||||||
import 'package:immich_mobile/widgets/search/search_filter/search_filter_utils.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()
|
@RoutePage()
|
||||||
class SearchPage extends HookConsumerWidget {
|
class SearchPage extends HookConsumerWidget {
|
||||||
@@ -56,6 +59,7 @@ class SearchPage extends HookConsumerWidget {
|
|||||||
final locationCurrentFilterWidget = useState<Widget?>(null);
|
final locationCurrentFilterWidget = useState<Widget?>(null);
|
||||||
final mediaTypeCurrentFilterWidget = useState<Widget?>(null);
|
final mediaTypeCurrentFilterWidget = useState<Widget?>(null);
|
||||||
final displayOptionCurrentFilterWidget = useState<Widget?>(null);
|
final displayOptionCurrentFilterWidget = useState<Widget?>(null);
|
||||||
|
final sortOrderCurrentFilterWidget = useState<Widget?>(null);
|
||||||
|
|
||||||
final isSearching = useState(false);
|
final isSearching = useState(false);
|
||||||
|
|
||||||
@@ -78,6 +82,8 @@ class SearchPage extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isSearching.value = true;
|
isSearching.value = true;
|
||||||
|
ref.read(searchGroupByProvider.notifier).state =
|
||||||
|
filter.value.order != null ? GroupAssetsBy.day : GroupAssetsBy.none;
|
||||||
ref.watch(paginatedSearchProvider.notifier).clear();
|
ref.watch(paginatedSearchProvider.notifier).clear();
|
||||||
final hasResult = await ref.watch(paginatedSearchProvider.notifier).search(filter.value);
|
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) {
|
handleTextSubmitted(String value) {
|
||||||
switch (textSearchType.value) {
|
switch (textSearchType.value) {
|
||||||
case TextSearchType.context:
|
case TextSearchType.context:
|
||||||
@@ -594,6 +631,12 @@ class SearchPage extends HookConsumerWidget {
|
|||||||
label: 'search_filter_display_options'.tr(),
|
label: 'search_filter_display_options'.tr(),
|
||||||
currentFilter: displayOptionCurrentFilterWidget.value,
|
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)
|
if (isSearching.value)
|
||||||
const Expanded(child: Center(child: CircularProgressIndicator()))
|
const Expanded(child: Center(child: CircularProgressIndicator()))
|
||||||
else
|
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 {
|
class SearchResultGrid extends StatelessWidget {
|
||||||
final VoidCallback onScrollEnd;
|
final VoidCallback onScrollEnd;
|
||||||
final bool isSearching;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -640,7 +693,7 @@ class SearchResultGrid extends StatelessWidget {
|
|||||||
editEnabled: true,
|
editEnabled: true,
|
||||||
favoriteEnabled: true,
|
favoriteEnabled: true,
|
||||||
stackEnabled: false,
|
stackEnabled: false,
|
||||||
dragScrollLabelEnabled: false,
|
dragScrollLabelEnabled: dragScrollLabelEnabled,
|
||||||
emptyIndicator: Padding(
|
emptyIndicator: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||||
child: !isSearching ? const SearchEmptyContent() : const SizedBox.shrink(),
|
child: !isSearching ? const SearchEmptyContent() : const SizedBox.shrink(),
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
|
|||||||
|
|
||||||
part 'paginated_search.provider.g.dart';
|
part 'paginated_search.provider.g.dart';
|
||||||
|
|
||||||
|
final searchGroupByProvider = StateProvider<GroupAssetsBy>((ref) => GroupAssetsBy.none);
|
||||||
|
|
||||||
final paginatedSearchProvider = StateNotifierProvider<PaginatedSearchNotifier, SearchResult>(
|
final paginatedSearchProvider = StateNotifierProvider<PaginatedSearchNotifier, SearchResult>(
|
||||||
(ref) => PaginatedSearchNotifier(ref.watch(searchServiceProvider)),
|
(ref) => PaginatedSearchNotifier(ref.watch(searchServiceProvider)),
|
||||||
);
|
);
|
||||||
@@ -41,6 +43,7 @@ class PaginatedSearchNotifier extends StateNotifier<SearchResult> {
|
|||||||
@riverpod
|
@riverpod
|
||||||
Future<RenderList> paginatedSearchRenderList(Ref ref) {
|
Future<RenderList> paginatedSearchRenderList(Ref ref) {
|
||||||
final result = ref.watch(paginatedSearchProvider);
|
final result = ref.watch(paginatedSearchProvider);
|
||||||
|
final groupBy = ref.watch(searchGroupByProvider);
|
||||||
final timelineService = ref.watch(timelineServiceProvider);
|
final timelineService = ref.watch(timelineServiceProvider);
|
||||||
return timelineService.getTimelineFromAssets(result.assets, GroupAssetsBy.none);
|
return timelineService.getTimelineFromAssets(result.assets, groupBy);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ part of 'paginated_search.provider.dart';
|
|||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
String _$paginatedSearchRenderListHash() =>
|
String _$paginatedSearchRenderListHash() =>
|
||||||
r'22d715ff7864e5a946be38322ce7813616f899c2';
|
r'bb1ea9153b2a186778420426f1fb1add6d6a9140';
|
||||||
|
|
||||||
/// See also [paginatedSearchRenderList].
|
/// See also [paginatedSearchRenderList].
|
||||||
@ProviderFor(paginatedSearchRenderList)
|
@ProviderFor(paginatedSearchRenderList)
|
||||||
|
|||||||
@@ -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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
20
mobile/openapi/lib/model/smart_search_dto.dart
generated
20
mobile/openapi/lib/model/smart_search_dto.dart
generated
@@ -30,6 +30,7 @@ class SmartSearchDto {
|
|||||||
this.make,
|
this.make,
|
||||||
this.model,
|
this.model,
|
||||||
this.ocr,
|
this.ocr,
|
||||||
|
this.order,
|
||||||
this.page,
|
this.page,
|
||||||
this.personIds = const [],
|
this.personIds = const [],
|
||||||
this.query,
|
this.query,
|
||||||
@@ -167,6 +168,15 @@ class SmartSearchDto {
|
|||||||
///
|
///
|
||||||
String? ocr;
|
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
|
/// Page number
|
||||||
///
|
///
|
||||||
/// Minimum value: 1
|
/// Minimum value: 1
|
||||||
@@ -338,6 +348,7 @@ class SmartSearchDto {
|
|||||||
other.make == make &&
|
other.make == make &&
|
||||||
other.model == model &&
|
other.model == model &&
|
||||||
other.ocr == ocr &&
|
other.ocr == ocr &&
|
||||||
|
other.order == order &&
|
||||||
other.page == page &&
|
other.page == page &&
|
||||||
_deepEquality.equals(other.personIds, personIds) &&
|
_deepEquality.equals(other.personIds, personIds) &&
|
||||||
other.query == query &&
|
other.query == query &&
|
||||||
@@ -377,6 +388,7 @@ class SmartSearchDto {
|
|||||||
(make == null ? 0 : make!.hashCode) +
|
(make == null ? 0 : make!.hashCode) +
|
||||||
(model == null ? 0 : model!.hashCode) +
|
(model == null ? 0 : model!.hashCode) +
|
||||||
(ocr == null ? 0 : ocr!.hashCode) +
|
(ocr == null ? 0 : ocr!.hashCode) +
|
||||||
|
(order == null ? 0 : order!.hashCode) +
|
||||||
(page == null ? 0 : page!.hashCode) +
|
(page == null ? 0 : page!.hashCode) +
|
||||||
(personIds.hashCode) +
|
(personIds.hashCode) +
|
||||||
(query == null ? 0 : query!.hashCode) +
|
(query == null ? 0 : query!.hashCode) +
|
||||||
@@ -397,7 +409,7 @@ class SmartSearchDto {
|
|||||||
(withExif == null ? 0 : withExif!.hashCode);
|
(withExif == null ? 0 : withExif!.hashCode);
|
||||||
|
|
||||||
@override
|
@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<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
final json = <String, dynamic>{};
|
final json = <String, dynamic>{};
|
||||||
@@ -482,6 +494,11 @@ class SmartSearchDto {
|
|||||||
} else {
|
} else {
|
||||||
// json[r'ocr'] = null;
|
// json[r'ocr'] = null;
|
||||||
}
|
}
|
||||||
|
if (this.order != null) {
|
||||||
|
json[r'order'] = this.order;
|
||||||
|
} else {
|
||||||
|
// json[r'order'] = null;
|
||||||
|
}
|
||||||
if (this.page != null) {
|
if (this.page != null) {
|
||||||
json[r'page'] = this.page;
|
json[r'page'] = this.page;
|
||||||
} else {
|
} else {
|
||||||
@@ -599,6 +616,7 @@ class SmartSearchDto {
|
|||||||
make: mapValueOfType<String>(json, r'make'),
|
make: mapValueOfType<String>(json, r'make'),
|
||||||
model: mapValueOfType<String>(json, r'model'),
|
model: mapValueOfType<String>(json, r'model'),
|
||||||
ocr: mapValueOfType<String>(json, r'ocr'),
|
ocr: mapValueOfType<String>(json, r'ocr'),
|
||||||
|
order: AssetOrder.fromJson(json[r'order']),
|
||||||
page: num.parse('${json[r'page']}'),
|
page: num.parse('${json[r'page']}'),
|
||||||
personIds: json[r'personIds'] is Iterable
|
personIds: json[r'personIds'] is Iterable
|
||||||
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
|
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
|
||||||
|
|||||||
@@ -22063,6 +22063,14 @@
|
|||||||
"description": "Filter by OCR text content",
|
"description": "Filter by OCR text content",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"order": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/AssetOrder"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Sort order by date. If not provided, results are sorted by relevance."
|
||||||
|
},
|
||||||
"page": {
|
"page": {
|
||||||
"description": "Page number",
|
"description": "Page number",
|
||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
|
|||||||
@@ -1893,6 +1893,8 @@ export type SmartSearchDto = {
|
|||||||
model?: string | null;
|
model?: string | null;
|
||||||
/** Filter by OCR text content */
|
/** Filter by OCR text content */
|
||||||
ocr?: string;
|
ocr?: string;
|
||||||
|
/** Sort order by date. If not provided, results are sorted by relevance. */
|
||||||
|
order?: AssetOrder;
|
||||||
/** Page number */
|
/** Page number */
|
||||||
page?: number;
|
page?: number;
|
||||||
/** Filter by person IDs */
|
/** Filter by person IDs */
|
||||||
|
|||||||
@@ -237,6 +237,14 @@ export class SmartSearchDto extends BaseSearchWithResultsDto {
|
|||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@Optional()
|
@Optional()
|
||||||
page?: number;
|
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 {
|
export class SearchPlacesDto {
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ export type SmartSearchOptions = SearchDateOptions &
|
|||||||
SearchEmbeddingOptions &
|
SearchEmbeddingOptions &
|
||||||
SearchExifOptions &
|
SearchExifOptions &
|
||||||
SearchOneToOneRelationOptions &
|
SearchOneToOneRelationOptions &
|
||||||
|
SearchOrderOptions &
|
||||||
SearchStatusOptions &
|
SearchStatusOptions &
|
||||||
SearchUserIdOptions &
|
SearchUserIdOptions &
|
||||||
SearchPeopleOptions &
|
SearchPeopleOptions &
|
||||||
@@ -300,7 +301,12 @@ export class SearchRepository {
|
|||||||
const items = await searchAssetBuilder(trx, options)
|
const items = await searchAssetBuilder(trx, options)
|
||||||
.selectAll('asset')
|
.selectAll('asset')
|
||||||
.innerJoin('smart_search', 'asset.id', 'smart_search.assetId')
|
.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)
|
.limit(pagination.size + 1)
|
||||||
.offset((pagination.page - 1) * pagination.size)
|
.offset((pagination.page - 1) * pagination.size)
|
||||||
.execute();
|
.execute();
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ export class SearchService extends BaseService {
|
|||||||
const size = dto.size || 100;
|
const size = dto.size || 100;
|
||||||
const { hasNextPage, items } = await this.searchRepository.searchSmart(
|
const { hasNextPage, items } = await this.searchRepository.searchSmart(
|
||||||
{ page, size },
|
{ 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 });
|
return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null, { auth });
|
||||||
|
|||||||
@@ -0,0 +1,459 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
|
||||||
|
import type { Action } from '$lib/components/asset-viewer/actions/action';
|
||||||
|
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
|
||||||
|
import { AssetAction } from '$lib/constants';
|
||||||
|
import Portal from '$lib/elements/Portal.svelte';
|
||||||
|
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||||
|
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
|
||||||
|
import AssetDeleteConfirmModal from '$lib/modals/AssetDeleteConfirmModal.svelte';
|
||||||
|
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
|
||||||
|
import { Route } from '$lib/route';
|
||||||
|
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
|
import { showDeleteModal } from '$lib/stores/preferences.store';
|
||||||
|
import { handlePromiseError } from '$lib/utils';
|
||||||
|
import { deleteAssets } from '$lib/utils/actions';
|
||||||
|
import { archiveAssets, cancelMultiselect, getNextAsset, getPreviousAsset, navigateToAsset } from '$lib/utils/asset-utils';
|
||||||
|
import { moveFocus } from '$lib/utils/focus-util';
|
||||||
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
|
import type { CommonJustifiedLayout } from '$lib/utils/layout-utils';
|
||||||
|
import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils';
|
||||||
|
import { navigate } from '$lib/utils/navigation';
|
||||||
|
import { formatGroupTitle, toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
|
import { AssetVisibility, type AssetResponseDto } from '@immich/sdk';
|
||||||
|
import { modalManager, Text } from '@immich/ui';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import { debounce } from 'lodash-es';
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
|
type DateGroup = {
|
||||||
|
date: DateTime;
|
||||||
|
title: string;
|
||||||
|
assets: AssetResponseDto[];
|
||||||
|
geometry: CommonJustifiedLayout;
|
||||||
|
offsetTop: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
assets: AssetResponseDto[];
|
||||||
|
assetInteraction: AssetInteraction;
|
||||||
|
disableAssetSelect?: boolean;
|
||||||
|
showArchiveIcon?: boolean;
|
||||||
|
viewport: Viewport;
|
||||||
|
onIntersected?: (() => void) | undefined;
|
||||||
|
onReload?: (() => void) | undefined;
|
||||||
|
slidingWindowOffset?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
assets = $bindable(),
|
||||||
|
assetInteraction,
|
||||||
|
disableAssetSelect = false,
|
||||||
|
showArchiveIcon = false,
|
||||||
|
viewport,
|
||||||
|
onIntersected = undefined,
|
||||||
|
onReload = undefined,
|
||||||
|
slidingWindowOffset = 0,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const HEADER_HEIGHT = 48;
|
||||||
|
|
||||||
|
let { isViewing: isViewerOpen, asset: viewingAsset } = assetViewingStore;
|
||||||
|
|
||||||
|
function groupAssetsByDate(items: AssetResponseDto[]): DateGroup[] {
|
||||||
|
const groupEntries: { key: string; assets: AssetResponseDto[] }[] = [];
|
||||||
|
|
||||||
|
for (const asset of items) {
|
||||||
|
const date = DateTime.fromISO(asset.localDateTime, { zone: 'UTC' });
|
||||||
|
const key = date.toISODate() ?? 'unknown';
|
||||||
|
const last = groupEntries.at(-1);
|
||||||
|
if (last && last.key === key) {
|
||||||
|
last.assets.push(asset);
|
||||||
|
} else {
|
||||||
|
groupEntries.push({ key, assets: [asset] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups: DateGroup[] = [];
|
||||||
|
let offsetTop = 0;
|
||||||
|
const rowWidth = Math.floor(viewport.width);
|
||||||
|
const rowHeight = rowWidth < 850 ? 100 : 235;
|
||||||
|
|
||||||
|
for (const { key, assets: groupAssets } of groupEntries) {
|
||||||
|
const date = DateTime.fromISO(key, { zone: 'local' });
|
||||||
|
const geometry = getJustifiedLayoutFromAssets(groupAssets, {
|
||||||
|
spacing: 2,
|
||||||
|
heightTolerance: 0.5,
|
||||||
|
rowHeight,
|
||||||
|
rowWidth,
|
||||||
|
});
|
||||||
|
|
||||||
|
groups.push({
|
||||||
|
date: date as DateTime<true>,
|
||||||
|
title: formatGroupTitle(date),
|
||||||
|
assets: groupAssets,
|
||||||
|
geometry,
|
||||||
|
offsetTop,
|
||||||
|
});
|
||||||
|
|
||||||
|
offsetTop += HEADER_HEIGHT + geometry.containerHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateGroups = $derived(groupAssetsByDate(assets));
|
||||||
|
const totalHeight = $derived(
|
||||||
|
dateGroups.length > 0
|
||||||
|
? dateGroups.at(-1)!.offsetTop + HEADER_HEIGHT + dateGroups.at(-1)!.geometry.containerHeight
|
||||||
|
: 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
let shiftKeyIsDown = $state(false);
|
||||||
|
let lastAssetMouseEvent: TimelineAsset | null = $state(null);
|
||||||
|
let scrollTop = $state(0);
|
||||||
|
let slidingWindow = $derived.by(() => {
|
||||||
|
const top = (scrollTop || 0) - slidingWindowOffset;
|
||||||
|
const bottom = top + viewport.height + slidingWindowOffset;
|
||||||
|
return { top, bottom };
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateSlidingWindow = () => (scrollTop = document.scrollingElement?.scrollTop ?? 0);
|
||||||
|
|
||||||
|
const debouncedOnIntersected = debounce(() => onIntersected?.(), 750, { maxWait: 100, leading: true });
|
||||||
|
|
||||||
|
let lastIntersectedHeight = 0;
|
||||||
|
$effect(() => {
|
||||||
|
if (totalHeight - slidingWindow.bottom <= viewport.height && lastIntersectedHeight !== totalHeight) {
|
||||||
|
debouncedOnIntersected();
|
||||||
|
lastIntersectedHeight = totalHeight;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function isGroupVisible(group: DateGroup): boolean {
|
||||||
|
const groupTop = group.offsetTop;
|
||||||
|
const groupBottom = groupTop + HEADER_HEIGHT + group.geometry.containerHeight;
|
||||||
|
return groupTop < slidingWindow.bottom && groupBottom > slidingWindow.top;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAssetVisible(group: DateGroup, assetIndex: number): boolean {
|
||||||
|
const assetTop = group.offsetTop + HEADER_HEIGHT + group.geometry.getTop(assetIndex);
|
||||||
|
const assetBottom = assetTop + group.geometry.getHeight(assetIndex);
|
||||||
|
return assetTop < slidingWindow.bottom && assetBottom > slidingWindow.top;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectAllAssets = () => {
|
||||||
|
assetInteraction.selectAssets(assets.map((a) => toTimelineAsset(a)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const deselectAllAssets = () => {
|
||||||
|
cancelMultiselect(assetInteraction);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Shift') {
|
||||||
|
event.preventDefault();
|
||||||
|
shiftKeyIsDown = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeyUp = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Shift') {
|
||||||
|
event.preventDefault();
|
||||||
|
shiftKeyIsDown = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectAssets = (asset: TimelineAsset) => {
|
||||||
|
if (!asset) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const deselect = assetInteraction.hasSelectedAsset(asset.id);
|
||||||
|
|
||||||
|
if (deselect) {
|
||||||
|
for (const candidate of assetInteraction.assetSelectionCandidates) {
|
||||||
|
assetInteraction.removeAssetFromMultiselectGroup(candidate.id);
|
||||||
|
}
|
||||||
|
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
|
||||||
|
} else {
|
||||||
|
for (const candidate of assetInteraction.assetSelectionCandidates) {
|
||||||
|
assetInteraction.selectAsset(candidate);
|
||||||
|
}
|
||||||
|
assetInteraction.selectAsset(asset);
|
||||||
|
}
|
||||||
|
|
||||||
|
assetInteraction.clearAssetSelectionCandidates();
|
||||||
|
assetInteraction.setAssetSelectionStart(deselect ? null : asset);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectAssetCandidates = (asset: TimelineAsset | null) => {
|
||||||
|
if (asset) {
|
||||||
|
selectAssetCandidates(asset);
|
||||||
|
}
|
||||||
|
lastAssetMouseEvent = asset;
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectAssetCandidates = (endAsset: TimelineAsset) => {
|
||||||
|
if (!shiftKeyIsDown) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startAsset = assetInteraction.assetSelectionStart;
|
||||||
|
if (!startAsset) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let start = assets.findIndex((a) => a.id === startAsset.id);
|
||||||
|
let end = assets.findIndex((a) => a.id === endAsset.id);
|
||||||
|
|
||||||
|
if (start > end) {
|
||||||
|
[start, end] = [end, start];
|
||||||
|
}
|
||||||
|
|
||||||
|
assetInteraction.setAssetSelectionCandidates(assets.slice(start, end + 1).map((a) => toTimelineAsset(a)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSelectStart = (event: Event) => {
|
||||||
|
if (assetInteraction.selectionActive && shiftKeyIsDown) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDelete = () => {
|
||||||
|
const hasTrashedAsset = assetInteraction.selectedAssets.some((asset) => asset.isTrashed);
|
||||||
|
handlePromiseError(trashOrDelete(hasTrashedAsset));
|
||||||
|
};
|
||||||
|
|
||||||
|
const trashOrDelete = async (force: boolean = false) => {
|
||||||
|
const forceOrNoTrash = force || !featureFlagsManager.value.trash;
|
||||||
|
const selectedAssets = assetInteraction.selectedAssets;
|
||||||
|
|
||||||
|
if ($showDeleteModal && forceOrNoTrash) {
|
||||||
|
const confirmed = await modalManager.show(AssetDeleteConfirmModal, { size: selectedAssets.length });
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteAssets(
|
||||||
|
forceOrNoTrash,
|
||||||
|
(assetIds) => (assets = assets.filter((asset) => !assetIds.includes(asset.id))),
|
||||||
|
selectedAssets,
|
||||||
|
onReload,
|
||||||
|
);
|
||||||
|
|
||||||
|
assetInteraction.clearMultiselect();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleArchive = async () => {
|
||||||
|
const ids = await archiveAssets(
|
||||||
|
assetInteraction.selectedAssets,
|
||||||
|
assetInteraction.isAllArchived ? AssetVisibility.Timeline : AssetVisibility.Archive,
|
||||||
|
);
|
||||||
|
if (ids) {
|
||||||
|
assets = assets.filter((asset) => !ids.includes(asset.id));
|
||||||
|
deselectAllAssets();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const focusNextAsset = () => moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, 'next');
|
||||||
|
const focusPreviousAsset = () =>
|
||||||
|
moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, 'previous');
|
||||||
|
|
||||||
|
let isShortcutModalOpen = false;
|
||||||
|
|
||||||
|
const handleOpenShortcutModal = async () => {
|
||||||
|
if (isShortcutModalOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isShortcutModalOpen = true;
|
||||||
|
await modalManager.show(ShortcutsModal, {});
|
||||||
|
isShortcutModalOpen = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const shortcutList = $derived(
|
||||||
|
(() => {
|
||||||
|
if ($isViewerOpen) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const sc: ShortcutOptions[] = [
|
||||||
|
{ shortcut: { key: '?', shift: true }, onShortcut: handleOpenShortcutModal },
|
||||||
|
{ shortcut: { key: '/' }, onShortcut: () => goto(Route.explore()) },
|
||||||
|
{ shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets() },
|
||||||
|
{ shortcut: { key: 'ArrowRight' }, preventDefault: false, onShortcut: focusNextAsset },
|
||||||
|
{ shortcut: { key: 'ArrowLeft' }, preventDefault: false, onShortcut: focusPreviousAsset },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (assetInteraction.selectionActive) {
|
||||||
|
sc.push(
|
||||||
|
{ shortcut: { key: 'Escape' }, onShortcut: deselectAllAssets },
|
||||||
|
{ shortcut: { key: 'Delete' }, onShortcut: onDelete },
|
||||||
|
{ shortcut: { key: 'Delete', shift: true }, onShortcut: () => trashOrDelete(true) },
|
||||||
|
{ shortcut: { key: 'D', ctrl: true }, onShortcut: () => deselectAllAssets() },
|
||||||
|
{ shortcut: { key: 'a', shift: true }, onShortcut: toggleArchive },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sc;
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRandom = async (): Promise<{ id: string } | undefined> => {
|
||||||
|
if (assets.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const randomIndex = Math.floor(Math.random() * assets.length);
|
||||||
|
const asset = assets[randomIndex];
|
||||||
|
await navigateToAsset(asset);
|
||||||
|
return asset;
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, $t('errors.cannot_navigate_next_asset'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateCurrentAsset = (asset: AssetResponseDto) => {
|
||||||
|
const index = assets.findIndex((oldAsset) => oldAsset.id === asset.id);
|
||||||
|
assets[index] = asset;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAction = async (action: Action) => {
|
||||||
|
switch (action.type) {
|
||||||
|
case AssetAction.ARCHIVE:
|
||||||
|
case AssetAction.DELETE:
|
||||||
|
case AssetAction.TRASH: {
|
||||||
|
const nextAsset = assetCursor.nextAsset ?? assetCursor.previousAsset;
|
||||||
|
assets.splice(
|
||||||
|
assets.findIndex((currentAsset) => currentAsset.id === action.asset.id),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
if (assets.length === 0) {
|
||||||
|
return await goto(Route.photos());
|
||||||
|
}
|
||||||
|
if (nextAsset) {
|
||||||
|
await navigateToAsset(nextAsset);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const assetMouseEventHandler = (asset: TimelineAsset | null) => {
|
||||||
|
if (assetInteraction.selectionActive) {
|
||||||
|
handleSelectAssetCandidates(asset);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!lastAssetMouseEvent) {
|
||||||
|
assetInteraction.clearAssetSelectionCandidates();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!shiftKeyIsDown) {
|
||||||
|
assetInteraction.clearAssetSelectionCandidates();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (shiftKeyIsDown && lastAssetMouseEvent) {
|
||||||
|
selectAssetCandidates(lastAssetMouseEvent);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const assetCursor = $derived({
|
||||||
|
current: $viewingAsset,
|
||||||
|
nextAsset: getNextAsset(assets, $viewingAsset),
|
||||||
|
previousAsset: getPreviousAsset(assets, $viewingAsset),
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:document
|
||||||
|
onkeydown={onKeyDown}
|
||||||
|
onkeyup={onKeyUp}
|
||||||
|
onselectstart={onSelectStart}
|
||||||
|
use:shortcuts={shortcutList}
|
||||||
|
onscroll={() => updateSlidingWindow()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if assets.length > 0}
|
||||||
|
<div style:position="relative" style:height={totalHeight + 'px'} style:width={viewport.width + 'px'}>
|
||||||
|
{#each dateGroups as group (group.date.toISODate())}
|
||||||
|
{#if isGroupVisible(group)}
|
||||||
|
<!-- Date header -->
|
||||||
|
<div
|
||||||
|
class="absolute flex items-center px-2"
|
||||||
|
style:top={group.offsetTop + 'px'}
|
||||||
|
style:height={HEADER_HEIGHT + 'px'}
|
||||||
|
style:width="100%"
|
||||||
|
>
|
||||||
|
<Text fontWeight="medium" class="text-sm md:text-base">{group.title}</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Thumbnails -->
|
||||||
|
<div
|
||||||
|
class="absolute"
|
||||||
|
style:top={group.offsetTop + HEADER_HEIGHT + 'px'}
|
||||||
|
style:height={group.geometry.containerHeight + 'px'}
|
||||||
|
style:width={group.geometry.containerWidth + 'px'}
|
||||||
|
>
|
||||||
|
{#each group.assets as asset, i (asset.id)}
|
||||||
|
{#if isAssetVisible(group, i)}
|
||||||
|
{@const currentAsset = toTimelineAsset(asset)}
|
||||||
|
<div
|
||||||
|
class="absolute"
|
||||||
|
style:overflow="clip"
|
||||||
|
style:top={group.geometry.getTop(i) + 'px'}
|
||||||
|
style:left={group.geometry.getLeft(i) + 'px'}
|
||||||
|
style:width={group.geometry.getWidth(i) + 'px'}
|
||||||
|
style:height={group.geometry.getHeight(i) + 'px'}
|
||||||
|
>
|
||||||
|
<Thumbnail
|
||||||
|
readonly={disableAssetSelect}
|
||||||
|
onClick={() => {
|
||||||
|
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)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Overlay Asset Viewer -->
|
||||||
|
{#if $isViewerOpen}
|
||||||
|
<Portal target="body">
|
||||||
|
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
|
||||||
|
<AssetViewer
|
||||||
|
cursor={assetCursor}
|
||||||
|
onAction={handleAction}
|
||||||
|
onRandom={handleRandom}
|
||||||
|
onAssetChange={updateCurrentAsset}
|
||||||
|
onClose={() => {
|
||||||
|
assetViewingStore.showAssetViewer(false);
|
||||||
|
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/await}
|
||||||
|
</Portal>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import RadioButton from '$lib/elements/RadioButton.svelte';
|
||||||
|
import { Text } from '@immich/ui';
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
sortOrder: 'best-match' | 'newest' | 'oldest';
|
||||||
|
}
|
||||||
|
|
||||||
|
let { sortOrder = $bindable() }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div id="sort-order-selection">
|
||||||
|
<fieldset>
|
||||||
|
<Text class="mb-2" fontWeight="medium">{$t('sort_order')}</Text>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-x-5 gap-y-2 mt-1">
|
||||||
|
<RadioButton
|
||||||
|
name="sort-order"
|
||||||
|
id="sort-best-match"
|
||||||
|
bind:group={sortOrder}
|
||||||
|
label={$t('best_match')}
|
||||||
|
value="best-match"
|
||||||
|
/>
|
||||||
|
<RadioButton
|
||||||
|
name="sort-order"
|
||||||
|
id="sort-newest"
|
||||||
|
bind:group={sortOrder}
|
||||||
|
label={$t('newest_first')}
|
||||||
|
value="newest"
|
||||||
|
/>
|
||||||
|
<RadioButton
|
||||||
|
name="sort-order"
|
||||||
|
id="sort-oldest"
|
||||||
|
bind:group={sortOrder}
|
||||||
|
label={$t('oldest_first')}
|
||||||
|
value="oldest"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
display: SearchDisplayFilters;
|
display: SearchDisplayFilters;
|
||||||
mediaType: MediaType;
|
mediaType: MediaType;
|
||||||
rating?: number;
|
rating?: number;
|
||||||
|
sortOrder: 'best-match' | 'newest' | 'oldest';
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -28,13 +29,14 @@
|
|||||||
import SearchLocationSection from '$lib/components/shared-components/search-bar/search-location-section.svelte';
|
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 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 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 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 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 SearchTextSection from '$lib/components/shared-components/search-bar/search-text-section.svelte';
|
||||||
import { preferences } from '$lib/stores/user.store';
|
import { preferences } from '$lib/stores/user.store';
|
||||||
import { parseUtcDate } from '$lib/utils/date-time';
|
import { parseUtcDate } from '$lib/utils/date-time';
|
||||||
import { generateId } from '$lib/utils/generate-id';
|
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 { Button, HStack, Modal, ModalBody, ModalFooter } from '@immich/ui';
|
||||||
import { mdiTune } from '@mdi/js';
|
import { mdiTune } from '@mdi/js';
|
||||||
import type { DateTime } from 'luxon';
|
import type { DateTime } from 'luxon';
|
||||||
@@ -111,6 +113,12 @@
|
|||||||
? MediaType.Video
|
? MediaType.Video
|
||||||
: MediaType.All,
|
: MediaType.All,
|
||||||
rating: searchQuery.rating,
|
rating: searchQuery.rating,
|
||||||
|
sortOrder:
|
||||||
|
'order' in searchQuery && searchQuery.order
|
||||||
|
? searchQuery.order === AssetOrder.Asc
|
||||||
|
? 'oldest'
|
||||||
|
: 'newest'
|
||||||
|
: 'best-match',
|
||||||
});
|
});
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
@@ -130,6 +138,7 @@
|
|||||||
},
|
},
|
||||||
mediaType: MediaType.All,
|
mediaType: MediaType.All,
|
||||||
rating: undefined,
|
rating: undefined,
|
||||||
|
sortOrder: 'best-match',
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -143,6 +152,9 @@
|
|||||||
|
|
||||||
const query = filter.query || undefined;
|
const query = filter.query || undefined;
|
||||||
|
|
||||||
|
const order =
|
||||||
|
filter.sortOrder === 'newest' ? AssetOrder.Desc : filter.sortOrder === 'oldest' ? AssetOrder.Asc : undefined;
|
||||||
|
|
||||||
let payload: SmartSearchDto | MetadataSearchDto = {
|
let payload: SmartSearchDto | MetadataSearchDto = {
|
||||||
query: filter.queryType === 'smart' ? query : undefined,
|
query: filter.queryType === 'smart' ? query : undefined,
|
||||||
ocr: filter.queryType === 'ocr' ? query : undefined,
|
ocr: filter.queryType === 'ocr' ? query : undefined,
|
||||||
@@ -163,6 +175,7 @@
|
|||||||
tagIds: filter.tagIds === null ? null : filter.tagIds.size > 0 ? [...filter.tagIds] : undefined,
|
tagIds: filter.tagIds === null ? null : filter.tagIds.size > 0 ? [...filter.tagIds] : undefined,
|
||||||
type,
|
type,
|
||||||
rating: filter.rating,
|
rating: filter.rating,
|
||||||
|
order,
|
||||||
};
|
};
|
||||||
|
|
||||||
onClose(payload);
|
onClose(payload);
|
||||||
@@ -218,6 +231,9 @@
|
|||||||
|
|
||||||
<!-- DISPLAY OPTIONS -->
|
<!-- DISPLAY OPTIONS -->
|
||||||
<SearchDisplaySection bind:filters={filter.display} />
|
<SearchDisplaySection bind:filters={filter.display} />
|
||||||
|
|
||||||
|
<!-- SORT ORDER -->
|
||||||
|
<SearchSortSection bind:sortOrder={filter.sortOrder} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.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 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 GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
|
||||||
import SearchBar from '$lib/components/shared-components/search-bar/search-bar.svelte';
|
import SearchBar from '$lib/components/shared-components/search-bar/search-bar.svelte';
|
||||||
import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte';
|
import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte';
|
||||||
@@ -68,6 +69,7 @@
|
|||||||
let searchQuery = $derived(page.url.searchParams.get(QueryParameter.QUERY));
|
let searchQuery = $derived(page.url.searchParams.get(QueryParameter.QUERY));
|
||||||
let smartSearchEnabled = $derived(featureFlagsManager.value.smartSearch);
|
let smartSearchEnabled = $derived(featureFlagsManager.value.smartSearch);
|
||||||
let terms = $derived(searchQuery ? JSON.parse(searchQuery) : {});
|
let terms = $derived(searchQuery ? JSON.parse(searchQuery) : {});
|
||||||
|
let isSortedByDate = $derived(!!terms.order);
|
||||||
|
|
||||||
const isAllUserOwned = $derived(
|
const isAllUserOwned = $derived(
|
||||||
$user && assetInteraction.selectedAssets.every((asset) => asset.ownerId === $user.id),
|
$user && assetInteraction.selectedAssets.every((asset) => asset.ownerId === $user.id),
|
||||||
@@ -196,6 +198,7 @@
|
|||||||
description: $t('description'),
|
description: $t('description'),
|
||||||
queryAssetId: $t('query_asset_id'),
|
queryAssetId: $t('query_asset_id'),
|
||||||
ocr: $t('ocr'),
|
ocr: $t('ocr'),
|
||||||
|
order: $t('sort_order'),
|
||||||
};
|
};
|
||||||
return keyMap[key] || key;
|
return keyMap[key] || key;
|
||||||
}
|
}
|
||||||
@@ -296,6 +299,17 @@
|
|||||||
>
|
>
|
||||||
<section id="search-content">
|
<section id="search-content">
|
||||||
{#if searchResultAssets.length > 0}
|
{#if searchResultAssets.length > 0}
|
||||||
|
{#if isSortedByDate}
|
||||||
|
<DateGroupedGalleryViewer
|
||||||
|
assets={searchResultAssets}
|
||||||
|
{assetInteraction}
|
||||||
|
onIntersected={loadNextPage}
|
||||||
|
showArchiveIcon={true}
|
||||||
|
{viewport}
|
||||||
|
onReload={onSearchQueryUpdate}
|
||||||
|
slidingWindowOffset={searchResultsElement.offsetTop}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
<GalleryViewer
|
<GalleryViewer
|
||||||
assets={searchResultAssets}
|
assets={searchResultAssets}
|
||||||
{assetInteraction}
|
{assetInteraction}
|
||||||
@@ -305,6 +319,7 @@
|
|||||||
onReload={onSearchQueryUpdate}
|
onReload={onSearchQueryUpdate}
|
||||||
slidingWindowOffset={searchResultsElement.offsetTop}
|
slidingWindowOffset={searchResultsElement.offsetTop}
|
||||||
/>
|
/>
|
||||||
|
{/if}
|
||||||
{:else if !isLoading}
|
{:else if !isLoading}
|
||||||
<div class="flex min-h-[calc(66vh-11rem)] w-full place-content-center items-center dark:text-white">
|
<div class="flex min-h-[calc(66vh-11rem)] w-full place-content-center items-center dark:text-white">
|
||||||
<div class="flex flex-col content-center items-center text-center">
|
<div class="flex flex-col content-center items-center text-center">
|
||||||
|
|||||||
Reference in New Issue
Block a user