mirror of
https://github.com/immich-app/immich.git
synced 2025-12-19 17:23:21 +03:00
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>
This commit is contained in:
@@ -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}
|
||||
@@ -41,6 +41,7 @@
|
||||
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
||||
import AlbumListItemDetails from './album-list-item-details.svelte';
|
||||
import DetailPanelDescription from '$lib/components/asset-viewer/detail-panel-description.svelte';
|
||||
import DetailPanelRating from '$lib/components/asset-viewer/detail-panel-star-rating.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
@@ -162,6 +163,7 @@
|
||||
{/if}
|
||||
|
||||
<DetailPanelDescription {asset} {isOwner} />
|
||||
<DetailPanelRating {asset} {isOwner} />
|
||||
|
||||
{#if (!isSharedLink() && unassignedFaces.length > 0) || people.length > 0}
|
||||
<section class="px-4 py-4 text-sm">
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
export let ariaHidden: boolean | undefined = undefined;
|
||||
export let ariaLabel: string | undefined = undefined;
|
||||
export let ariaLabelledby: string | undefined = undefined;
|
||||
export let strokeWidth: number = 0;
|
||||
export let strokeColor: string = 'currentColor';
|
||||
</script>
|
||||
|
||||
<svg
|
||||
@@ -22,6 +24,8 @@
|
||||
{viewBox}
|
||||
class="{className} {flipped ? '-scale-x-100' : ''}"
|
||||
{role}
|
||||
stroke={strokeColor}
|
||||
stroke-width={strokeWidth}
|
||||
aria-label={ariaLabel}
|
||||
aria-hidden={ariaHidden}
|
||||
aria-labelledby={ariaLabelledby}
|
||||
|
||||
50
web/src/lib/components/shared-components/star-rating.svelte
Normal file
50
web/src/lib/components/shared-components/star-rating.svelte
Normal file
@@ -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>
|
||||
@@ -19,6 +19,13 @@
|
||||
import { locale as i18nLocale, t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import { preferences } from '$lib/stores/user.store';
|
||||
import { updateMyPreferences } from '@immich/sdk';
|
||||
import { handleError } from '../../utils/handle-error';
|
||||
import {
|
||||
notificationController,
|
||||
NotificationType,
|
||||
} from '$lib/components/shared-components/notification/notification';
|
||||
|
||||
let time = new Date();
|
||||
|
||||
@@ -39,6 +46,7 @@
|
||||
label: findLocale(editedLocale).name || fallbackLocale.name,
|
||||
};
|
||||
$: closestLanguage = getClosestAvailableLocale([$lang], langCodes);
|
||||
$: ratingEnabled = $preferences?.rating?.enabled;
|
||||
|
||||
onMount(() => {
|
||||
const interval = setInterval(() => {
|
||||
@@ -90,6 +98,17 @@
|
||||
$locale = newLocale;
|
||||
}
|
||||
};
|
||||
|
||||
const handleRatingChange = async (enabled: boolean) => {
|
||||
try {
|
||||
const data = await updateMyPreferences({ userPreferencesUpdateDto: { rating: { enabled } } });
|
||||
$preferences.rating.enabled = data.rating.enabled;
|
||||
|
||||
notificationController.show({ message: $t('saved_settings'), type: NotificationType.Info });
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_update_settings'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<section class="my-4">
|
||||
@@ -185,6 +204,14 @@
|
||||
bind:checked={$sidebarSettings.sharing}
|
||||
/>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<SettingSwitch
|
||||
title={$t('rating')}
|
||||
subtitle={$t('rating_description')}
|
||||
bind:checked={ratingEnabled}
|
||||
on:toggle={({ detail: enabled }) => handleRatingChange(enabled)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user