feat: Mark people as favorite (#14866)

* feat: added ability to mark people as favorite, which get sorted to the front of the people list

* feat(server): added unit test for favorite people

* feat(server): refactored for better readability

* fixed person service unit tests

* fixed open-api and sql checks

* fixed bad codegen and removed unnecessary type assertion again

* chore: clean up

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
This commit is contained in:
Arno 2025-02-04 09:52:17 +01:00 committed by GitHub
parent 69e88ef985
commit 7ec3610753
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 355 additions and 28 deletions

View file

@ -1,4 +1,7 @@
<script lang="ts">
import { focusOutside } from '$lib/actions/focus-outside';
import Icon from '$lib/components/elements/icon.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import { AppRoute, QueryParameter } from '$lib/constants';
import { getPeopleThumbnailUrl } from '$lib/utils';
import { type PersonResponseDto } from '@immich/sdk';
@ -8,12 +11,13 @@
mdiCalendarEditOutline,
mdiDotsVertical,
mdiEyeOffOutline,
mdiHeart,
mdiHeartMinusOutline,
mdiHeartOutline,
} from '@mdi/js';
import { t } from 'svelte-i18n';
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
import MenuOption from '../shared-components/context-menu/menu-option.svelte';
import { t } from 'svelte-i18n';
import { focusOutside } from '$lib/actions/focus-outside';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
interface Props {
person: PersonResponseDto;
@ -22,9 +26,18 @@
onSetBirthDate: () => void;
onMergePeople: () => void;
onHidePerson: () => void;
onToggleFavorite: () => void;
}
let { person, preload = false, onChangeName, onSetBirthDate, onMergePeople, onHidePerson }: Props = $props();
let {
person,
preload = false,
onChangeName,
onSetBirthDate,
onMergePeople,
onHidePerson,
onToggleFavorite,
}: Props = $props();
let showVerticalDots = $state(false);
</script>
@ -51,6 +64,11 @@
title={person.name}
widthStyle="100%"
/>
{#if person.isFavorite}
<div class="absolute bottom-2 left-2 z-10">
<Icon path={mdiHeart} size="24" class="text-white" />
</div>
{/if}
</div>
{#if person.name}
<span
@ -76,6 +94,11 @@
<MenuOption onClick={onChangeName} icon={mdiAccountEditOutline} text={$t('change_name')} />
<MenuOption onClick={onSetBirthDate} icon={mdiCalendarEditOutline} text={$t('set_date_of_birth')} />
<MenuOption onClick={onMergePeople} icon={mdiAccountMultipleCheckOutline} text={$t('merge_people')} />
<MenuOption
onClick={onToggleFavorite}
icon={person.isFavorite ? mdiHeartMinusOutline : mdiHeartOutline}
text={person.isFavorite ? $t('unfavorite') : $t('to_favorite')}
/>
</ButtonContextMenu>
</div>
{/if}

View file

@ -11,6 +11,8 @@
import { onMount } from 'svelte';
import { websocketEvents } from '$lib/stores/websocket';
import SingleGridRow from '$lib/components/shared-components/single-grid-row.svelte';
import Icon from '$lib/components/elements/icon.svelte';
import { mdiHeart } from '@mdi/js';
interface Props {
data: PageData;
@ -53,7 +55,7 @@
<SingleGridRow class="grid md:grid-auto-fill-28 grid-auto-fill-20 gap-x-4">
{#snippet children({ itemCount })}
{#each people.slice(0, itemCount) as person (person.id)}
<a href="{AppRoute.PEOPLE}/{person.id}" class="text-center">
<a href="{AppRoute.PEOPLE}/{person.id}" class="text-center relative">
<ImageThumbnail
circle
shadow
@ -61,6 +63,11 @@
altText={person.name}
widthStyle="100%"
/>
{#if person.isFavorite}
<div class="absolute bottom-2 left-2 z-10">
<Icon path={mdiHeart} size="24" class="text-white" />
</div>
{/if}
<p class="mt-2 text-ellipsis text-sm font-medium dark:text-white">{person.name}</p>
</a>
{/each}

View file

@ -222,6 +222,25 @@
}
};
const handleToggleFavorite = async (detail: PersonResponseDto) => {
try {
const updatedPerson = await updatePerson({
id: detail.id,
personUpdateDto: { isFavorite: !detail.isFavorite },
});
const index = people.findIndex((person) => person.id === detail.id);
people[index] = updatedPerson;
notificationController.show({
message: updatedPerson.isFavorite ? $t('added_to_favorites') : $t('removed_from_favorites'),
type: NotificationType.Info,
});
} catch (error) {
handleError(error, $t('errors.unable_to_add_remove_favorites', { values: { favorite: detail.isFavorite } }));
}
};
const handleMergePeople = async (detail: PersonResponseDto) => {
await goto(
`${AppRoute.PEOPLE}/${detail.id}?${QueryParameter.ACTION}=${ActionQueryParameterValue.MERGE}&${QueryParameter.PREVIOUS_ROUTE}=${AppRoute.PEOPLE}`,
@ -413,6 +432,7 @@
onSetBirthDate={() => handleSetBirthDate(person)}
onMergePeople={() => handleMergePeople(person)}
onHidePerson={() => handleHidePerson(person)}
onToggleFavorite={() => handleToggleFavorite(person)}
/>
{/snippet}
</PeopleInfiniteScroll>

View file

@ -1,6 +1,8 @@
<script lang="ts">
import { afterNavigate, goto } from '$app/navigation';
import { afterNavigate, goto, invalidateAll } from '$app/navigation';
import { page } from '$app/stores';
import { clickOutside } from '$lib/actions/click-outside';
import { listNavigation } from '$lib/actions/list-navigation';
import { scrollMemoryClearer } from '$lib/actions/scroll-memory';
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
import EditNameInput from '$lib/components/faces-page/edit-name-input.svelte';
@ -17,8 +19,10 @@
import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte';
import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte';
import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte';
import TagAction from '$lib/components/photos-page/actions/tag-action.svelte';
import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
@ -27,11 +31,12 @@
notificationController,
} from '$lib/components/shared-components/notification/notification';
import { AppRoute, PersonPageViewMode, QueryParameter, SessionStorageKey } from '$lib/constants';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { AssetStore } from '$lib/stores/assets.store';
import { preferences } from '$lib/stores/user.store';
import { websocketEvents } from '$lib/stores/websocket';
import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils';
import { clickOutside } from '$lib/actions/click-outside';
import { handleError } from '$lib/utils/handle-error';
import { isExternalUrl } from '$lib/utils/navigation';
import {
@ -50,16 +55,13 @@
mdiDotsVertical,
mdiEyeOffOutline,
mdiEyeOutline,
mdiHeartMinusOutline,
mdiHeartOutline,
mdiPlus,
} from '@mdi/js';
import { onDestroy, onMount } from 'svelte';
import type { PageData } from './$types';
import { listNavigation } from '$lib/actions/list-navigation';
import { t } from 'svelte-i18n';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import { preferences } from '$lib/stores/user.store';
import TagAction from '$lib/components/photos-page/actions/tag-action.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import type { PageData } from './$types';
interface Props {
data: PageData;
@ -181,6 +183,25 @@
}
};
const handleToggleFavorite = async () => {
try {
const updatedPerson = await updatePerson({
id: person.id,
personUpdateDto: { isFavorite: !person.isFavorite },
});
// Invalidate to reload the page data and have the favorite status updated
await invalidateAll();
notificationController.show({
message: updatedPerson.isFavorite ? $t('added_to_favorites') : $t('removed_from_favorites'),
type: NotificationType.Info,
});
} catch (error) {
handleError(error, $t('errors.unable_to_add_remove_favorites', { values: { favorite: person.isFavorite } }));
}
};
const handleMerge = async (person: PersonResponseDto) => {
await updateAssetCount();
await handleGoBack();
@ -445,6 +466,11 @@
icon={mdiAccountMultipleCheckOutline}
onClick={() => (viewMode = PersonPageViewMode.MERGE_PEOPLE)}
/>
<MenuOption
icon={person.isFavorite ? mdiHeartMinusOutline : mdiHeartOutline}
text={person.isFavorite ? $t('unfavorite') : $t('to_favorite')}
onClick={handleToggleFavorite}
/>
</ButtonContextMenu>
{/snippet}
</ControlAppBar>