feat(web): support searching by EXIF rating (#16208)

* Add rating to search DTO

* Add search by EXIF rating in search query builder

* Generate OpenAPI spec

* Add rating filter on web

* Add rating filter to search docs

* Format / lint

* Hide rating filter if ratings are disabled

* chore: component order in form

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
Jason Antwi-Appah 2025-02-20 10:17:06 -06:00 committed by GitHub
parent f6ba071569
commit 34b88bb47a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 134 additions and 3 deletions

View File

@ -31,6 +31,7 @@ The filters smart search allows you to search by include:
- Not in any album - Not in any album
- Archived - Archived
- Favorited - Favorited
- Rating
<Tabs> <Tabs>
<TabItem value="Computer" label="Computer" default> <TabItem value="Computer" label="Computer" default>

View File

@ -1134,6 +1134,7 @@
"search_timezone": "Search timezone...", "search_timezone": "Search timezone...",
"search_type": "Search type", "search_type": "Search type",
"search_your_photos": "Search your photos", "search_your_photos": "Search your photos",
"search_rating": "Search by rating...",
"searching_locales": "Searching locales...", "searching_locales": "Searching locales...",
"second": "Second", "second": "Second",
"see_all_people": "See all people", "see_all_people": "See all people",

View File

@ -40,6 +40,7 @@ class MetadataSearchDto {
this.page, this.page,
this.personIds = const [], this.personIds = const [],
this.previewPath, this.previewPath,
this.rating,
this.size, this.size,
this.state, this.state,
this.tagIds = const [], this.tagIds = const [],
@ -233,6 +234,16 @@ class MetadataSearchDto {
/// ///
String? previewPath; String? previewPath;
/// Minimum value: -1
/// Maximum value: 5
///
/// 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.
///
num? rating;
/// Minimum value: 1 /// Minimum value: 1
/// Maximum value: 1000 /// Maximum value: 1000
/// ///
@ -374,6 +385,7 @@ class MetadataSearchDto {
other.page == page && other.page == page &&
_deepEquality.equals(other.personIds, personIds) && _deepEquality.equals(other.personIds, personIds) &&
other.previewPath == previewPath && other.previewPath == previewPath &&
other.rating == rating &&
other.size == size && other.size == size &&
other.state == state && other.state == state &&
_deepEquality.equals(other.tagIds, tagIds) && _deepEquality.equals(other.tagIds, tagIds) &&
@ -421,6 +433,7 @@ class MetadataSearchDto {
(page == null ? 0 : page!.hashCode) + (page == null ? 0 : page!.hashCode) +
(personIds.hashCode) + (personIds.hashCode) +
(previewPath == null ? 0 : previewPath!.hashCode) + (previewPath == null ? 0 : previewPath!.hashCode) +
(rating == null ? 0 : rating!.hashCode) +
(size == null ? 0 : size!.hashCode) + (size == null ? 0 : size!.hashCode) +
(state == null ? 0 : state!.hashCode) + (state == null ? 0 : state!.hashCode) +
(tagIds.hashCode) + (tagIds.hashCode) +
@ -439,7 +452,7 @@ class MetadataSearchDto {
(withStacked == null ? 0 : withStacked!.hashCode); (withStacked == null ? 0 : withStacked!.hashCode);
@override @override
String toString() => 'MetadataSearchDto[checksum=$checksum, city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, description=$description, deviceAssetId=$deviceAssetId, deviceId=$deviceId, encodedVideoPath=$encodedVideoPath, id=$id, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, order=$order, originalFileName=$originalFileName, originalPath=$originalPath, page=$page, personIds=$personIds, previewPath=$previewPath, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, thumbnailPath=$thumbnailPath, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]'; String toString() => 'MetadataSearchDto[checksum=$checksum, city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, description=$description, deviceAssetId=$deviceAssetId, deviceId=$deviceId, encodedVideoPath=$encodedVideoPath, id=$id, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, order=$order, originalFileName=$originalFileName, originalPath=$originalPath, page=$page, personIds=$personIds, previewPath=$previewPath, rating=$rating, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, thumbnailPath=$thumbnailPath, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@ -570,6 +583,11 @@ class MetadataSearchDto {
} else { } else {
// json[r'previewPath'] = null; // json[r'previewPath'] = null;
} }
if (this.rating != null) {
json[r'rating'] = this.rating;
} else {
// json[r'rating'] = null;
}
if (this.size != null) { if (this.size != null) {
json[r'size'] = this.size; json[r'size'] = this.size;
} else { } else {
@ -683,6 +701,7 @@ class MetadataSearchDto {
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false) ? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
: const [], : const [],
previewPath: mapValueOfType<String>(json, r'previewPath'), previewPath: mapValueOfType<String>(json, r'previewPath'),
rating: num.parse('${json[r'rating']}'),
size: num.parse('${json[r'size']}'), size: num.parse('${json[r'size']}'),
state: mapValueOfType<String>(json, r'state'), state: mapValueOfType<String>(json, r'state'),
tagIds: json[r'tagIds'] is Iterable tagIds: json[r'tagIds'] is Iterable

View File

@ -30,6 +30,7 @@ class RandomSearchDto {
this.make, this.make,
this.model, this.model,
this.personIds = const [], this.personIds = const [],
this.rating,
this.size, this.size,
this.state, this.state,
this.tagIds = const [], this.tagIds = const [],
@ -147,6 +148,16 @@ class RandomSearchDto {
List<String> personIds; List<String> personIds;
/// Minimum value: -1
/// Maximum value: 5
///
/// 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.
///
num? rating;
/// Minimum value: 1 /// Minimum value: 1
/// Maximum value: 1000 /// Maximum value: 1000
/// ///
@ -270,6 +281,7 @@ class RandomSearchDto {
other.make == make && other.make == make &&
other.model == model && other.model == model &&
_deepEquality.equals(other.personIds, personIds) && _deepEquality.equals(other.personIds, personIds) &&
other.rating == rating &&
other.size == size && other.size == size &&
other.state == state && other.state == state &&
_deepEquality.equals(other.tagIds, tagIds) && _deepEquality.equals(other.tagIds, tagIds) &&
@ -306,6 +318,7 @@ class RandomSearchDto {
(make == null ? 0 : make!.hashCode) + (make == null ? 0 : make!.hashCode) +
(model == null ? 0 : model!.hashCode) + (model == null ? 0 : model!.hashCode) +
(personIds.hashCode) + (personIds.hashCode) +
(rating == null ? 0 : rating!.hashCode) +
(size == null ? 0 : size!.hashCode) + (size == null ? 0 : size!.hashCode) +
(state == null ? 0 : state!.hashCode) + (state == null ? 0 : state!.hashCode) +
(tagIds.hashCode) + (tagIds.hashCode) +
@ -323,7 +336,7 @@ class RandomSearchDto {
(withStacked == null ? 0 : withStacked!.hashCode); (withStacked == null ? 0 : withStacked!.hashCode);
@override @override
String toString() => 'RandomSearchDto[city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, personIds=$personIds, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]'; String toString() => 'RandomSearchDto[city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, personIds=$personIds, rating=$rating, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@ -408,6 +421,11 @@ class RandomSearchDto {
// json[r'model'] = null; // json[r'model'] = null;
} }
json[r'personIds'] = this.personIds; json[r'personIds'] = this.personIds;
if (this.rating != null) {
json[r'rating'] = this.rating;
} else {
// json[r'rating'] = null;
}
if (this.size != null) { if (this.size != null) {
json[r'size'] = this.size; json[r'size'] = this.size;
} else { } else {
@ -506,6 +524,7 @@ class RandomSearchDto {
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)
: const [], : const [],
rating: num.parse('${json[r'rating']}'),
size: num.parse('${json[r'size']}'), size: num.parse('${json[r'size']}'),
state: mapValueOfType<String>(json, r'state'), state: mapValueOfType<String>(json, r'state'),
tagIds: json[r'tagIds'] is Iterable tagIds: json[r'tagIds'] is Iterable

View File

@ -32,6 +32,7 @@ class SmartSearchDto {
this.page, this.page,
this.personIds = const [], this.personIds = const [],
required this.query, required this.query,
this.rating,
this.size, this.size,
this.state, this.state,
this.tagIds = const [], this.tagIds = const [],
@ -158,6 +159,16 @@ class SmartSearchDto {
String query; String query;
/// Minimum value: -1
/// Maximum value: 5
///
/// 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.
///
num? rating;
/// Minimum value: 1 /// Minimum value: 1
/// Maximum value: 1000 /// Maximum value: 1000
/// ///
@ -267,6 +278,7 @@ class SmartSearchDto {
other.page == page && other.page == page &&
_deepEquality.equals(other.personIds, personIds) && _deepEquality.equals(other.personIds, personIds) &&
other.query == query && other.query == query &&
other.rating == rating &&
other.size == size && other.size == size &&
other.state == state && other.state == state &&
_deepEquality.equals(other.tagIds, tagIds) && _deepEquality.equals(other.tagIds, tagIds) &&
@ -303,6 +315,7 @@ class SmartSearchDto {
(page == null ? 0 : page!.hashCode) + (page == null ? 0 : page!.hashCode) +
(personIds.hashCode) + (personIds.hashCode) +
(query.hashCode) + (query.hashCode) +
(rating == null ? 0 : rating!.hashCode) +
(size == null ? 0 : size!.hashCode) + (size == null ? 0 : size!.hashCode) +
(state == null ? 0 : state!.hashCode) + (state == null ? 0 : state!.hashCode) +
(tagIds.hashCode) + (tagIds.hashCode) +
@ -318,7 +331,7 @@ class SmartSearchDto {
(withExif == null ? 0 : withExif!.hashCode); (withExif == null ? 0 : withExif!.hashCode);
@override @override
String toString() => 'SmartSearchDto[city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, page=$page, personIds=$personIds, query=$query, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif]'; String toString() => 'SmartSearchDto[city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, page=$page, personIds=$personIds, query=$query, rating=$rating, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@ -409,6 +422,11 @@ class SmartSearchDto {
} }
json[r'personIds'] = this.personIds; json[r'personIds'] = this.personIds;
json[r'query'] = this.query; json[r'query'] = this.query;
if (this.rating != null) {
json[r'rating'] = this.rating;
} else {
// json[r'rating'] = null;
}
if (this.size != null) { if (this.size != null) {
json[r'size'] = this.size; json[r'size'] = this.size;
} else { } else {
@ -499,6 +517,7 @@ class SmartSearchDto {
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false) ? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
: const [], : const [],
query: mapValueOfType<String>(json, r'query')!, query: mapValueOfType<String>(json, r'query')!,
rating: num.parse('${json[r'rating']}'),
size: num.parse('${json[r'size']}'), size: num.parse('${json[r'size']}'),
state: mapValueOfType<String>(json, r'state'), state: mapValueOfType<String>(json, r'state'),
tagIds: json[r'tagIds'] is Iterable tagIds: json[r'tagIds'] is Iterable

View File

@ -9956,6 +9956,11 @@
"previewPath": { "previewPath": {
"type": "string" "type": "string"
}, },
"rating": {
"maximum": 5,
"minimum": -1,
"type": "number"
},
"size": { "size": {
"maximum": 1000, "maximum": 1000,
"minimum": 1, "minimum": 1,
@ -10613,6 +10618,11 @@
}, },
"type": "array" "type": "array"
}, },
"rating": {
"maximum": 5,
"minimum": -1,
"type": "number"
},
"size": { "size": {
"maximum": 1000, "maximum": 1000,
"minimum": 1, "minimum": 1,
@ -11563,6 +11573,11 @@
"query": { "query": {
"type": "string" "type": "string"
}, },
"rating": {
"maximum": 5,
"minimum": -1,
"type": "number"
},
"size": { "size": {
"maximum": 1000, "maximum": 1000,
"minimum": 1, "minimum": 1,

View File

@ -811,6 +811,7 @@ export type MetadataSearchDto = {
page?: number; page?: number;
personIds?: string[]; personIds?: string[];
previewPath?: string; previewPath?: string;
rating?: number;
size?: number; size?: number;
state?: string | null; state?: string | null;
tagIds?: string[]; tagIds?: string[];
@ -878,6 +879,7 @@ export type RandomSearchDto = {
make?: string; make?: string;
model?: string | null; model?: string | null;
personIds?: string[]; personIds?: string[];
rating?: number;
size?: number; size?: number;
state?: string | null; state?: string | null;
tagIds?: string[]; tagIds?: string[];
@ -914,6 +916,7 @@ export type SmartSearchDto = {
page?: number; page?: number;
personIds?: string[]; personIds?: string[];
query: string; query: string;
rating?: number;
size?: number; size?: number;
state?: string | null; state?: string | null;
tagIds?: string[]; tagIds?: string[];

View File

@ -114,6 +114,12 @@ class BaseSearchDto {
@ValidateUUID({ each: true, optional: true }) @ValidateUUID({ each: true, optional: true })
tagIds?: string[]; tagIds?: string[];
@Optional()
@IsInt()
@Max(5)
@Min(-1)
rating?: number;
} }
export class RandomSearchDto extends BaseSearchDto { export class RandomSearchDto extends BaseSearchDto {

View File

@ -387,6 +387,11 @@ export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuild
.innerJoin('exif', 'assets.id', 'exif.assetId') .innerJoin('exif', 'assets.id', 'exif.assetId')
.where('exif.lensModel', options.lensModel === null ? 'is' : '=', options.lensModel!), .where('exif.lensModel', options.lensModel === null ? 'is' : '=', options.lensModel!),
) )
.$if(options.rating !== undefined, (qb) =>
qb
.innerJoin('exif', 'assets.id', 'exif.assetId')
.where('exif.rating', options.rating === null ? 'is' : '=', options.rating!),
)
.$if(!!options.checksum, (qb) => qb.where('assets.checksum', '=', options.checksum!)) .$if(!!options.checksum, (qb) => qb.where('assets.checksum', '=', options.checksum!))
.$if(!!options.deviceAssetId, (qb) => qb.where('assets.deviceAssetId', '=', options.deviceAssetId!)) .$if(!!options.deviceAssetId, (qb) => qb.where('assets.deviceAssetId', '=', options.deviceAssetId!))
.$if(!!options.deviceId, (qb) => qb.where('assets.deviceId', '=', options.deviceId!)) .$if(!!options.deviceId, (qb) => qb.where('assets.deviceId', '=', options.deviceId!))

View File

@ -109,6 +109,7 @@ export interface SearchExifOptions {
model?: string | null; model?: string | null;
state?: string | null; state?: string | null;
description?: string | null; description?: string | null;
rating?: number | null;
} }
export interface SearchEmbeddingOptions { export interface SearchEmbeddingOptions {

View File

@ -14,6 +14,7 @@
date: SearchDateFilter; date: SearchDateFilter;
display: SearchDisplayFilters; display: SearchDisplayFilters;
mediaType: MediaType; mediaType: MediaType;
rating?: number;
}; };
</script> </script>
@ -26,6 +27,7 @@
import SearchCameraSection, { type SearchCameraFilter } from './search-camera-section.svelte'; import SearchCameraSection, { type SearchCameraFilter } from './search-camera-section.svelte';
import SearchDateSection from './search-date-section.svelte'; import SearchDateSection from './search-date-section.svelte';
import SearchMediaSection from './search-media-section.svelte'; import SearchMediaSection from './search-media-section.svelte';
import SearchRatingsSection from './search-ratings-section.svelte';
import { parseUtcDate } from '$lib/utils/date-time'; import { parseUtcDate } from '$lib/utils/date-time';
import SearchDisplaySection from './search-display-section.svelte'; import SearchDisplaySection from './search-display-section.svelte';
import SearchTextSection from './search-text-section.svelte'; import SearchTextSection from './search-text-section.svelte';
@ -34,6 +36,7 @@
import { mdiTune } from '@mdi/js'; import { mdiTune } from '@mdi/js';
import { generateId } from '$lib/utils/generate-id'; import { generateId } from '$lib/utils/generate-id';
import { SvelteSet } from 'svelte/reactivity'; import { SvelteSet } from 'svelte/reactivity';
import { preferences } from '$lib/stores/user.store';
interface Props { interface Props {
searchQuery: MetadataSearchDto | SmartSearchDto; searchQuery: MetadataSearchDto | SmartSearchDto;
@ -81,6 +84,7 @@
: searchQuery.type === AssetTypeEnum.Video : searchQuery.type === AssetTypeEnum.Video
? MediaType.Video ? MediaType.Video
: MediaType.All, : MediaType.All,
rating: searchQuery.rating,
}); });
const resetForm = () => { const resetForm = () => {
@ -94,6 +98,7 @@
date: {}, date: {},
display: {}, display: {},
mediaType: MediaType.All, mediaType: MediaType.All,
rating: undefined,
}; };
}; };
@ -124,6 +129,7 @@
personIds: filter.personIds.size > 0 ? [...filter.personIds] : undefined, personIds: filter.personIds.size > 0 ? [...filter.personIds] : undefined,
tagIds: filter.tagIds.size > 0 ? [...filter.tagIds] : undefined, tagIds: filter.tagIds.size > 0 ? [...filter.tagIds] : undefined,
type, type,
rating: filter.rating,
}; };
onSearch(payload); onSearch(payload);
@ -161,6 +167,11 @@
<!-- DATE RANGE --> <!-- DATE RANGE -->
<SearchDateSection bind:filters={filter.date} /> <SearchDateSection bind:filters={filter.date} />
<!-- RATING -->
{#if $preferences?.ratings.enabled}
<SearchRatingsSection bind:rating={filter.rating} />
{/if}
<div class="grid md:grid-cols-2 gap-x-5 gap-y-10"> <div class="grid md:grid-cols-2 gap-x-5 gap-y-10">
<!-- MEDIA TYPE --> <!-- MEDIA TYPE -->
<SearchMediaSection bind:filteredMedia={filter.mediaType} /> <SearchMediaSection bind:filteredMedia={filter.mediaType} />

View File

@ -0,0 +1,31 @@
<script lang="ts">
import { t } from 'svelte-i18n';
import Combobox from '../combobox.svelte';
interface Props {
rating?: number;
}
let { rating = $bindable() }: Props = $props();
const options = [
{ value: '0', label: $t('rating_count', { values: { count: 0 } }) },
{ value: '1', label: $t('rating_count', { values: { count: 1 } }) },
{ value: '2', label: $t('rating_count', { values: { count: 2 } }) },
{ value: '3', label: $t('rating_count', { values: { count: 3 } }) },
{ value: '4', label: $t('rating_count', { values: { count: 4 } }) },
{ value: '5', label: $t('rating_count', { values: { count: 5 } }) },
];
</script>
<div class="grid grid-auto-fit-40 gap-5">
<label class="immich-form-label" for="start-date">
<Combobox
label={$t('rating').toUpperCase()}
placeholder={$t('search_rating')}
{options}
selectedOption={rating === undefined ? undefined : options[rating]}
onSelect={(r) => (rating = r === undefined ? undefined : Number.parseInt(r.value))}
/>
</label>
</div>