mirror of https://github.com/immich-app/immich.git
feat(web): add Exif-Rating (#11580)
* Add Exif-Rating * Integrate star rating as own component * Add e2e tests for rating and validation * Rename component and async handleChangeRating * Display rating can be enabled in app settings * Correct i18n reference Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> * Star rating: change from slider to buttons * Star rating for clarity * Design updates. * Renaming and code optimization * chore: clean up * chore: e2e formatting * light mode border and default value --------- Co-authored-by: Christoph Suter <christoph@suter-burri.ch> Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Co-authored-by: Mert <101130780+mertalev@users.noreply.github.com> Co-authored-by: Jason Rasmussen <jrasm91@gmail.com> Co-authored-by: Alex Tran <alex.tran1502@gmail.com>pull/11678/head
parent
b1587a5dee
commit
f33dbdfe9a
@ -1 +1 @@
|
||||
Subproject commit 898069e47f8e3283bf3bbd40b58b56d8fd57dc65
|
||||
Subproject commit 39f25a96f13f743c96cdb7c6d93b031fcb61b83c
|
||||
@ -0,0 +1,98 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class RatingResponse {
|
||||
/// Returns a new [RatingResponse] instance.
|
||||
RatingResponse({
|
||||
required this.enabled,
|
||||
});
|
||||
|
||||
bool enabled;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is RatingResponse &&
|
||||
other.enabled == enabled;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(enabled.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'RatingResponse[enabled=$enabled]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'enabled'] = this.enabled;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [RatingResponse] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static RatingResponse? fromJson(dynamic value) {
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return RatingResponse(
|
||||
enabled: mapValueOfType<bool>(json, r'enabled')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<RatingResponse> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <RatingResponse>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = RatingResponse.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, RatingResponse> mapFromJson(dynamic json) {
|
||||
final map = <String, RatingResponse>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = RatingResponse.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of RatingResponse-objects as value to a dart map
|
||||
static Map<String, List<RatingResponse>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<RatingResponse>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = RatingResponse.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'enabled',
|
||||
};
|
||||
}
|
||||
|
||||
@ -0,0 +1,107 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class RatingUpdate {
|
||||
/// Returns a new [RatingUpdate] instance.
|
||||
RatingUpdate({
|
||||
this.enabled,
|
||||
});
|
||||
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
bool? enabled;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is RatingUpdate &&
|
||||
other.enabled == enabled;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(enabled == null ? 0 : enabled!.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'RatingUpdate[enabled=$enabled]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
if (this.enabled != null) {
|
||||
json[r'enabled'] = this.enabled;
|
||||
} else {
|
||||
// json[r'enabled'] = null;
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [RatingUpdate] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static RatingUpdate? fromJson(dynamic value) {
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return RatingUpdate(
|
||||
enabled: mapValueOfType<bool>(json, r'enabled'),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<RatingUpdate> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <RatingUpdate>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = RatingUpdate.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, RatingUpdate> mapFromJson(dynamic json) {
|
||||
final map = <String, RatingUpdate>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = RatingUpdate.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of RatingUpdate-objects as value to a dart map
|
||||
static Map<String, List<RatingUpdate>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<RatingUpdate>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = RatingUpdate.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
};
|
||||
}
|
||||
|
||||
@ -0,0 +1,14 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AddRating1722753178937 implements MigrationInterface {
|
||||
name = 'AddRating1722753178937'
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "exif" ADD "rating" integer`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "rating"`);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { updateAsset, type AssetResponseDto } from '@immich/sdk';
|
||||
import { t } from 'svelte-i18n';
|
||||
import StarRating from '$lib/components/shared-components/star-rating.svelte';
|
||||
import { handlePromiseError, isSharedLink } from '$lib/utils';
|
||||
import { preferences } from '$lib/stores/user.store';
|
||||
|
||||
export let asset: AssetResponseDto;
|
||||
export let isOwner: boolean;
|
||||
|
||||
$: rating = asset.exifInfo?.rating || 0;
|
||||
|
||||
const handleChangeRating = async (rating: number) => {
|
||||
try {
|
||||
await updateAsset({ id: asset.id, updateAssetDto: { rating } });
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.cant_apply_changes'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if !isSharedLink() && $preferences?.rating?.enabled}
|
||||
<section class="relative flex px-4 pt-2">
|
||||
<StarRating {rating} readOnly={!isOwner} onRating={(rating) => handlePromiseError(handleChangeRating(rating))} />
|
||||
</section>
|
||||
{/if}
|
||||
@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
|
||||
export let count = 5;
|
||||
export let rating: number;
|
||||
export let readOnly = false;
|
||||
export let onRating: (rating: number) => void | undefined;
|
||||
|
||||
let hoverRating = 0;
|
||||
|
||||
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 handleSelect = (newRating: number) => {
|
||||
if (readOnly) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (newRating === rating) {
|
||||
newRating = 0;
|
||||
}
|
||||
|
||||
rating = newRating;
|
||||
|
||||
onRating?.(rating);
|
||||
};
|
||||
</script>
|
||||
|
||||
<div role="presentation" tabindex="-1" on:mouseout={() => (hoverRating = 0)} on:blur|preventDefault>
|
||||
{#each { length: count } as _, index}
|
||||
{@const value = index + 1}
|
||||
{@const filled = hoverRating >= value || (hoverRating === 0 && rating >= value)}
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => handleSelect(value)}
|
||||
on:mouseover={() => (hoverRating = value)}
|
||||
on:focus|preventDefault={() => (hoverRating = value)}
|
||||
class="shadow-0 outline-0 text-immich-primary dark:text-immich-dark-primary"
|
||||
disabled={readOnly}
|
||||
>
|
||||
<Icon
|
||||
path={starIcon}
|
||||
size="1.5em"
|
||||
strokeWidth={1}
|
||||
color={filled ? 'currentcolor' : 'transparent'}
|
||||
strokeColor={filled ? 'currentcolor' : '#c1cce8'}
|
||||
/>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
Loading…
Reference in New Issue