refactor(web): use actions and ui lib context menu (#31729)

This commit is contained in:
Mees Frensel
2026-09-22 16:38:00 -04:00
committed by GitHub
parent 1aedf1b41c
commit a9ce155cf8
3 changed files with 50 additions and 139 deletions
@@ -1,7 +1,5 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/ButtonContextMenu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
import { timeBeforeShowLoadingSpinner } from '$lib/constants';
import { activityManager } from '$lib/managers/activity-manager.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
@@ -13,8 +11,8 @@
import { handleError } from '$lib/utils/handle-error';
import { isTenMinutesApart } from '$lib/utils/timesince';
import { ReactionType, type ActivityResponseDto, type AlbumUserResponseDto, type AssetTypeEnum } from '@immich/sdk';
import { Icon, IconButton, LoadingSpinner, Textarea, toastManager } from '@immich/ui';
import { mdiClose, mdiDeleteOutline, mdiDotsVertical, mdiSend, mdiThumbUp } from '@mdi/js';
import { ContextMenuButton, Icon, IconButton, LoadingSpinner, Textarea, toastManager } from '@immich/ui';
import { mdiClose, mdiSend, mdiThumbUp, mdiTrashCanOutline } from '@mdi/js';
import * as luxon from 'luxon';
import { t } from 'svelte-i18n';
import { fromAction } from 'svelte/attachments';
@@ -110,6 +108,25 @@
};
</script>
{#snippet reactionMenu(reaction: ActivityResponseDto, index: number, title: string)}
{#if reaction.user.id === authManager.user.id || isAlbumOwner}
<ContextMenuButton
translations={{ open_menu: title }}
position="top-right"
class="shrink-0"
size="small"
items={[
{
icon: mdiTrashCanOutline,
title: $t('remove'),
color: 'danger',
onAction: () => handleDeleteReaction(reaction, index),
},
]}
/>
{/if}
{/snippet}
<div class="relative h-full overflow-y-hidden border-l border-subtle bg-subtle" bind:offsetHeight={innerHeight}>
<div class="size-full">
<div class="flex h-fit w-full bg-subtle p-2 dark:text-immich-dark-fg" bind:clientHeight={activityHeight}>
@@ -133,7 +150,7 @@
>
{#each activityManager.activities as reaction, index (reaction.id)}
{#if reaction.type === ReactionType.Comment}
<div class="mt-3 flex justify-start gap-4 rounded-lg bg-gray-200 py-3 ps-3 dark:bg-gray-800">
<div class="mt-3 flex justify-start gap-4 rounded-lg bg-gray-200 p-3 dark:bg-gray-800">
<div class="flex items-center">
<UserAvatar user={reaction.user} size="sm" />
</div>
@@ -148,24 +165,7 @@
/>
</a>
{/if}
{#if reaction.user.id === authManager.user.id || isAlbumOwner}
<div class="me-4">
<ButtonContextMenu
icon={mdiDotsVertical}
title={$t('comment_options')}
align="top-right"
direction="left"
size="small"
>
<MenuOption
activeColor="bg-red-200"
icon={mdiDeleteOutline}
text={$t('remove')}
onClick={() => handleDeleteReaction(reaction, index)}
/>
</ButtonContextMenu>
</div>
{/if}
{@render reactionMenu(reaction, index, $t('comment_options'))}
</div>
{#if (index !== activityManager.activities.length - 1 && !shouldGroup(activityManager.activities[index].createdAt, activityManager.activities[index + 1].createdAt)) || index === activityManager.activities.length - 1}
@@ -178,7 +178,7 @@
{/if}
{:else if reaction.type === ReactionType.Like}
<div class="relative">
<div class="mt-3 flex items-center gap-4 py-3 ps-3 text-sm">
<div class="mt-3 flex items-center gap-4 p-3 text-sm">
<div class="text-primary"><Icon icon={mdiThumbUp} size="20" /></div>
<div class="w-full" title={`${reaction.user.name} (${reaction.user.email})`}>
@@ -198,24 +198,7 @@
/>
</a>
{/if}
{#if reaction.user.id === authManager.user.id || isAlbumOwner}
<div class="me-4">
<ButtonContextMenu
icon={mdiDotsVertical}
title={$t('reaction_options')}
align="top-right"
direction="left"
size="small"
>
<MenuOption
activeColor="bg-red-200"
icon={mdiDeleteOutline}
text={$t('remove')}
onClick={() => handleDeleteReaction(reaction, index)}
/>
</ButtonContextMenu>
</div>
{/if}
{@render reactionMenu(reaction, index, $t('reaction_options'))}
</div>
{#if (index !== activityManager.activities.length - 1 && isTenMinutesApart(activityManager.activities[index].createdAt, activityManager.activities[index + 1].createdAt)) || index === activityManager.activities.length - 1}
<div
+1 -46
View File
@@ -161,46 +161,6 @@
}
};
const handleHidePerson = async (detail: PersonResponseDto) => {
try {
const updatedPerson = await updatePerson({
id: detail.id,
personUpdateDto: { isHidden: true },
});
people = people.map((person: PersonResponseDto) => {
if (person.id === updatedPerson.id) {
return updatedPerson;
}
return person;
});
toastManager.primary($t('changed_visibility_successfully'));
} catch (error) {
handleError(error, $t('errors.unable_to_hide_person'));
}
};
const handleToggleFavorite = async (detail: PersonResponseDto) => {
try {
const updatedPerson = await updatePerson({
id: detail.id,
personUpdateDto: { isFavorite: !detail.isFavorite },
});
people = people.map((person: PersonResponseDto) => {
if (person.id === updatedPerson.id) {
return updatedPerson;
}
return person;
});
toastManager.primary(updatedPerson.isFavorite ? $t('added_to_favorites') : $t('removed_from_favorites'));
} catch (error) {
handleError(error, $t('errors.unable_to_add_remove_favorites', { values: { favorite: detail.isFavorite } }));
}
};
const handleMergePeople = async (detail: PersonResponseDto) => {
await goto(Route.viewPerson(detail, { previousRoute: Route.people(), action: 'merge' }));
};
@@ -344,12 +304,7 @@
<div
class="rounded-xl border-2 border-transparent p-2 transition-all hover:border-immich-primary/50 hover:bg-gray-200 hover:shadow-sm hover:dark:border-immich-dark-primary/25 dark:hover:bg-immich-dark-primary/20"
>
<PeopleCard
{person}
onMergePeople={() => handleMergePeople(person)}
onHidePerson={() => handleHidePerson(person)}
onToggleFavorite={() => handleToggleFavorite(person)}
/>
<PeopleCard {person} onMergePeople={() => handleMergePeople(person)} />
<input
type="text"
+24 -51
View File
@@ -1,51 +1,37 @@
<script lang="ts">
import { focusOutside } from '$lib/actions/focus-outside';
import ActionMenuItem from '$lib/components/ActionMenuItem.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/ButtonContextMenu.svelte';
import { Route } from '$lib/route';
import { getPersonActions } from '$lib/services/person.service';
import { getPeopleThumbnailUrl } from '$lib/utils';
import { type PersonResponseDto } from '@immich/sdk';
import { Icon } from '@immich/ui';
import {
mdiAccountMultipleCheckOutline,
mdiDotsVertical,
mdiEyeOffOutline,
mdiHeart,
mdiHeartMinusOutline,
mdiHeartOutline,
} from '@mdi/js';
import { ContextMenuButton, Icon } from '@immich/ui';
import { mdiAccountMultipleCheckOutline, mdiHeart } from '@mdi/js';
import { t } from 'svelte-i18n';
import ImageThumbnail from '$lib/components/assets/thumbnail/ImageThumbnail.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
type Props = {
person: PersonResponseDto;
onMergePeople: () => void;
onHidePerson: () => void;
onToggleFavorite: () => void;
};
let { person, onMergePeople, onHidePerson, onToggleFavorite }: Props = $props();
let { person, onMergePeople }: Props = $props();
let showVerticalDots = $state(false);
const { SetDateOfBirth, HidePerson, Favorite, Unfavorite } = $derived(getPersonActions($t, person));
const { SetDateOfBirth } = $derived(getPersonActions($t, person));
const items = $derived([
HidePerson,
SetDateOfBirth,
{
icon: mdiAccountMultipleCheckOutline,
title: $t('merge_people'),
onAction: onMergePeople,
},
Favorite,
Unfavorite,
]);
</script>
<div
id="people-card"
class="relative"
onmouseenter={() => (showVerticalDots = true)}
onmouseleave={() => (showVerticalDots = false)}
role="group"
use:focusOutside={{ onFocusOut: () => (showVerticalDots = false) }}
>
<a
href={Route.viewPerson(person, { previousRoute: Route.people() })}
draggable="false"
onfocus={() => (showVerticalDots = true)}
>
<div id="people-card" class="relative" role="group">
<a href={Route.viewPerson(person, { previousRoute: Route.people() })} draggable="false" class="group">
<div class="size-full rounded-xl brightness-95 filter">
<ImageThumbnail
shadow
@@ -62,27 +48,14 @@
</div>
{/if}
</div>
</a>
{#if showVerticalDots}
<div class="absolute inset-e-2 top-2 z-1">
<ButtonContextMenu
buttonClass="icon-white-drop-shadow"
color="secondary"
size="medium"
<div class="absolute inset-e-2 top-2 opacity-0 group-hover:opacity-100 focus-within:opacity-100">
<ContextMenuButton
variant="filled"
icon={mdiDotsVertical}
title={$t('show_person_options')}
>
<MenuOption onClick={onHidePerson} icon={mdiEyeOffOutline} text={$t('hide_person')} />
<ActionMenuItem action={SetDateOfBirth} />
<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>
class="icon-white-drop-shadow"
translations={{ open_menu: $t('show_person_options') }}
{items}
/>
</div>
{/if}
</a>
</div>