Compare commits

..

3 Commits

Author SHA1 Message Date
Mees Frensel
3644f67f59 use single query param 2026-03-05 11:02:02 +01:00
Mees Frensel
372aad07d5 fix(web): gracefully handle map errors when WebGL is disabled 2026-02-26 17:53:38 +01:00
Mees Frensel
e454c3566b refactor: star rating (#26357)
* refactor: star rating

* transform rating 0 to null in controller dto

* migrate rating 0 to null

* deprecate rating -1

* rating type annotation

* update Rating type
2026-02-26 14:54:20 +01:00
33 changed files with 438 additions and 429 deletions

View File

@@ -1050,6 +1050,7 @@
"cant_get_number_of_comments": "Can't get number of comments",
"cant_search_people": "Can't search people",
"cant_search_places": "Can't search places",
"enable_webgl_for_map": "Enable WebGL to load the map.{isAdmin, select, true { To hide this warning, disable the map feature.} other {}}",
"error_adding_assets_to_album": "Error adding assets to album",
"error_adding_users_to_album": "Error adding users to album",
"error_deleting_shared_user": "Error deleting shared user",
@@ -1245,6 +1246,7 @@
"go_back": "Go back",
"go_to_folder": "Go to folder",
"go_to_search": "Go to search",
"go_to_settings": "Go to settings",
"gps": "GPS",
"gps_missing": "No GPS",
"grant_permission": "Grant permission",
@@ -1621,7 +1623,6 @@
"not_available": "N/A",
"not_in_any_album": "Not in any album",
"not_selected": "Not selected",
"not_set": "Not set",
"notes": "Notes",
"nothing_here_yet": "Nothing here yet",
"notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.",
@@ -1811,9 +1812,8 @@
"rate_asset": "Rate Asset",
"rating": "Star rating",
"rating_clear": "Clear rating",
"rating_count": "{count, plural, one {# star} other {# stars}}",
"rating_count": "{count, plural, =0 {Unrated} one {# star} other {# stars}}",
"rating_description": "Display the EXIF rating in the info panel",
"rating_set": "Rating set to {rating, plural, one {# star} other {# stars}}",
"reaction_options": "Reaction options",
"read_changelog": "Read Changelog",
"readonly_mode_disabled": "Read-only mode disabled",

View File

@@ -76,10 +76,6 @@ enum StoreKey<T> {
// Image viewer navigation settings
tapToNavigate<bool>._(141),
// Map custom time range settings
mapCustomFrom<String>._(142),
mapCustomTo<String>._(143),
// Experimental stuff
photoManagerCustomFilter<bool>._(1000),
betaPromptShown<bool>._(1001),

View File

@@ -27,19 +27,9 @@ class DriftMapRepository extends DriftDatabaseRepository {
condition = condition & _db.remoteAssetEntity.isFavorite.equals(true);
}
final from = options.timeRange.from;
final to = options.timeRange.to;
if (from != null || to != null) {
if (from != null) {
condition = condition & _db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(from);
}
if (to != null) {
condition = condition & _db.remoteAssetEntity.createdAt.isSmallerOrEqualValue(to);
}
} else if (options.relativeDays > 0) {
final fromDate = DateTime.now().subtract(Duration(days: options.relativeDays));
condition = condition & _db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(fromDate);
if (options.relativeDays != 0) {
final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays));
condition = condition & _db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate);
}
return condition;

View File

@@ -12,7 +12,6 @@ import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/map.repository.dart';
import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:stream_transform/stream_transform.dart';
@@ -22,7 +21,6 @@ class TimelineMapOptions {
final bool includeArchived;
final bool withPartners;
final int relativeDays;
final TimeRange timeRange;
const TimelineMapOptions({
required this.bounds,
@@ -30,7 +28,6 @@ class TimelineMapOptions {
this.includeArchived = false,
this.withPartners = false,
this.relativeDays = 0,
this.timeRange = const TimeRange(),
});
}
@@ -538,19 +535,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
query.where(_db.remoteAssetEntity.isFavorite.equals(true));
}
final from = options.timeRange.from;
final to = options.timeRange.to;
if (from != null || to != null) {
// Use custom from/to filters
if (from != null) {
query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(from));
}
if (to != null) {
query.where(_db.remoteAssetEntity.createdAt.isSmallerOrEqualValue(to));
}
} else if (options.relativeDays > 0) {
// Use relative days
if (options.relativeDays != 0) {
final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays));
query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate));
}
@@ -592,19 +577,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
query.where(_db.remoteAssetEntity.isFavorite.equals(true));
}
final from = options.timeRange.from;
final to = options.timeRange.to;
if (from != null || to != null) {
// Use custom from/to filters
if (from != null) {
query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(from));
}
if (to != null) {
query.where(_db.remoteAssetEntity.createdAt.isSmallerOrEqualValue(to));
}
} else if (options.relativeDays > 0) {
// Use relative days
if (options.relativeDays != 0) {
final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays));
query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate));
}

View File

@@ -9,20 +9,6 @@ import 'package:immich_mobile/providers/map/map_state.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
class TimeRange {
final DateTime? from;
final DateTime? to;
const TimeRange({this.from, this.to});
TimeRange copyWith({DateTime? from, DateTime? to}) {
return TimeRange(from: from ?? this.from, to: to ?? this.to);
}
TimeRange clearFrom() => TimeRange(to: to);
TimeRange clearTo() => TimeRange(from: from);
}
class MapState {
final ThemeMode themeMode;
final LatLngBounds bounds;
@@ -30,7 +16,6 @@ class MapState {
final bool includeArchived;
final bool withPartners;
final int relativeDays;
final TimeRange timeRange;
const MapState({
this.themeMode = ThemeMode.system,
@@ -39,7 +24,6 @@ class MapState {
this.includeArchived = false,
this.withPartners = false,
this.relativeDays = 0,
this.timeRange = const TimeRange(),
});
@override
@@ -57,7 +41,6 @@ class MapState {
bool? includeArchived,
bool? withPartners,
int? relativeDays,
TimeRange? timeRange,
}) {
return MapState(
bounds: bounds ?? this.bounds,
@@ -66,7 +49,6 @@ class MapState {
includeArchived: includeArchived ?? this.includeArchived,
withPartners: withPartners ?? this.withPartners,
relativeDays: relativeDays ?? this.relativeDays,
timeRange: timeRange ?? this.timeRange,
);
}
@@ -75,7 +57,7 @@ class MapState {
onlyFavorites: onlyFavorites,
includeArchived: includeArchived,
withPartners: withPartners,
timeRange: timeRange,
relativeDays: relativeDays,
);
}
@@ -122,32 +104,16 @@ class MapStateNotifier extends Notifier<MapState> {
EventStream.shared.emit(const MapMarkerReloadEvent());
}
void setTimeRange(TimeRange range) {
ref
.read(appSettingsServiceProvider)
.setSetting(AppSettingsEnum.mapCustomFrom, range.from == null ? "" : range.from!.toIso8601String());
ref
.read(appSettingsServiceProvider)
.setSetting(AppSettingsEnum.mapCustomTo, range.to == null ? "" : range.to!.toIso8601String());
state = state.copyWith(timeRange: range);
EventStream.shared.emit(const MapMarkerReloadEvent());
}
@override
MapState build() {
final appSettingsService = ref.read(appSettingsServiceProvider);
final customFrom = appSettingsService.getSetting(AppSettingsEnum.mapCustomFrom);
final customTo = appSettingsService.getSetting(AppSettingsEnum.mapCustomTo);
return MapState(
themeMode: ThemeMode.values[appSettingsService.getSetting(AppSettingsEnum.mapThemeMode)],
onlyFavorites: appSettingsService.getSetting(AppSettingsEnum.mapShowFavoriteOnly),
includeArchived: appSettingsService.getSetting(AppSettingsEnum.mapIncludeArchived),
withPartners: appSettingsService.getSetting(AppSettingsEnum.mapwithPartners),
relativeDays: appSettingsService.getSetting(AppSettingsEnum.mapRelativeDate),
bounds: LatLngBounds(northeast: const LatLng(0, 0), southwest: const LatLng(0, 0)),
timeRange: TimeRange(
from: customFrom.isNotEmpty ? DateTime.parse(customFrom) : null,
to: customTo.isNotEmpty ? DateTime.parse(customTo) : null,
),
);
}
}

View File

@@ -2,36 +2,20 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
import 'package:immich_mobile/widgets/map/map_settings/map_custom_time_range.dart';
import 'package:immich_mobile/widgets/map/map_settings/map_settings_list_tile.dart';
import 'package:immich_mobile/widgets/map/map_settings/map_settings_time_dropdown.dart';
import 'package:immich_mobile/widgets/map/map_settings/map_theme_picker.dart';
class DriftMapSettingsSheet extends ConsumerStatefulWidget {
class DriftMapSettingsSheet extends HookConsumerWidget {
const DriftMapSettingsSheet({super.key});
@override
ConsumerState<DriftMapSettingsSheet> createState() => _DriftMapSettingsSheetState();
}
class _DriftMapSettingsSheetState extends ConsumerState<DriftMapSettingsSheet> {
late bool useCustomRange;
@override
void initState() {
super.initState();
final mapState = ref.read(mapStateProvider);
final timeRange = mapState.timeRange;
useCustomRange = timeRange.from != null || timeRange.to != null;
}
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final mapState = ref.watch(mapStateProvider);
return DraggableScrollableSheet(
expand: false,
initialChildSize: useCustomRange ? 0.7 : 0.6,
initialChildSize: 0.6,
builder: (ctx, scrollController) => SingleChildScrollView(
controller: scrollController,
child: Card(
@@ -63,41 +47,10 @@ class _DriftMapSettingsSheetState extends ConsumerState<DriftMapSettingsSheet> {
selected: mapState.withPartners,
onChanged: (withPartners) => ref.read(mapStateProvider.notifier).switchWithPartners(withPartners),
),
if (useCustomRange) ...[
MapTimeRange(
timeRange: mapState.timeRange,
onChanged: (range) {
ref.read(mapStateProvider.notifier).setTimeRange(range);
},
),
Align(
alignment: Alignment.centerLeft,
child: TextButton(
onPressed: () => setState(() {
useCustomRange = false;
ref.read(mapStateProvider.notifier).setRelativeTime(0);
ref.read(mapStateProvider.notifier).setTimeRange(const TimeRange());
}),
child: Text("remove_custom_date_range".t(context: context)),
),
),
] else ...[
MapTimeDropDown(
relativeTime: mapState.relativeDays,
onTimeChange: (time) => ref.read(mapStateProvider.notifier).setRelativeTime(time),
),
Align(
alignment: Alignment.centerLeft,
child: TextButton(
onPressed: () => setState(() {
useCustomRange = true;
ref.read(mapStateProvider.notifier).setRelativeTime(0);
ref.read(mapStateProvider.notifier).setTimeRange(const TimeRange());
}),
child: Text("use_custom_date_range".t(context: context)),
),
),
],
MapTimeDropDown(
relativeTime: mapState.relativeDays,
onTimeChange: (time) => ref.read(mapStateProvider.notifier).setRelativeTime(time),
),
const SizedBox(height: 20),
],
),

View File

@@ -41,8 +41,6 @@ enum AppSettingsEnum<T> {
mapIncludeArchived<bool>(StoreKey.mapIncludeArchived, null, false),
mapwithPartners<bool>(StoreKey.mapwithPartners, null, false),
mapRelativeDate<int>(StoreKey.mapRelativeDate, null, 0),
mapCustomFrom<String>(StoreKey.mapCustomFrom, null, ""),
mapCustomTo<String>(StoreKey.mapCustomTo, null, ""),
allowSelfSignedSSLCert<bool>(StoreKey.selfSignedCert, null, false),
ignoreIcloudAssets<bool>(StoreKey.ignoreIcloudAssets, null, false),
selectedAlbumSortReverse<bool>(StoreKey.selectedAlbumSortReverse, null, true),

View File

@@ -1,62 +0,0 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
import 'package:intl/intl.dart';
class MapTimeRange extends StatelessWidget {
const MapTimeRange({super.key, required this.timeRange, required this.onChanged});
final TimeRange timeRange;
final Function(TimeRange) onChanged;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
title: Text("date_after".t(context: context)),
subtitle: Text(
timeRange.from != null
? DateFormat.yMMMd().add_jm().format(timeRange.from!)
: "not_set".t(context: context),
),
trailing: timeRange.from != null
? IconButton(icon: const Icon(Icons.close), onPressed: () => onChanged(timeRange.clearFrom()))
: null,
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: timeRange.from ?? DateTime.now(),
firstDate: DateTime(1970),
lastDate: DateTime.now(),
);
if (picked != null) {
onChanged(timeRange.copyWith(from: picked));
}
},
),
ListTile(
title: Text("date_before".t(context: context)),
subtitle: Text(
timeRange.to != null ? DateFormat.yMMMd().add_jm().format(timeRange.to!) : "not_set".t(context: context),
),
trailing: timeRange.to != null
? IconButton(icon: const Icon(Icons.close), onPressed: () => onChanged(timeRange.clearTo()))
: null,
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: timeRange.to ?? DateTime.now(),
firstDate: DateTime(1970),
lastDate: DateTime.now(),
);
if (picked != null) {
onChanged(timeRange.copyWith(to: picked));
}
},
),
],
);
}
}

View File

@@ -410,7 +410,7 @@ class SearchApi {
/// Filter by person IDs
///
/// * [num] rating:
/// Filter by rating
/// Filter by rating [1-5], or null for unrated
///
/// * [num] size:
/// Number of results to return
@@ -633,7 +633,7 @@ class SearchApi {
/// Filter by person IDs
///
/// * [num] rating:
/// Filter by rating
/// Filter by rating [1-5], or null for unrated
///
/// * [num] size:
/// Number of results to return

View File

@@ -86,16 +86,10 @@ class AssetBulkUpdateDto {
///
num? longitude;
/// Rating
/// Rating in range [1-5], or null for unrated
///
/// 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;
/// Time zone (IANA timezone)
@@ -223,7 +217,9 @@ class AssetBulkUpdateDto {
isFavorite: mapValueOfType<bool>(json, r'isFavorite'),
latitude: num.parse('${json[r'latitude']}'),
longitude: num.parse('${json[r'longitude']}'),
rating: num.parse('${json[r'rating']}'),
rating: json[r'rating'] == null
? null
: num.parse('${json[r'rating']}'),
timeZone: mapValueOfType<String>(json, r'timeZone'),
visibility: AssetVisibility.fromJson(json[r'visibility']),
);

View File

@@ -256,16 +256,10 @@ class MetadataSearchDto {
///
String? previewPath;
/// Filter by rating
/// Filter by rating [1-5], or null for unrated
///
/// 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;
/// Number of results to return
@@ -754,7 +748,9 @@ class MetadataSearchDto {
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
: const [],
previewPath: mapValueOfType<String>(json, r'previewPath'),
rating: num.parse('${json[r'rating']}'),
rating: json[r'rating'] == null
? null
: num.parse('${json[r'rating']}'),
size: num.parse('${json[r'size']}'),
state: mapValueOfType<String>(json, r'state'),
tagIds: json[r'tagIds'] is Iterable

View File

@@ -159,16 +159,10 @@ class RandomSearchDto {
/// Filter by person IDs
List<String> personIds;
/// Filter by rating
/// Filter by rating [1-5], or null for unrated
///
/// 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;
/// Number of results to return
@@ -565,7 +559,9 @@ class RandomSearchDto {
personIds: json[r'personIds'] is Iterable
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
: const [],
rating: num.parse('${json[r'rating']}'),
rating: json[r'rating'] == null
? null
: num.parse('${json[r'rating']}'),
size: num.parse('${json[r'size']}'),
state: mapValueOfType<String>(json, r'state'),
tagIds: json[r'tagIds'] is Iterable

View File

@@ -199,16 +199,10 @@ class SmartSearchDto {
///
String? queryAssetId;
/// Filter by rating
/// Filter by rating [1-5], or null for unrated
///
/// 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;
/// Number of results to return
@@ -605,7 +599,9 @@ class SmartSearchDto {
: const [],
query: mapValueOfType<String>(json, r'query'),
queryAssetId: mapValueOfType<String>(json, r'queryAssetId'),
rating: num.parse('${json[r'rating']}'),
rating: json[r'rating'] == null
? null
: num.parse('${json[r'rating']}'),
size: num.parse('${json[r'size']}'),
state: mapValueOfType<String>(json, r'state'),
tagIds: json[r'tagIds'] is Iterable

View File

@@ -164,16 +164,10 @@ class StatisticsSearchDto {
/// Filter by person IDs
List<String> personIds;
/// Filter by rating
/// Filter by rating [1-5], or null for unrated
///
/// 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;
/// Filter by state/province name
@@ -495,7 +489,9 @@ class StatisticsSearchDto {
personIds: json[r'personIds'] is Iterable
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
: const [],
rating: num.parse('${json[r'rating']}'),
rating: json[r'rating'] == null
? null
: num.parse('${json[r'rating']}'),
state: mapValueOfType<String>(json, r'state'),
tagIds: json[r'tagIds'] is Iterable
? (json[r'tagIds'] as Iterable).cast<String>().toList(growable: false)

View File

@@ -71,16 +71,10 @@ class UpdateAssetDto {
///
num? longitude;
/// Rating
/// Rating in range [1-5], or null for unrated
///
/// 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;
/// Asset visibility
@@ -178,7 +172,9 @@ class UpdateAssetDto {
latitude: num.parse('${json[r'latitude']}'),
livePhotoVideoId: mapValueOfType<String>(json, r'livePhotoVideoId'),
longitude: num.parse('${json[r'longitude']}'),
rating: num.parse('${json[r'rating']}'),
rating: json[r'rating'] == null
? null
: num.parse('${json[r'rating']}'),
visibility: AssetVisibility.fromJson(json[r'visibility']),
);
}

View File

@@ -9407,10 +9407,27 @@
"name": "rating",
"required": false,
"in": "query",
"description": "Filter by rating",
"description": "Filter by rating [1-5], or null for unrated",
"x-immich-history": [
{
"version": "v1",
"state": "Added"
},
{
"version": "v2",
"state": "Stable"
},
{
"version": "v2.6.0",
"state": "Updated",
"description": "Using -1 as a rating is deprecated and will be removed in the next major version."
}
],
"x-immich-state": "Stable",
"schema": {
"minimum": -1,
"maximum": 5,
"nullable": true,
"type": "number"
}
},
@@ -15872,10 +15889,27 @@
"type": "number"
},
"rating": {
"description": "Rating",
"description": "Rating in range [1-5], or null for unrated",
"maximum": 5,
"minimum": -1,
"type": "number"
"nullable": true,
"type": "number",
"x-immich-history": [
{
"version": "v1",
"state": "Added"
},
{
"version": "v2",
"state": "Stable"
},
{
"version": "v2.6.0",
"state": "Updated",
"description": "Using -1 as a rating is deprecated and will be removed in the next major version."
}
],
"x-immich-state": "Stable"
},
"timeZone": {
"description": "Time zone (IANA timezone)",
@@ -18988,10 +19022,27 @@
"type": "string"
},
"rating": {
"description": "Filter by rating",
"description": "Filter by rating [1-5], or null for unrated",
"maximum": 5,
"minimum": -1,
"type": "number"
"nullable": true,
"type": "number",
"x-immich-history": [
{
"version": "v1",
"state": "Added"
},
{
"version": "v2",
"state": "Stable"
},
{
"version": "v2.6.0",
"state": "Updated",
"description": "Using -1 as a rating is deprecated and will be removed in the next major version."
}
],
"x-immich-state": "Stable"
},
"size": {
"description": "Number of results to return",
@@ -20714,10 +20765,27 @@
"type": "array"
},
"rating": {
"description": "Filter by rating",
"description": "Filter by rating [1-5], or null for unrated",
"maximum": 5,
"minimum": -1,
"type": "number"
"nullable": true,
"type": "number",
"x-immich-history": [
{
"version": "v1",
"state": "Added"
},
{
"version": "v2",
"state": "Stable"
},
{
"version": "v2.6.0",
"state": "Updated",
"description": "Using -1 as a rating is deprecated and will be removed in the next major version."
}
],
"x-immich-state": "Stable"
},
"size": {
"description": "Number of results to return",
@@ -22088,10 +22156,27 @@
"type": "string"
},
"rating": {
"description": "Filter by rating",
"description": "Filter by rating [1-5], or null for unrated",
"maximum": 5,
"minimum": -1,
"type": "number"
"nullable": true,
"type": "number",
"x-immich-history": [
{
"version": "v1",
"state": "Added"
},
{
"version": "v2",
"state": "Stable"
},
{
"version": "v2.6.0",
"state": "Updated",
"description": "Using -1 as a rating is deprecated and will be removed in the next major version."
}
],
"x-immich-state": "Stable"
},
"size": {
"description": "Number of results to return",
@@ -22322,10 +22407,27 @@
"type": "array"
},
"rating": {
"description": "Filter by rating",
"description": "Filter by rating [1-5], or null for unrated",
"maximum": 5,
"minimum": -1,
"type": "number"
"nullable": true,
"type": "number",
"x-immich-history": [
{
"version": "v1",
"state": "Added"
},
{
"version": "v2",
"state": "Stable"
},
{
"version": "v2.6.0",
"state": "Updated",
"description": "Using -1 as a rating is deprecated and will be removed in the next major version."
}
],
"x-immich-state": "Stable"
},
"state": {
"description": "Filter by state/province name",
@@ -25209,10 +25311,27 @@
"type": "number"
},
"rating": {
"description": "Rating",
"description": "Rating in range [1-5], or null for unrated",
"maximum": 5,
"minimum": -1,
"type": "number"
"nullable": true,
"type": "number",
"x-immich-history": [
{
"version": "v1",
"state": "Added"
},
{
"version": "v2",
"state": "Stable"
},
{
"version": "v2.6.0",
"state": "Updated",
"description": "Using -1 as a rating is deprecated and will be removed in the next major version."
}
],
"x-immich-state": "Stable"
},
"visibility": {
"allOf": [

View File

@@ -834,8 +834,8 @@ export type AssetBulkUpdateDto = {
latitude?: number;
/** Longitude coordinate */
longitude?: number;
/** Rating */
rating?: number;
/** Rating in range [1-5], or null for unrated */
rating?: number | null;
/** Time zone (IANA timezone) */
timeZone?: string;
/** Asset visibility */
@@ -944,8 +944,8 @@ export type UpdateAssetDto = {
livePhotoVideoId?: string | null;
/** Longitude coordinate */
longitude?: number;
/** Rating */
rating?: number;
/** Rating in range [1-5], or null for unrated */
rating?: number | null;
/** Asset visibility */
visibility?: AssetVisibility;
};
@@ -1711,8 +1711,8 @@ export type MetadataSearchDto = {
personIds?: string[];
/** Filter by preview file path */
previewPath?: string;
/** Filter by rating */
rating?: number;
/** Filter by rating [1-5], or null for unrated */
rating?: number | null;
/** Number of results to return */
size?: number;
/** Filter by state/province name */
@@ -1827,8 +1827,8 @@ export type RandomSearchDto = {
ocr?: string;
/** Filter by person IDs */
personIds?: string[];
/** Filter by rating */
rating?: number;
/** Filter by rating [1-5], or null for unrated */
rating?: number | null;
/** Number of results to return */
size?: number;
/** Filter by state/province name */
@@ -1903,8 +1903,8 @@ export type SmartSearchDto = {
query?: string;
/** Asset ID to use as search reference */
queryAssetId?: string;
/** Filter by rating */
rating?: number;
/** Filter by rating [1-5], or null for unrated */
rating?: number | null;
/** Number of results to return */
size?: number;
/** Filter by state/province name */
@@ -1969,8 +1969,8 @@ export type StatisticsSearchDto = {
ocr?: string;
/** Filter by person IDs */
personIds?: string[];
/** Filter by rating */
rating?: number;
/** Filter by rating [1-5], or null for unrated */
rating?: number | null;
/** Filter by state/province name */
state?: string | null;
/** Filter by tag IDs */
@@ -5454,7 +5454,7 @@ export function searchLargeAssets({ albumIds, city, country, createdAfter, creat
model?: string | null;
ocr?: string;
personIds?: string[];
rating?: number;
rating?: number | null;
size?: number;
state?: string | null;
tagIds?: string[] | null;

View File

@@ -207,12 +207,28 @@ describe(AssetController.name, () => {
});
it('should reject invalid rating', async () => {
for (const test of [{ rating: 7 }, { rating: 3.5 }, { rating: null }]) {
for (const test of [{ rating: 7 }, { rating: 3.5 }, { rating: -2 }]) {
const { status, body } = await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}`).send(test);
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest());
}
});
it('should convert rating 0 to null', async () => {
const assetId = factory.uuid();
const { status } = await request(ctx.getHttpServer()).put(`/assets/${assetId}`).send({ rating: 0 });
expect(service.update).toHaveBeenCalledWith(undefined, assetId, { rating: null });
expect(status).toBe(200);
});
it('should leave correct ratings as-is', async () => {
const assetId = factory.uuid();
for (const test of [{ rating: -1 }, { rating: 1 }, { rating: 5 }]) {
const { status } = await request(ctx.getHttpServer()).put(`/assets/${assetId}`).send(test);
expect(service.update).toHaveBeenCalledWith(undefined, assetId, test);
expect(status).toBe(200);
}
});
});
describe('GET /assets/statistics', () => {

View File

@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { Transform, Type } from 'class-transformer';
import {
IsArray,
IsDateString,
@@ -16,6 +16,7 @@ import {
ValidateIf,
ValidateNested,
} from 'class-validator';
import { HistoryBuilder, Property } from 'src/decorators';
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AssetType, AssetVisibility } from 'src/enum';
import { AssetStats } from 'src/repositories/asset.repository';
@@ -56,12 +57,19 @@ export class UpdateAssetBase {
@IsNotEmpty()
longitude?: number;
@ApiProperty({ description: 'Rating' })
@Optional()
@Property({
description: 'Rating in range [1-5], or null for unrated',
history: new HistoryBuilder()
.added('v1')
.stable('v2')
.updated('v2.6.0', 'Using -1 as a rating is deprecated and will be removed in the next major version.'),
})
@Optional({ nullable: true })
@IsInt()
@Max(5)
@Min(-1)
rating?: number;
@Transform(({ value }) => (value === 0 ? null : value))
rating?: number | null;
@ApiProperty({ description: 'Asset description' })
@Optional()

View File

@@ -2,7 +2,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator';
import { Place } from 'src/database';
import { HistoryBuilder } from 'src/decorators';
import { HistoryBuilder, Property } from 'src/decorators';
import { AlbumResponseDto } from 'src/dtos/album.dto';
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { AssetOrder, AssetType, AssetVisibility } from 'src/enum';
@@ -103,12 +103,21 @@ class BaseSearchDto {
@ValidateUUID({ each: true, optional: true, description: 'Filter by album IDs' })
albumIds?: string[];
@ApiPropertyOptional({ type: 'number', description: 'Filter by rating', minimum: -1, maximum: 5 })
@Optional()
@Property({
type: 'number',
description: 'Filter by rating [1-5], or null for unrated',
minimum: -1,
maximum: 5,
history: new HistoryBuilder()
.added('v1')
.stable('v2')
.updated('v2.6.0', 'Using -1 as a rating is deprecated and will be removed in the next major version.'),
})
@Optional({ nullable: true })
@IsInt()
@Max(5)
@Min(-1)
rating?: number;
rating?: number | null;
@ApiPropertyOptional({ description: 'Filter by OCR text content' })
@IsString()

View File

@@ -107,7 +107,7 @@ export class MediaRepository {
ExposureTime: tags.exposureTime,
ProfileDescription: tags.profileDescription,
ColorSpace: tags.colorspace,
Rating: tags.rating,
Rating: tags.rating === null ? 0 : tags.rating,
// specially convert Orientation to numeric Orientation# for exiftool
'Orientation#': tags.orientation ? Number(tags.orientation) : undefined,
};

View File

@@ -0,0 +1,9 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`UPDATE "asset_exif" SET "rating" = NULL WHERE "rating" = 0;`.execute(db);
}
export async function down(): Promise<void> {
// not supported
}

View File

@@ -516,7 +516,7 @@ export class AssetService extends BaseService {
dateTimeOriginal?: string;
latitude?: number;
longitude?: number;
rating?: number;
rating?: number | null;
}) {
const { id, description, dateTimeOriginal, latitude, longitude, rating } = dto;
const writes = _.omitBy(

View File

@@ -1423,6 +1423,20 @@ describe(MetadataService.name, () => {
);
});
it('should handle 0 as unrated -> null', async () => {
const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mockReadTags({ Rating: 0 });
await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
expect.objectContaining({
rating: null,
}),
{ lockedPropertiesBehavior: 'skip' },
);
});
it('should handle valid negative rating value', async () => {
const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
@@ -1780,6 +1794,28 @@ describe(MetadataService.name, () => {
'timeZone',
]);
});
it('should write rating', async () => {
const asset = factory.jobAssets.sidecarWrite();
asset.exifInfo.rating = 4;
mocks.assetJob.getLockedPropertiesForMetadataExtraction.mockResolvedValue(['rating']);
mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(asset);
await expect(sut.handleSidecarWrite({ id: asset.id })).resolves.toBe(JobStatus.Success);
expect(mocks.metadata.writeTags).toHaveBeenCalledWith(asset.files[0].path, { Rating: 4 });
expect(mocks.asset.unlockProperties).toHaveBeenCalledWith(asset.id, ['rating']);
});
it('should write null rating as 0', async () => {
const asset = factory.jobAssets.sidecarWrite();
asset.exifInfo.rating = null;
mocks.assetJob.getLockedPropertiesForMetadataExtraction.mockResolvedValue(['rating']);
mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(asset);
await expect(sut.handleSidecarWrite({ id: asset.id })).resolves.toBe(JobStatus.Success);
expect(mocks.metadata.writeTags).toHaveBeenCalledWith(asset.files[0].path, { Rating: 0 });
expect(mocks.asset.unlockProperties).toHaveBeenCalledWith(asset.id, ['rating']);
});
});
describe('firstDateTime', () => {

View File

@@ -301,7 +301,7 @@ export class MetadataService extends BaseService {
// comments
description: String(exifTags.ImageDescription || exifTags.Description || '').trim(),
profileDescription: exifTags.ProfileDescription || null,
rating: validateRange(exifTags.Rating, -1, 5),
rating: exifTags.Rating === 0 ? null : validateRange(exifTags.Rating, -1, 5),
// grouping
livePhotoCID: (exifTags.ContentIdentifier || exifTags.MediaGroupUUID) ?? null,
@@ -451,7 +451,7 @@ export class MetadataService extends BaseService {
dateTimeOriginal: asset.exifInfo.dateTimeOriginal as string | null,
latitude: asset.exifInfo.latitude,
longitude: asset.exifInfo.longitude,
rating: asset.exifInfo.rating,
rating: asset.exifInfo.rating ?? 0,
tags: asset.exifInfo.tags,
timeZone: asset.exifInfo.timeZone,
},

View File

@@ -17,10 +17,9 @@
const rateAsset = async (rating: number | null) => {
try {
const updateAssetDto = rating === null ? {} : { rating };
await updateAsset({
id: asset.id,
updateAssetDto,
updateAssetDto: { rating },
});
asset = {

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import StarRating from '$lib/elements/StarRating.svelte';
import StarRating, { type Rating } from '$lib/elements/StarRating.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { preferences } from '$lib/stores/user.store';
import { handlePromiseError } from '$lib/utils';
@@ -14,9 +14,9 @@
let { asset, isOwner }: Props = $props();
let rating = $derived(asset.exifInfo?.rating || 0);
let rating = $derived(asset.exifInfo?.rating || null) as Rating;
const handleChangeRating = async (rating: number) => {
const handleChangeRating = async (rating: number | null) => {
try {
await updateAsset({ id: asset.id, updateAssetDto: { rating } });
} catch (error) {
@@ -26,7 +26,7 @@
</script>
{#if !authManager.isSharedLink && $preferences?.ratings.enabled}
<section class="px-4 pt-2">
<section class="px-4 pt-4">
<StarRating {rating} readOnly={!isOwner} onRating={(rating) => handlePromiseError(handleChangeRating(rating))} />
</section>
{/if}

View File

@@ -11,15 +11,17 @@
<script lang="ts">
import { afterNavigate } from '$app/navigation';
import OnEvents from '$lib/components/OnEvents.svelte';
import { Theme } from '$lib/constants';
import { OpenQueryParam, Theme } from '$lib/constants';
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
import { themeManager } from '$lib/managers/theme-manager.svelte';
import MapSettingsModal from '$lib/modals/MapSettingsModal.svelte';
import { Route } from '$lib/route';
import { mapSettings } from '$lib/stores/preferences.store';
import { user } from '$lib/stores/user.store';
import { getAssetMediaUrl, handlePromiseError } from '$lib/utils';
import { getMapMarkers, type MapMarkerResponseDto } from '@immich/sdk';
import { Icon, modalManager } from '@immich/ui';
import { mdiCog, mdiMap, mdiMapMarker } from '@mdi/js';
import { Icon, Link, modalManager, Text } from '@immich/ui';
import { mdiCog, mdiInformationOutline, mdiMap, mdiMapMarker } from '@mdi/js';
import type { Feature, GeoJsonProperties, Geometry, Point } from 'geojson';
import { isEqual, omit } from 'lodash-es';
import { DateTime, Duration } from 'luxon';
@@ -301,109 +303,133 @@
<OnEvents {onAssetsDelete} />
<!-- We handle style loading ourselves so we set style blank here -->
<MapLibre
{hash}
style=""
class="h-full {rounded ? 'rounded-2xl' : 'rounded-none'}"
{zoom}
{center}
bounds={initialBounds}
fitBoundsOptions={{ padding: 50, maxZoom: 15 }}
attributionControl={false}
diffStyleUpdates={true}
onload={(event: Map) => {
event.setMaxZoom(18);
event.on('click', handleMapClick);
if (!simplified) {
event.addControl(new GlobeControl(), 'top-left');
}
}}
bind:map
>
{#snippet children({ map }: { map: Map })}
{#if showSimpleControls}
<NavigationControl position="top-left" showCompass={!simplified} />
<!-- Use svelte:boundary instead of MapLibre onerror until https://github.com/dimfeld/svelte-maplibre/issues/279 is fixed -->
<svelte:boundary>
<!-- We handle style loading ourselves so we set style blank here -->
<MapLibre
{hash}
style=""
class="h-full {rounded ? 'rounded-2xl' : 'rounded-none'}"
{zoom}
{center}
bounds={initialBounds}
fitBoundsOptions={{ padding: 50, maxZoom: 15 }}
attributionControl={false}
diffStyleUpdates={true}
onload={(event: Map) => {
event.setMaxZoom(18);
event.on('click', handleMapClick);
if (!simplified) {
event.addControl(new GlobeControl(), 'top-left');
}
}}
bind:map
>
{#snippet children({ map }: { map: Map })}
{#if showSimpleControls}
<NavigationControl position="top-left" showCompass={!simplified} />
{#if !simplified}
<GeolocateControl position="top-left" />
<FullscreenControl position="top-left" />
<ScaleControl />
<AttributionControl compact={false} />
{#if !simplified}
<GeolocateControl position="top-left" />
<FullscreenControl position="top-left" />
<ScaleControl />
<AttributionControl compact={false} />
{/if}
{/if}
{/if}
{#if showSettings}
<Control>
<ControlGroup>
<ControlButton onclick={handleSettingsClick}>
<Icon icon={mdiCog} size="100%" class="text-black/80" />
</ControlButton>
</ControlGroup>
</Control>
{/if}
{#if showSettings}
<Control>
<ControlGroup>
<ControlButton onclick={handleSettingsClick}>
<Icon icon={mdiCog} size="100%" class="text-black/80" />
</ControlButton>
</ControlGroup>
</Control>
{/if}
{#if onOpenInMapView && showSimpleControls}
<Control position="top-right">
<ControlGroup>
<ControlButton onclick={() => onOpenInMapView()}>
<Icon title={$t('open_in_map_view')} icon={mdiMap} size="100%" class="text-black/80" />
</ControlButton>
</ControlGroup>
</Control>
{/if}
{#if onOpenInMapView && showSimpleControls}
<Control position="top-right">
<ControlGroup>
<ControlButton onclick={() => onOpenInMapView()}>
<Icon title={$t('open_in_map_view')} icon={mdiMap} size="100%" class="text-black/80" />
</ControlButton>
</ControlGroup>
</Control>
{/if}
<GeoJSON
data={{
type: 'FeatureCollection',
features: mapMarkers?.map((marker) => asFeature(marker)) ?? [],
}}
id="geojson"
cluster={{ radius: 35, maxZoom: 18 }}
>
<MarkerLayer
applyToClusters
asButton
onclick={(event) => handlePromiseError(handleClusterClick(event.feature.properties?.cluster_id, map))}
>
{#snippet children({ feature })}
<div
class="rounded-full w-10 h-10 bg-immich-primary text-white flex justify-center items-center font-mono font-bold shadow-lg hover:bg-immich-dark-primary transition-all duration-200 hover:text-immich-dark-bg opacity-90"
>
{feature.properties?.point_count?.toLocaleString()}
</div>
{/snippet}
</MarkerLayer>
<MarkerLayer
applyToClusters={false}
asButton
onclick={(event) => {
if (!popup) {
handleAssetClick(event.feature.properties?.id, map);
}
<GeoJSON
data={{
type: 'FeatureCollection',
features: mapMarkers?.map((marker) => asFeature(marker)) ?? [],
}}
id="geojson"
cluster={{ radius: 35, maxZoom: 18 }}
>
{#snippet children({ feature }: { feature: Feature })}
{#if useLocationPin}
<Icon icon={mdiMapMarker} size="50px" class="text-primary -translate-y-[50%]" />
{:else}
<img
src={getAssetMediaUrl({ id: feature.properties?.id })}
class="rounded-full w-15 h-15 border-2 border-immich-primary shadow-lg hover:border-immich-dark-primary transition-all duration-200 hover:scale-150 object-cover bg-immich-primary"
alt={feature.properties?.city && feature.properties.country
? $t('map_marker_for_images', {
values: { city: feature.properties.city, country: feature.properties.country },
})
: $t('map_marker_with_image')}
/>
{/if}
{#if popup}
<Popup offset={[0, -30]} openOn="click" closeOnClickOutside>
{@render popup?.({ marker: asMarker(feature) })}
</Popup>
{/if}
{/snippet}
</MarkerLayer>
</GeoJSON>
<MarkerLayer
applyToClusters
asButton
onclick={(event) => handlePromiseError(handleClusterClick(event.feature.properties?.cluster_id, map))}
>
{#snippet children({ feature })}
<div
class="rounded-full w-10 h-10 bg-immich-primary text-white flex justify-center items-center font-mono font-bold shadow-lg hover:bg-immich-dark-primary transition-all duration-200 hover:text-immich-dark-bg opacity-90"
>
{feature.properties?.point_count?.toLocaleString()}
</div>
{/snippet}
</MarkerLayer>
<MarkerLayer
applyToClusters={false}
asButton
onclick={(event) => {
if (!popup) {
handleAssetClick(event.feature.properties?.id, map);
}
}}
>
{#snippet children({ feature }: { feature: Feature })}
{#if useLocationPin}
<Icon icon={mdiMapMarker} size="50px" class="text-primary -translate-y-[50%]" />
{:else}
<img
src={getAssetMediaUrl({ id: feature.properties?.id })}
class="rounded-full w-15 h-15 border-2 border-immich-primary shadow-lg hover:border-immich-dark-primary transition-all duration-200 hover:scale-150 object-cover bg-immich-primary"
alt={feature.properties?.city && feature.properties.country
? $t('map_marker_for_images', {
values: { city: feature.properties.city, country: feature.properties.country },
})
: $t('map_marker_with_image')}
/>
{/if}
{#if popup}
<Popup offset={[0, -30]} openOn="click" closeOnClickOutside>
{@render popup?.({ marker: asMarker(feature) })}
</Popup>
{/if}
{/snippet}
</MarkerLayer>
</GeoJSON>
{/snippet}
</MapLibre>
{#snippet failed(_)}
<div
class={[
'flex place-content-center place-items-center text-warning',
simplified ? 'gap-4 px-6 text-sm' : 'h-full mx-auto gap-6',
]}
>
<div>
<Icon icon={mdiInformationOutline} size={simplified ? '18' : '24'} />
</div>
<div>
<Text>
{$t('errors.enable_webgl_for_map', { values: { isAdmin: $user.isAdmin } })}
</Text>
{#if $user.isAdmin}
<Link href={Route.systemSettings({ isOpen: OpenQueryParam.LOCATION })}>{$t('go_to_settings')}</Link>
{/if}
</div>
</div>
{/snippet}
</MapLibre>
</svelte:boundary>

View File

@@ -4,13 +4,13 @@
import Combobox from '../combobox.svelte';
interface Props {
rating?: number;
rating?: number | null;
}
let { rating = $bindable() }: Props = $props();
const options = [
{ value: '0', label: $t('rating_count', { values: { count: 0 } }) },
{ value: 'null', 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 } }) },
@@ -26,7 +26,7 @@
placeholder={$t('search_rating')}
hideLabel
{options}
selectedOption={rating === undefined ? undefined : options[rating]}
selectedOption={rating === undefined ? undefined : options[rating === null ? 0 : rating]}
onSelect={(r) => (rating = r === undefined ? undefined : Number.parseInt(r.value))}
/>
</div>

View File

@@ -65,6 +65,7 @@ export enum OpenQueryParam {
OAUTH = 'oauth',
JOB = 'job',
STORAGE_TEMPLATE = 'storage-template',
LOCATION = 'location',
NOTIFICATIONS = 'notifications',
PURCHASE_SETTINGS = 'user-purchase-settings',
}

View File

@@ -3,27 +3,28 @@
import { shortcuts } from '$lib/actions/shortcut';
import { generateId } from '$lib/utils/generate-id';
import { Icon } from '@immich/ui';
import { mdiStar, mdiStarOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
export type Rating = 1 | 2 | 3 | 4 | 5 | null;
interface Props {
count?: number;
rating: number;
rating: Rating;
readOnly?: boolean;
onRating: (rating: number) => void | undefined;
onRating: (rating: Rating) => void | undefined;
}
let { count = 5, rating, readOnly = false, onRating }: Props = $props();
let ratingSelection = $derived(rating);
let hoverRating = $state(0);
let focusRating = $state(0);
let hoverRating: Rating = $state(null);
let focusRating: Rating = $state(null);
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const starIcon =
'M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.007 5.404.433c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.433 2.082-5.006z';
const id = generateId();
const handleSelect = (newRating: number) => {
const handleSelect = (newRating: Rating) => {
if (readOnly) {
return;
}
@@ -35,7 +36,7 @@
onRating(newRating);
};
const setHoverRating = (value: number) => {
const setHoverRating = (value: Rating) => {
if (readOnly) {
return;
}
@@ -43,11 +44,11 @@
};
const reset = () => {
setHoverRating(0);
focusRating = 0;
setHoverRating(null);
focusRating = null;
};
const handleSelectDebounced = (value: number) => {
const handleSelectDebounced = (value: Rating) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
handleSelect(value);
@@ -58,7 +59,7 @@
<!-- svelte-ignore a11y_mouse_events_have_key_events -->
<fieldset
class="text-primary w-fit cursor-default"
onmouseleave={() => setHoverRating(0)}
onmouseleave={() => setHoverRating(null)}
use:focusOutside={{ onFocusOut: reset }}
use:shortcuts={[
{ shortcut: { key: 'ArrowLeft' }, preventDefault: false, onShortcut: (event) => event.stopPropagation() },
@@ -69,7 +70,7 @@
<div class="flex flex-row" data-testid="star-container">
{#each { length: count } as _, index (index)}
{@const value = index + 1}
{@const filled = hoverRating >= value || (hoverRating === 0 && ratingSelection >= value)}
{@const filled = hoverRating === null ? (ratingSelection || 0) >= value : hoverRating >= value}
{@const starId = `${id}-${value}`}
<!-- svelte-ignore a11y_mouse_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
@@ -77,19 +78,12 @@
for={starId}
class:cursor-pointer={!readOnly}
class:ring-2={focusRating === value}
onmouseover={() => setHoverRating(value)}
onmouseover={() => setHoverRating(value as Rating)}
tabindex={-1}
data-testid="star"
>
<span class="sr-only">{$t('rating_count', { values: { count: value } })}</span>
<Icon
icon={starIcon}
size="1.5em"
strokeWidth={1}
color={filled ? 'currentcolor' : 'transparent'}
strokeColor={filled ? 'currentcolor' : '#c1cce8'}
aria-hidden
/>
<Icon icon={filled ? mdiStar : mdiStarOutline} size="1.5em" aria-hidden />
</label>
<input
type="radio"
@@ -99,19 +93,19 @@
bind:group={ratingSelection}
disabled={readOnly}
onfocus={() => {
focusRating = value;
focusRating = value as Rating;
}}
onchange={() => handleSelectDebounced(value)}
onchange={() => handleSelectDebounced(value as Rating)}
class="sr-only"
/>
{/each}
</div>
</fieldset>
{#if ratingSelection > 0 && !readOnly}
{#if ratingSelection !== null && !readOnly}
<button
type="button"
onclick={() => {
ratingSelection = 0;
ratingSelection = null;
handleSelect(ratingSelection);
}}
class="cursor-pointer text-xs text-primary"

View File

@@ -15,7 +15,7 @@
date: SearchDateFilter;
display: SearchDisplayFilters;
mediaType: MediaType;
rating?: number;
rating?: number | null;
};
</script>

View File

@@ -276,6 +276,8 @@
{#await getTagNames(value) then tagNames}
{tagNames}
{/await}
{:else if searchKey === 'rating'}
{$t('rating_count', { values: { count: value ?? 0 } })}
{:else if value === null || value === ''}
{$t('unknown')}
{:else}