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
This commit is contained in:
Mees Frensel
2026-02-26 14:54:20 +01:00
committed by GitHub
parent 4c79c3c902
commit e454c3566b
24 changed files with 294 additions and 127 deletions
@@ -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 = {
@@ -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}
@@ -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>
+20 -26
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"
+1 -1
View File
@@ -15,7 +15,7 @@
date: SearchDateFilter;
display: SearchDisplayFilters;
mediaType: MediaType;
rating?: number;
rating?: number | null;
};
</script>
@@ -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}