mirror of
https://github.com/immich-app/immich.git
synced 2026-03-03 02:37:02 +00:00
Compare commits
1 Commits
d94d9600a7
...
uhthomas/f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d416e225e6 |
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Widget?>(null);
|
||||
final mediaTypeCurrentFilterWidget = useState<Widget?>(null);
|
||||
final displayOptionCurrentFilterWidget = useState<Widget?>(null);
|
||||
final sortOrderCurrentFilterWidget = useState<Widget?>(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(),
|
||||
|
||||
@@ -8,6 +8,8 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'paginated_search.provider.g.dart';
|
||||
|
||||
final searchGroupByProvider = StateProvider<GroupAssetsBy>((ref) => GroupAssetsBy.none);
|
||||
|
||||
final paginatedSearchProvider = StateNotifierProvider<PaginatedSearchNotifier, SearchResult>(
|
||||
(ref) => PaginatedSearchNotifier(ref.watch(searchServiceProvider)),
|
||||
);
|
||||
@@ -41,6 +43,7 @@ class PaginatedSearchNotifier extends StateNotifier<SearchResult> {
|
||||
@riverpod
|
||||
Future<RenderList> 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);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ part of 'paginated_search.provider.dart';
|
||||
// **************************************************************************
|
||||
|
||||
String _$paginatedSearchRenderListHash() =>
|
||||
r'22d715ff7864e5a946be38322ce7813616f899c2';
|
||||
r'bb1ea9153b2a186778420426f1fb1add6d6a9140';
|
||||
|
||||
/// See also [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.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<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
@@ -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<String>(json, r'make'),
|
||||
model: mapValueOfType<String>(json, r'model'),
|
||||
ocr: mapValueOfType<String>(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<String>().toList(growable: false)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
mediaType: MediaType;
|
||||
rating?: number;
|
||||
sortOrder: 'best-match' | 'newest' | 'oldest';
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -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 @@
|
||||
|
||||
<!-- DISPLAY OPTIONS -->
|
||||
<SearchDisplaySection bind:filters={filter.display} />
|
||||
|
||||
<!-- SORT ORDER -->
|
||||
<SearchSortSection bind:sortOrder={filter.sortOrder} />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -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 @@
|
||||
>
|
||||
<section id="search-content">
|
||||
{#if searchResultAssets.length > 0}
|
||||
<GalleryViewer
|
||||
assets={searchResultAssets}
|
||||
{assetInteraction}
|
||||
onIntersected={loadNextPage}
|
||||
showArchiveIcon={true}
|
||||
{viewport}
|
||||
onReload={onSearchQueryUpdate}
|
||||
slidingWindowOffset={searchResultsElement.offsetTop}
|
||||
/>
|
||||
{#if isSortedByDate}
|
||||
<DateGroupedGalleryViewer
|
||||
assets={searchResultAssets}
|
||||
{assetInteraction}
|
||||
onIntersected={loadNextPage}
|
||||
showArchiveIcon={true}
|
||||
{viewport}
|
||||
onReload={onSearchQueryUpdate}
|
||||
slidingWindowOffset={searchResultsElement.offsetTop}
|
||||
/>
|
||||
{:else}
|
||||
<GalleryViewer
|
||||
assets={searchResultAssets}
|
||||
{assetInteraction}
|
||||
onIntersected={loadNextPage}
|
||||
showArchiveIcon={true}
|
||||
{viewport}
|
||||
onReload={onSearchQueryUpdate}
|
||||
slidingWindowOffset={searchResultsElement.offsetTop}
|
||||
/>
|
||||
{/if}
|
||||
{:else if !isLoading}
|
||||
<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">
|
||||
|
||||
Reference in New Issue
Block a user