feat: bulk person management (#31960)

Co-authored-by: Jason Rasmussen <jason@rasm.me>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
This commit is contained in:
immich-push-o-matic[bot]
2026-09-30 23:39:14 +02:00
committed by GitHub
co-authored by Jason Rasmussen Daniel Dietzler
parent c263b0d4d2
commit f32a891d40
19 changed files with 812 additions and 483 deletions
@@ -8,7 +8,6 @@ import type {
LibraryResponseDto,
LoginResponseDto,
PersonResponseDto,
PersonUserRole,
QueueResponseDto,
ReleaseEventV1,
SharedLinkResponseDto,
@@ -55,7 +54,6 @@ export type Events = {
PersonUpdate: [PersonResponseDto];
PersonShare: [{ personId: string }];
PersonUserUpdate: [{ personId: string; userId: string; role: PersonUserRole }];
PersonUserDelete: [{ personId: string; userId: string }];
PersonThumbnailReady: [{ id: string }];
PersonAssetDelete: [{ id: string; assetId: string }];
@@ -18,7 +18,7 @@
};
</script>
<Modal title={$t('cluster_group')} {onClose} size="small">
<Modal title={$t('people_cluster_group')} {onClose} size="small">
<ModalBody>
{#await loadUsers()}
<div class="flex w-full place-content-center place-items-center">
@@ -10,25 +10,21 @@
title: string;
selected: string;
users: UserResponseDto[];
/** show a "no filter" option, which resolves to an empty string */
allowEmpty?: boolean;
// an empty string means "any user"
onClose: (userId?: string) => void;
};
const { title, selected, users, allowEmpty = true, onClose }: Props = $props();
const { title, selected, users, onClose }: Props = $props();
</script>
<BasicModal {title} onClose={() => onClose()} size="small">
<Stack>
{#if allowEmpty}
<ListButton selected={selected === ''} onclick={() => onClose('')}>
<div class="flex size-10 shrink-0 items-center justify-center">
<Icon icon={mdiFilterOffOutline} size="24" />
</div>
<Text fontWeight="medium" class="grow text-start">{$t('no_filter')}</Text>
</ListButton>
{/if}
<ListButton selected={selected === ''} onclick={() => onClose('')}>
<div class="flex size-10 shrink-0 items-center justify-center">
<Icon icon={mdiFilterOffOutline} size="24" />
</div>
<Text fontWeight="medium" class="grow text-start">{$t('no_filter')}</Text>
</ListButton>
{#each [authManager.user, ...users] as user (user.id)}
<ListButton selected={selected === user.id} onclick={() => onClose(user.id)}>
<UserAvatar {user} size="md" />
@@ -0,0 +1,117 @@
<script lang="ts">
import ImageThumbnail from '$lib/components/assets/thumbnail/ImageThumbnail.svelte';
import SearchBar from '$lib/elements/SearchBar.svelte';
import { getPeopleThumbnailUrl } from '$lib/utils';
import { normalizeSearchString } from '$lib/utils/string-utils';
import { type PersonResponseDto } from '@immich/sdk';
import {
ActionButton,
Button,
HStack,
ListButton,
Modal,
ModalBody,
ModalFooter,
Text,
type ActionItem,
} from '@immich/ui';
import { mdiCheckAll, mdiCloseBoxMultipleOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
type Props = {
people: PersonResponseDto[];
selectedPeople?: PersonResponseDto[];
onClose: (people?: PersonResponseDto[]) => void;
};
let { people, onClose, selectedPeople = $bindable<PersonResponseDto[]>([]) }: Props = $props();
let searchName = $state('');
const selectedIds = $derived(new Set(selectedPeople.map(({ id }) => id)));
const filteredPeople = $derived(
people.filter(({ name }) => !searchName || normalizeSearchString(name).includes(normalizeSearchString(searchName))),
);
const namedPeople = $derived(filteredPeople.filter(({ name }) => name));
const unnamedPeople = $derived(filteredPeople.filter(({ name }) => !name));
const selectPerson = (person: PersonResponseDto) => {
selectedPeople = selectedIds.has(person.id)
? selectedPeople.filter(({ id }) => id !== person.id)
: [...selectedPeople, person];
};
const SelectAll: ActionItem = $derived({
title: $t('select_all'),
icon: mdiCheckAll,
$if: () => filteredPeople.some(({ id }) => !selectedIds.has(id)),
onAction: () => (selectedPeople = [...selectedPeople, ...filteredPeople.filter(({ id }) => !selectedIds.has(id))]),
});
const UnselectAll: ActionItem = $derived({
title: $t('unselect_all'),
icon: mdiCloseBoxMultipleOutline,
$if: () => filteredPeople.some(({ id }) => selectedIds.has(id)),
onAction: () => {
const ids = new Set(filteredPeople.map(({ id }) => id));
selectedPeople = selectedPeople.filter(({ id }) => !ids.has(id));
},
});
</script>
<Modal title={$t('manage_people')} {onClose} size="medium">
<ModalBody class="flex min-h-0 flex-col">
{#if people.length > 0}
<div class="flex min-h-0 grow flex-col gap-4">
<div class="flex justify-end gap-2">
<ActionButton type="button" shape="round" size="small" action={UnselectAll} />
<ActionButton type="button" shape="round" size="small" action={SelectAll} />
</div>
<div>
<SearchBar bind:name={searchName} placeholder={$t('search_people')} showLoadingSpinner={false} />
</div>
<div class="flex min-h-0 grow immich-scrollbar flex-col gap-2 overflow-y-auto sm:max-h-120">
{#each namedPeople as person (person.id)}
<ListButton onclick={() => selectPerson(person)} selected={selectedIds.has(person.id)}>
<ImageThumbnail circle url={getPeopleThumbnailUrl(person)} altText={person.name} widthStyle="4rem" />
<Text fontWeight="medium" class="grow truncate text-start">{person.name}</Text>
</ListButton>
{/each}
{#if unnamedPeople.length > 0}
<div class="flex flex-wrap gap-2 p-1">
{#each unnamedPeople as person (person.id)}
<button
type="button"
onclick={() => selectPerson(person)}
class="rounded-full p-1 transition-all hover:bg-subtle {selectedIds.has(person.id)
? 'ring-2 ring-primary'
: ''}"
>
<ImageThumbnail circle url={getPeopleThumbnailUrl(person)} altText={person.name} widthStyle="4rem" />
</button>
{/each}
</div>
{/if}
{#if filteredPeople.length === 0}
<Text size="small" color="muted" class="p-2">{$t('no_people_found')}</Text>
{/if}
</div>
</div>
<ModalFooter>
<HStack fullWidth>
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
<Button shape="round" fullWidth onclick={() => onClose(selectedPeople)}>
{$t('save')}
</Button>
</HStack>
</ModalFooter>
{:else}
<Text color="muted">{$t('no_people_found')}</Text>
{/if}
</ModalBody>
</Modal>
@@ -0,0 +1,233 @@
<script lang="ts">
import ImageThumbnail from '$lib/components/assets/thumbnail/ImageThumbnail.svelte';
import UserAvatar from '$lib/components/shared-components/UserAvatar.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import PeopleSelectionModal from '$lib/modals/PeopleSelectionModal.svelte';
import { handleBulkUpsert } from '$lib/services/person-user.service';
import { getPeopleThumbnailUrl } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { getAllPeople, type PersonResponseDto, type UserResponseDto } from '@immich/sdk';
import {
ActionButton,
Field,
FormModal,
LoadingSpinner,
modalManager,
Select,
Switch,
Text,
type ActionItem,
} from '@immich/ui';
import { mdiAccountMultipleOutline, mdiPencilOutline } from '@mdi/js';
import { untrack } from 'svelte';
import { t } from 'svelte-i18n';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
type Props = {
users: UserResponseDto[];
people?: PersonResponseDto[];
onClose: (saved?: boolean) => void;
};
let { users, people, onClose }: Props = $props();
const user = $derived(users.find(({ id }) => id === selectedUserId) ?? users[0]);
const userOptions = users.map(({ id, name }) => ({ label: name, value: id }));
let selectedUserId = $state(users[0].id);
let shareEveryone = $state(false);
let serverPeople = $state<PersonResponseDto[]>([]);
const added = new SvelteMap<string, PersonResponseDto>();
const removed = new SvelteSet<string>();
const selectedPeople = $derived([...serverPeople.filter(({ id }) => !removed.has(id)), ...added.values()]);
const selectedIds = $derived(new Set(selectedPeople.map(({ id }) => id)));
const canSubmit = $derived(shareEveryone || selectedIds.size > 0 || serverPeople.length > 0);
const ManagePeople: ActionItem = $derived({
title: $t('edit_people'),
icon: mdiPencilOutline,
onAction: () => handleEditPeople(),
});
const loadSharedPeople = async (sharedWithId: string) => {
if (people) {
return people.filter(({ sharedWith }) => sharedWith.some(({ id }) => id === sharedWithId));
}
const sharedPeople: PersonResponseDto[] = [];
for (let page = 1, hasNextPage = true; hasNextPage; page++) {
const result = await getAllPeople({
sharedById: authManager.user.id,
sharedWithId,
withHidden: true,
page,
size: 1000,
});
sharedPeople.push(...result.people);
hasNextPage = result.hasNextPage ?? false;
}
return sharedPeople;
};
const loadShares = async (sharedWithId: string) => {
added.clear();
removed.clear();
try {
serverPeople = await loadSharedPeople(sharedWithId);
} catch (error) {
handleError(error, $t('errors.something_went_wrong'));
}
try {
const sharedIds = new Set(serverPeople.map(({ id }) => id));
const allPeople = await loadAllPeople();
const visiblePeople = allPeople.filter(({ isHidden }) => !isHidden);
shareEveryone = sharedIds.size > 0 && visiblePeople.every(({ id }) => sharedIds.has(id));
} catch (error) {
handleError(error, $t('errors.something_went_wrong'));
}
};
const handleSelectUser = async (id: string) => {
selectedUserId = id;
await loadShares(id);
};
const loadAllPeople = async () => {
if (people) {
return people;
}
const allPeople: PersonResponseDto[] = [];
for (let page = 1, hasNextPage = true; hasNextPage; page++) {
const result = await getAllPeople({ withHidden: false, page, size: 1000 });
allPeople.push(...result.people);
hasNextPage = result.hasNextPage ?? false;
}
return allPeople;
};
const handleEditPeople = async () => {
let allPeople: PersonResponseDto[];
try {
allPeople = await loadAllPeople();
} catch (error) {
handleError(error, $t('errors.something_went_wrong'));
return;
}
const updatedPeople = await modalManager.show(PeopleSelectionModal, {
people: allPeople.filter(({ isHidden }) => !isHidden),
selectedPeople,
});
if (!updatedPeople) {
return;
}
const updatedIds = new Set(updatedPeople.map(({ id }) => id));
for (const id of selectedIds) {
if (updatedIds.has(id)) {
continue;
}
if (added.has(id)) {
added.delete(id);
} else {
removed.add(id);
shareEveryone = false;
}
}
for (const person of updatedPeople ?? []) {
if (!selectedIds.has(person.id)) {
added.set(person.id, person);
}
}
};
const onSubmit = async () => {
const success = await handleBulkUpsert({
sharedWithIds: [selectedUserId],
everyone: shareEveryone,
personIds: [...added.keys()],
removedIds: [...removed],
});
if (success) {
onClose(true);
}
};
</script>
<FormModal
title={$t('manage_person_access')}
submitText={$t('save')}
icon={mdiAccountMultipleOutline}
size="small"
disabled={!canSubmit}
{onSubmit}
onClose={() => onClose()}
>
<div class="flex flex-col gap-4">
<Text size="small" color="muted">{$t('manage_people_access_description')}</Text>
{#if users.length > 1}
<Field label={$t('share_with')}>
<Select value={selectedUserId} options={userOptions} onChange={handleSelectUser} />
</Field>
{:else}
<div class="flex items-center gap-4">
<UserAvatar {user} size="md" />
<div class="text-start">
<Text fontWeight="medium">{user.name}</Text>
<Text size="tiny" color="muted">{user.email}</Text>
</div>
</div>
{/if}
{#await untrack(() => loadShares(selectedUserId))}
<div class="flex w-full place-content-center place-items-center">
<LoadingSpinner />
</div>
{:then}
<Field
label={$t('share_everyone')}
description={$t('share_everyone_description', { values: { user: user.name } })}
>
<Switch bind:checked={shareEveryone} />
</Field>
{#if !shareEveryone}
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<Text size="small" fontWeight="medium">
{$t('shared_with_count', { values: { count: selectedPeople.length } })}
</Text>
<ActionButton type="button" color="primary" variant="ghost" action={ManagePeople} />
</div>
<div class="immich-scrollbar overflow-y-auto sm:max-h-64">
{#each selectedPeople as person (person.id)}
<div class="flex items-center gap-3 rounded-lg px-2 py-1 hover:bg-subtle">
<ImageThumbnail circle url={getPeopleThumbnailUrl(person)} altText={person.name} widthStyle="2.5rem" />
<Text size="small" class="grow truncate">{person.name}</Text>
</div>
{:else}
<Text size="small" color="muted" class="p-2">{$t('no_people_found')}</Text>
{/each}
</div>
</div>
{/if}
{/await}
</div>
</FormModal>
+56 -45
View File
@@ -2,20 +2,17 @@
import HeaderActionButton from '$lib/components/HeaderActionButton.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import UserAvatar from '$lib/components/shared-components/UserAvatar.svelte';
import {
getPersonUserActions,
getPersonUsersActions,
handleUpdatePersonUserRole,
} from '$lib/services/person-user.service';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { getPersonUserActions, getPersonUsersActions } from '$lib/services/person-user.service';
import { handleError } from '$lib/utils/handle-error';
import {
getClusterGroupUsers,
getUsersForPeople,
PersonUserRole,
SharingDirection,
type PersonResponseDto,
type PersonUsersResponseDto,
type UserResponseDto,
} from '@immich/sdk';
import { ActionButton, BasicModal, Field, HStack, Select, Text } from '@immich/ui';
import { ActionButton, BasicModal, HStack, Text, type ActionItem } from '@immich/ui';
import { mdiAccountMultipleOutline } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
@@ -27,64 +24,78 @@
let { person, onClose }: Props = $props();
let personUsers = $state<PersonUsersResponseDto>([]);
let sharedByMe = $state<UserResponseDto[]>([]);
let sharedWithMe = $state<UserResponseDto[]>([]);
let clusterGroupUsers = $state<UserResponseDto[]>([]);
const { AddUsers } = $derived(getPersonUsersActions($t, person, personUsers));
const { AddUsers } = $derived(getPersonUsersActions($t, person, [...sharedByMe, ...sharedWithMe], clusterGroupUsers));
const refreshPersonUsers = async () => {
try {
personUsers = await getUsersForPeople({ personId: person.id, direction: SharingDirection.SharedBy });
const [sharedBy, sharedWith] = await Promise.all([
getUsersForPeople({ personId: person.id, direction: SharingDirection.SharedBy }),
getUsersForPeople({ personId: person.id, direction: SharingDirection.SharedWith }),
]);
const sharedByMeIds = new Set(sharedBy.map(({ sharedWithId }) => sharedWithId));
sharedByMe = sharedBy.map(({ sharedWith }) => sharedWith);
sharedWithMe = sharedWith.map(({ sharedBy }) => sharedBy).filter(({ id }) => !sharedByMeIds.has(id));
} catch (error) {
handleError(error, $t('errors.something_went_wrong'));
}
};
const loadClusterGroupUsers = async () => {
try {
clusterGroupUsers = await getClusterGroupUsers({ id: authManager.user.clusterGroupId });
} catch (error) {
handleError(error, $t('errors.something_went_wrong'));
}
};
onMount(async () => {
await refreshPersonUsers();
await Promise.all([refreshPersonUsers(), loadClusterGroupUsers()]);
});
</script>
<OnEvents
onPersonShare={refreshPersonUsers}
onPersonUserUpdate={refreshPersonUsers}
onPersonUserDelete={refreshPersonUsers}
/>
{#snippet userCard(user: UserResponseDto, action: ActionItem, type: 'icon' | 'button')}
<div class="rounded-2xl border border-gray-200 bg-slate-50 p-4 dark:border-gray-800 dark:bg-gray-900">
<div class="flex items-center justify-between gap-4">
<div class="flex min-w-0 items-center gap-4">
<UserAvatar {user} size="md" />
<div class="min-w-0 text-start">
<Text class="truncate">{user.name}</Text>
<Text size="small" color="muted" class="truncate">{user.email}</Text>
</div>
</div>
<ActionButton {type} shape="round" color={action.color ?? 'secondary'} variant="ghost" size="small" {action} />
</div>
</div>
{/snippet}
<BasicModal title={$t('manage_person_access')} size="small" icon={mdiAccountMultipleOutline} {onClose}>
<OnEvents onPersonShare={refreshPersonUsers} onPersonUserDelete={refreshPersonUsers} />
<BasicModal title={$t('manage_person_access')} size="medium" icon={mdiAccountMultipleOutline} {onClose}>
<Text size="small" color="muted" class="mb-4">
{$t('manage_person_access_description')}
</Text>
<HStack fullWidth class="my-2 justify-between">
<Text size="medium" fontWeight="semi-bold">{$t('users')}</Text>
<Text size="medium" fontWeight="semi-bold">{$t('shared_by_me')}</Text>
<HeaderActionButton action={AddUsers} />
</HStack>
<div class="ps-2">
{#each personUsers as { sharedWith, role } (sharedWith.id)}
{@const { Delete } = getPersonUserActions($t, person, sharedWith)}
<div class="flex items-center justify-between gap-4 py-2">
<div class="flex w-full items-center justify-between gap-4">
<div class="flex flex-row items-center gap-2">
<div>
<UserAvatar user={sharedWith} size="md" />
</div>
<Text size="small">{sharedWith.name}</Text>
</div>
<Field class="w-32">
<Select
value={role}
options={[
{ label: 'Read', value: PersonUserRole.Read },
{ label: 'Write', value: PersonUserRole.Write },
{ label: 'Admin', value: PersonUserRole.Admin },
]}
onChange={(value) =>
handleUpdatePersonUserRole({ personId: person.id, userId: sharedWith.id, role: value })}
/>
</Field>
</div>
<ActionButton type="icon" action={Delete} />
</div>
<div class="flex flex-col gap-3">
{#each sharedByMe as user (user.id)}
{@render userCard(user, getPersonUserActions($t, person, user).Delete, 'icon')}
{/each}
</div>
{#if sharedWithMe.length > 0}
<Text size="medium" fontWeight="semi-bold" class="mt-4 mb-2">{$t('shared_with_me')}</Text>
<div class="flex flex-col gap-3">
{#each sharedWithMe as user (user.id)}
{@render userCard(user, getPersonUserActions($t, person, user).ShareBack, 'button')}
{/each}
</div>
{/if}
</BasicModal>
+24 -146
View File
@@ -1,190 +1,68 @@
<script lang="ts">
import { authManager } from '$lib/managers/auth-manager.svelte';
import PeopleFilterUserPicker from '$lib/modals/PeopleFilterUserPicker.svelte';
import { handleUpdatePerson } from '$lib/services/person.service';
import { locale } from '$lib/stores/preferences.store';
import { PersonUpdateStrategy, PersonUserRole, type PersonResponseDto } from '@immich/sdk';
import {
Alert,
Button,
Checkbox,
DatePicker,
Field,
FormModal,
HelperText,
HStack,
Input,
Label,
modalManager,
Stack,
Text,
} from '@immich/ui';
import { mdiAccountMultipleOutline, mdiChevronDown, mdiText } from '@mdi/js';
import { PersonUpdateStrategy, type PersonResponseDto } from '@immich/sdk';
import { Checkbox, DatePicker, Field, FormModal, HelperText, HStack, Input, Label, Stack } from '@immich/ui';
import { mdiPencilOutline } from '@mdi/js';
import { DateTime } from 'luxon';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
type Props = {
person: PersonResponseDto;
targetUserId?: string;
onClose: () => void;
};
const { person, targetUserId: initialTargetUserId, onClose }: Props = $props();
const { person, onClose }: Props = $props();
const candidates = $derived([
{ name: person.name, birthDate: person.birthDate, sharedById: authManager.user.id },
...(person.otherPeople ?? []),
]);
const userNames = $derived(
new Map([
[authManager.user.id, authManager.user.name],
...person.sharedBy.map(({ id, name }) => [id, name] as const),
]),
);
const writableRoles = new Set([PersonUserRole.Write, PersonUserRole.Admin]);
let targetUserId = $state(initialTargetUserId ?? authManager.user.id);
let targetPerson = $state(candidates[0]);
let applyToEveryone = $state(authManager.preferences.people?.updateStrategy === PersonUpdateStrategy.Everyone);
const isWritable = $derived.by(() => {
if (!targetUserId) {
return false;
}
if (targetUserId === authManager.user.id) {
return true;
}
const sharedBy = person.sharedBy.find((user) => user.id === targetUserId);
if (!sharedBy) {
return false;
}
return writableRoles.has(sharedBy.role);
});
let name = $state(person.name);
let birthDate = $state(person.birthDate);
let onlyForMe = $state(authManager.preferences.people?.updateStrategy !== PersonUpdateStrategy.Everyone);
const onSubmit = async () => {
const response = await handleUpdatePerson({
id: person.id,
name: targetPerson.name,
birthDate: targetPerson.birthDate,
userId: applyToEveryone ? undefined : targetPerson.sharedById,
name,
birthDate,
userId: onlyForMe ? authManager.user.id : undefined,
});
if (response) {
onClose();
}
};
const handleCopyFromMine = () => {
targetPerson.name = person.name;
targetPerson.birthDate = person.birthDate;
};
const onChange = (value: string) => {
targetUserId = value;
const match = candidates.find((person) => person.sharedById === value);
if (match) {
targetPerson.name = match.name;
targetPerson.birthDate = match.birthDate;
targetPerson.sharedById = match.sharedById;
}
};
const onViewAsAnotherUser = async () => {
const candidateIds = new Set(candidates.map(({ sharedById }) => sharedById));
const userId = await modalManager.show(PeopleFilterUserPicker, {
title: $t('user'),
selected: targetUserId,
users: person.sharedBy.filter(({ id }) => candidateIds.has(id)),
allowEmpty: false,
});
if (userId) {
onChange(userId);
}
};
onMount(() => {
onChange(targetUserId);
});
</script>
<FormModal
title={$t('person')}
title={$t('edit_person')}
size="small"
icon={mdiText}
icon={mdiPencilOutline}
{onClose}
{onSubmit}
submitText={$t('submit')}
disabled={!isWritable}
>
<Stack gap={6}>
{#if candidates.length > 1}
<div class="flex flex-col gap-2">
<Button
color="secondary"
size="small"
shape="round"
trailingIcon={mdiChevronDown}
onclick={onViewAsAnotherUser}
>
{$t('view_as', { values: { name: userNames.get(targetUserId) ?? targetUserId } })}
</Button>
{#if isWritable}
<Button
size="small"
color="secondary"
shape="round"
variant="ghost"
leadingIcon={mdiAccountMultipleOutline}
onclick={handleCopyFromMine}
>
{$t('copy_from_my_person')}
</Button>
{:else}
<Alert shape="rectangle" color="warning" size="small" icon={false} class="mt-4" title={$t('readonly_access')}>
<Text size="tiny">{$t('person_read_access_message')}</Text>
</Alert>
{/if}
</div>
{/if}
<Field label={$t('name')} disabled={!isWritable}>
<Input bind:value={targetPerson.name} />
<Field label={$t('name')}>
<Input bind:value={name} />
</Field>
<Field label={$t('date_of_birth')} disabled={!isWritable}>
<Field label={$t('date_of_birth')}>
<DatePicker
bind:value={
() => (targetPerson.birthDate ? DateTime.fromISO(targetPerson.birthDate) : undefined),
(value) => (targetPerson.birthDate = value?.toISO() ?? null)
() => (birthDate ? DateTime.fromISO(birthDate) : undefined), (value) => (birthDate = value?.toISO() ?? null)
}
maxDate={DateTime.now()}
/>
<HelperText>{$t('birthdate_set_description')}</HelperText>
</Field>
{#if candidates.length > 1 && isWritable}
<HStack fullWidth gap={4}>
<Checkbox id="apply-for-all-users-checkbox" color="secondary" size="small" bind:checked={applyToEveryone} />
<Label
label={$t('person_edit_change_for_all_users', {
values: {
people: new Intl.ListFormat($locale, { style: 'long' }).format(
candidates
.filter(({ sharedById }) => sharedById !== targetUserId)
.map(({ sharedById }) => userNames.get(sharedById) ?? sharedById),
),
},
})}
size="small"
for="apply-for-all-users-checkbox"
/>
</HStack>
{#if person.otherPeople.length > 0}
<div>
<HStack gap={4}>
<Checkbox id="only-for-me-checkbox" color="secondary" size="small" bind:checked={onlyForMe} />
<Label label={$t('person_edit_only_change_for_me')} size="small" for="only-for-me-checkbox" />
</HStack>
<HelperText>{$t('person_edit_only_change_for_me_description')}</HelperText>
</div>
{/if}
</Stack>
</FormModal>
+77 -35
View File
@@ -1,35 +1,50 @@
import {
addUsersToPeople,
getClusterGroupUsers,
PeopleUsersUpsertType,
PersonUserRole,
removeUsersFromPeople,
upsertPeopleUsers,
type PersonResponseDto,
type PersonUsersResponseDto,
type UserResponseDto,
} from '@immich/sdk';
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import { mdiCheck, mdiPlus, mdiTrashCanOutline } from '@mdi/js';
import { mdiAccountMultipleOutline, mdiCheck, mdiClose, mdiPlus, mdiShareOutline } from '@mdi/js';
import { type MessageFormatter } from 'svelte-i18n';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import AddUsersModal from '$lib/modals/AddUsersModal.svelte';
import PersonBulkShareModal from '$lib/modals/PersonBulkShareModal.svelte';
import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
export const getPeopleUserActions = ($t: MessageFormatter, users: UserResponseDto[], people?: PersonResponseDto[]) => {
const ManageAccess: ActionItem = {
title: $t('manage_person_access'),
icon: mdiAccountMultipleOutline,
$if: () => users.length > 0,
onAction: () => modalManager.show(PersonBulkShareModal, { users, people }),
};
return { ManageAccess };
};
export const getPersonUsersActions = (
$t: MessageFormatter,
person: PersonResponseDto,
personUsers: PersonUsersResponseDto,
sharedUsers: UserResponseDto[],
clusterGroupUsers: UserResponseDto[],
) => {
const excludedUserIds = [authManager.user.id, ...sharedUsers.map(({ id }) => id)];
const AddUsers: ActionItem = {
title: $t('add_user'),
icon: mdiPlus,
color: 'primary',
$if: () => clusterGroupUsers.some(({ id }) => !excludedUserIds.includes(id)),
onAction: () =>
modalManager.show(AddUsersModal, {
emptyMessage: $t('person_share_no_users'),
excludedUserIds: [authManager.user.id, ...personUsers.map(({ sharedWithId }) => sharedWithId)],
loadUsers: () => getClusterGroupUsers({ id: authManager.user.clusterGroupId }),
excludedUserIds,
loadUsers: () => Promise.resolve(clusterGroupUsers),
onAddUsers: (users: UserResponseDto[]) => handleSharePersonWithUsers(person, users),
}),
};
@@ -39,23 +54,72 @@ export const getPersonUsersActions = (
export const getPersonUserActions = ($t: MessageFormatter, person: PersonResponseDto, user: UserResponseDto) => {
const Delete: ActionItem = {
title: $t('delete_user'),
icon: mdiTrashCanOutline,
title: $t('stop_sharing_person_with_user'),
icon: mdiClose,
onAction: () => handleDeletePersonUser(person, user),
};
return { Delete };
const ShareBack: ActionItem = {
title: $t('share_back'),
icon: mdiShareOutline,
color: 'primary',
onAction: () => handleSharePersonWithUsers(person, [user]),
};
return { Delete, ShareBack };
};
export type BulkUpsertOptions = {
sharedWithIds: string[];
everyone?: boolean;
personIds?: string[];
removedIds?: string[];
};
export const handleBulkUpsert = async ({
sharedWithIds,
everyone = false,
personIds = [],
removedIds = [],
}: BulkUpsertOptions): Promise<boolean> => {
const $t = await getFormatter();
try {
if (everyone) {
await upsertPeopleUsers({
peopleUsersUpsertDto: { type: PeopleUsersUpsertType.Everyone, sharedWithIds, role: PersonUserRole.Admin },
});
} else {
if (personIds.length > 0) {
await upsertPeopleUsers({ peopleUsersUpsertDto: { personIds, sharedWithIds, role: PersonUserRole.Admin } });
}
if (removedIds.length > 0) {
await removeUsersFromPeople({
personUsersDeleteDto: removedIds.flatMap((personId) =>
sharedWithIds.map((sharedWithId) => ({ personId, sharedWithId })),
),
});
}
}
toastManager.primary({ icon: mdiCheck, title: $t('saved') });
return true;
} catch (error) {
handleError(error, $t('errors.something_went_wrong'));
return false;
}
};
export const handleSharePersonWithUsers = async (person: PersonResponseDto, users: UserResponseDto[]) => {
const $t = await getFormatter();
try {
await addUsersToPeople({
personUsersCreateDto: {
await upsertPeopleUsers({
peopleUsersUpsertDto: {
personIds: [person.id],
sharedWithIds: users.map(({ id }) => id),
role: PersonUserRole.Read,
role: PersonUserRole.Admin,
},
});
eventManager.emit('PersonShare', { personId: person.id });
@@ -65,28 +129,6 @@ export const handleSharePersonWithUsers = async (person: PersonResponseDto, user
}
};
export const handleUpdatePersonUserRole = async ({
personId,
userId,
role,
}: {
personId: string;
userId: string;
role: PersonUserRole;
}) => {
const $t = await getFormatter();
try {
await addUsersToPeople({
personUsersCreateDto: { personIds: [personId], sharedWithIds: [userId], role },
});
eventManager.emit('PersonUserUpdate', { personId, userId, role });
toastManager.primary({ icon: mdiCheck, title: $t('saved') });
} catch (error) {
handleError(error, $t('errors.something_went_wrong'));
}
};
export const handleDeletePersonUser = async (person: PersonResponseDto, user: UserResponseDto) => {
const $t = await getFormatter();
+24 -2
View File
@@ -9,6 +9,7 @@
import UserPageLayout from '$lib/components/layouts/UserPageLayout.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import { QueryParameter, SessionStorageKey } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte';
import PeopleFilterModal from '$lib/modals/PeopleFilterModal.svelte';
import PersonMergeSuggestionModal from '$lib/modals/PersonMergeSuggestionModal.svelte';
import { Route } from '$lib/route';
@@ -18,9 +19,17 @@
import { handlePromiseError } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { clearQueryParam } from '$lib/utils/navigation';
import { getPeopleUserActions } from '$lib/services/person-user.service';
import { handleUpdatePersonName } from '$lib/services/person.service';
import { getAllPeople, getPerson, searchPerson, type PersonResponseDto } from '@immich/sdk';
import { Button, Icon, IconButton, modalManager } from '@immich/ui';
import {
getAllPeople,
getClusterGroupUsers,
getPerson,
searchPerson,
type PersonResponseDto,
type UserResponseDto,
} from '@immich/sdk';
import { ActionButton, Button, Icon, IconButton, modalManager } from '@immich/ui';
import { mdiAccountOff, mdiEyeOutline, mdiTune } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
@@ -44,6 +53,17 @@
let innerHeight = $state(0);
let searchPeopleElement = $state<ReturnType<typeof SearchPeople>>();
let clusterGroupUsers: UserResponseDto[] = $state([]);
onMount(async () => {
try {
const users = await getClusterGroupUsers({ id: authManager.user.clusterGroupId });
clusterGroupUsers = users.filter(({ id }) => id !== authManager.user.id);
} catch (error) {
handleError(error, $t('errors.something_went_wrong'));
}
});
onMount(() => {
const getSearchedPeople = $page.url.searchParams.get(QueryParameter.SEARCHED_PEOPLE);
if (getSearchedPeople) {
@@ -191,6 +211,7 @@
};
let people = $derived(data.people.people);
const { ManageAccess } = $derived(getPeopleUserActions($t, clusterGroupUsers, nextPage ? undefined : people));
// hidden people are only shown when explicitly filtering for them
let visiblePeople = $derived(data.filter.isHidden ? people : people.filter((people) => !people.isHidden));
@@ -315,6 +336,7 @@
color="secondary">{$t('show_and_hide_people')}</Button
>
{/if}
<ActionButton action={ManageAccess} />
<IconButton
shape="round"
color="secondary"
@@ -37,7 +37,7 @@
import { getPeopleThumbnailUrl } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { normalizeSearchString } from '$lib/utils/string-utils';
import { AssetVisibility, PersonUserRole, searchPerson, updatePerson, type PersonResponseDto } from '@immich/sdk';
import { AssetVisibility, searchPerson, updatePerson, type PersonResponseDto } from '@immich/sdk';
import {
ActionButton,
CommandPaletteDefaultProvider,
@@ -403,27 +403,9 @@
/>
</div>
{#if altItems.length > 0}
{@const parts = new Intl.ListFormat($locale).formatToParts(
altItems.map(({ sharedById }) => sharedById),
)}
<p class="text-sm text-gray-500 dark:text-gray-400">
{#each parts as { type, value } (value)}
{#if type === 'element'}
{@const altItem = altItems.find(({ sharedById }) => sharedById === value)!}
{#if [PersonUserRole.Write, PersonUserRole.Admin].includes(altItem.role)}
<button
type="button"
onclick={() =>
modalManager.show(PersonEditModal, { person, targetUserId: altItem.sharedById })}
class="underline">{altItem.name}</button
>
{:else}
{altItem.name}
{/if}
{:else}
{value}
{/if}
{/each}
{$t('also_known_as')}
{new Intl.ListFormat($locale).format(altItems.map(({ name }) => name))}
</p>
{/if}
<p class="text-sm text-gray-500 dark:text-gray-400">
@@ -1,10 +1,12 @@
<script lang="ts">
import SettingAccordion from '$lib/components/shared-components/settings/SettingAccordion.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/SettingSwitch.svelte';
import UserAvatar from '$lib/components/shared-components/UserAvatar.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import ClusterGroupUserSelectionModal from '$lib/modals/ClusterGroupUserSelectionModal.svelte';
import ClusterGroupUsersModal from '$lib/modals/ClusterGroupUsersModal.svelte';
import PartnerSelectionModal from '$lib/modals/PartnerSelectionModal.svelte';
import { getPeopleUserActions } from '$lib/services/person-user.service';
import { handleError } from '$lib/utils/handle-error';
import {
acceptClusterGroupRequest,
@@ -26,7 +28,7 @@
type PartnerResponseDto,
type UserResponseDto,
} from '@immich/sdk';
import { Button, Card, CardBody, HStack, Icon, IconButton, modalManager, Text } from '@immich/ui';
import { ActionButton, Button, Card, CardBody, HStack, Icon, IconButton, modalManager, Text } from '@immich/ui';
import { mdiCheck, mdiClose } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
@@ -244,158 +246,166 @@
</script>
<section class="my-4">
<Text size="large" fontWeight="medium">{$t('cluster_group')}</Text>
<Text size="small" color="muted">{$t('cluster_group_description')}</Text>
<Card class="mt-4">
<CardBody>
{#each users as user, index (user.id)}
<div class="flex items-center justify-between gap-4" class:mt-4={index > 0}>
<div class="flex items-center gap-4">
<UserAvatar {user} size="md" />
<div class="text-start">
<p class="text-immich-fg dark:text-immich-dark-fg">
{user.name}
{#if user.id === authManager.user.id}
<span class="text-sm text-immich-fg/75 dark:text-immich-dark-fg/75">({$t('you')})</span>
{/if}
</p>
<p class="text-sm text-immich-fg/75 dark:text-immich-dark-fg/75">{user.email}</p>
</div>
</div>
{#if user.id === authManager.user.id && canLeave}
<Button shape="round" size="small" color="secondary" onclick={() => handleLeave()}>
{$t('leave')}
</Button>
{/if}
</div>
{/each}
</CardBody>
</Card>
{#if sentRequests.length > 0 || receivedRequests.length > 0}
<div class="mt-4">
<Text size="small" fontWeight="medium">{$t('pending')}</Text>
</div>
<Card color="secondary" class="mt-2">
<CardBody>
{#each receivedRequests as request, index (request.id)}
<div class="flex items-center justify-between gap-4" class:mt-4={index > 0}>
<Text size="small">{$t('request_received_description')}</Text>
<div class="flex gap-2">
<Button shape="round" size="small" color="secondary" onclick={() => handleViewGroup(request)}>
{$t('view_group')}
</Button>
</div>
</div>
{/each}
{#each sentRequests as request, index (request.id)}
{@const user = candidates[request.userId]}
<div class="flex items-center justify-between gap-4" class:mt-4={index > 0 || receivedRequests.length > 0}>
<div class="flex items-center gap-4">
{#if user}
<div class="flex flex-col sm:ms-4 md:ms-8">
<SettingAccordion key="cluster-group" title={$t('people_cluster_group')} subtitle={$t('cluster_group_description')}>
<Card class="mt-4">
<CardBody>
{#each users as user, index (user.id)}
{@const { ManageAccess } = getPeopleUserActions($t, [
user,
...users.filter(({ id }) => id !== authManager.user.id && id !== user.id),
])}
<div class="flex items-center justify-between gap-4" class:mt-4={index > 0}>
<div class="flex items-center gap-4">
<UserAvatar {user} size="md" />
{/if}
<div class="text-start">
<p class="text-immich-fg dark:text-immich-dark-fg">{user?.name ?? request.userId}</p>
<p class="text-sm text-immich-fg/75 dark:text-immich-dark-fg/75">{user?.email ?? ''}</p>
<div class="text-start">
<p class="text-immich-fg dark:text-immich-dark-fg">
{user.name}
{#if user.id === authManager.user.id}
<span class="text-sm text-immich-fg/75 dark:text-immich-dark-fg/75">({$t('you')})</span>
{/if}
</p>
<p class="text-sm text-immich-fg/75 dark:text-immich-dark-fg/75">{user.email}</p>
</div>
</div>
{#if user.id === authManager.user.id && canLeave}
<Button shape="round" size="small" color="secondary" onclick={() => handleLeave()}>
{$t('leave')}
</Button>
{:else if user.id !== authManager.user.id}
<ActionButton type="icon" variant="filled" action={ManageAccess} />
{/if}
</div>
{/each}
</CardBody>
</Card>
{#if sentRequests.length > 0 || receivedRequests.length > 0}
<div class="mt-4">
<Text size="small" fontWeight="medium">{$t('pending')}</Text>
</div>
<Card color="secondary" class="mt-2">
<CardBody>
{#each receivedRequests as request, index (request.id)}
<div class="flex items-center justify-between gap-4" class:mt-4={index > 0}>
<Text size="small">{$t('request_received_description')}</Text>
<div class="flex gap-2">
<Button shape="round" size="small" color="secondary" onclick={() => handleViewGroup(request)}>
{$t('view_group')}
</Button>
</div>
</div>
{/each}
{#each sentRequests as request, index (request.id)}
{@const user = candidates[request.userId]}
<div
class="flex items-center justify-between gap-4"
class:mt-4={index > 0 || receivedRequests.length > 0}
>
<div class="flex items-center gap-4">
{#if user}
<UserAvatar {user} size="md" />
{/if}
<div class="text-start">
<p class="text-immich-fg dark:text-immich-dark-fg">{user?.name ?? request.userId}</p>
<p class="text-sm text-immich-fg/75 dark:text-immich-dark-fg/75">{user?.email ?? ''}</p>
</div>
</div>
<Button shape="round" size="small" color="secondary" onclick={() => handleDeleteRequest(request)}>
{$t('cancel')}
</Button>
</div>
{/each}
</CardBody>
</Card>
{/if}
<HStack fullWidth class="mt-5 justify-end">
<Button shape="round" size="small" onclick={() => handleRerunFacialRecognition()}
>{$t('cluster_group_facial_recognition')}</Button
>
<Button shape="round" size="small" onclick={() => handleAddUsers()}>{$t('add_user')}</Button>
</HStack>
</SettingAccordion>
<SettingAccordion key="partners" title={$t('partners')} subtitle={$t('partners_description')}>
{#if partners.length > 0}
{#each partners as partner (partner.user.id)}
<div class="mt-6 rounded-2xl border border-gray-200 bg-slate-50 p-5 dark:border-gray-800 dark:bg-gray-900">
<div class="flex justify-between gap-4 rounded-lg pb-4 transition-all">
<div class="flex gap-4">
<UserAvatar user={partner.user} size="md" />
<div class="text-start">
<p class="text-immich-fg dark:text-immich-dark-fg">
{partner.user.name}
</p>
<p class="text-sm text-immich-fg/75 dark:text-immich-dark-fg/75">
{partner.user.email}
</p>
</div>
</div>
{#if partner.sharedByMe}
<IconButton
shape="round"
color="secondary"
variant="ghost"
onclick={() => handleRemovePartner(partner.user)}
icon={mdiClose}
size="small"
aria-label={$t('stop_sharing_photos_with_user')}
/>
{/if}
</div>
<Button shape="round" size="small" color="secondary" onclick={() => handleDeleteRequest(request)}>
{$t('cancel')}
</Button>
<div class="text-immich-dark-gray dark:text-gray-200">
<!-- I am sharing my assets with this user -->
{#if partner.sharedByMe}
<hr class="my-4 border border-gray-200 dark:border-gray-700" />
<Text class="my-4" size="small" fontWeight="medium">
{$t('shared_with_partner', { values: { partner: partner.user.name } })}
</Text>
<Text size="tiny" fontWeight="medium"
>{$t('partner_can_access', { values: { partner: partner.user.name } })}</Text
>
<ul class="text-sm">
<li class="mt-2 flex place-items-center gap-2 py-1">
<Icon icon={mdiCheck} />
{$t('partner_can_access_assets')}
</li>
<li class="flex place-items-center gap-2 py-1">
<Icon icon={mdiCheck} />
{$t('partner_can_access_location')}
</li>
</ul>
{/if}
<!-- this user is sharing assets with me -->
{#if partner.sharedWithMe}
<hr class="my-4 border border-gray-200 dark:border-gray-700" />
<Text class="my-4" size="small" fontWeight="medium">
{$t('shared_from_partner', { values: { partner: partner.user.name } })}
</Text>
<SettingSwitch
title={$t('show_in_timeline')}
subtitle={$t('show_in_timeline_setting_description')}
bind:checked={partner.inTimeline}
onToggle={(isChecked) => handleShowOnTimelineChanged(partner, isChecked)}
/>
{/if}
</div>
</div>
{/each}
</CardBody>
</Card>
{/if}
{/if}
<HStack fullWidth class="mt-5 justify-end">
<Button shape="round" size="small" onclick={() => handleRerunFacialRecognition()}
>{$t('cluster_group_facial_recognition')}</Button
>
<Button shape="round" size="small" onclick={() => handleAddUsers()}>{$t('add_user')}</Button>
</HStack>
</section>
<section class="my-4">
<Text size="large" fontWeight="medium">{$t('partners')}</Text>
{#if partners.length > 0}
{#each partners as partner (partner.user.id)}
<div class="mt-6 rounded-2xl border border-gray-200 bg-slate-50 p-5 dark:border-gray-800 dark:bg-gray-900">
<div class="flex justify-between gap-4 rounded-lg pb-4 transition-all">
<div class="flex gap-4">
<UserAvatar user={partner.user} size="md" />
<div class="text-start">
<p class="text-immich-fg dark:text-immich-dark-fg">
{partner.user.name}
</p>
<p class="text-sm text-immich-fg/75 dark:text-immich-dark-fg/75">
{partner.user.email}
</p>
</div>
</div>
{#if partner.sharedByMe}
<IconButton
shape="round"
color="secondary"
variant="ghost"
onclick={() => handleRemovePartner(partner.user)}
icon={mdiClose}
size="small"
aria-label={$t('stop_sharing_photos_with_user')}
/>
{/if}
</div>
<div class="text-immich-dark-gray dark:text-gray-200">
<!-- I am sharing my assets with this user -->
{#if partner.sharedByMe}
<hr class="my-4 border border-gray-200 dark:border-gray-700" />
<Text class="my-4" size="small" fontWeight="medium">
{$t('shared_with_partner', { values: { partner: partner.user.name } })}
</Text>
<Text size="tiny" fontWeight="medium"
>{$t('partner_can_access', { values: { partner: partner.user.name } })}</Text
>
<ul class="text-sm">
<li class="mt-2 flex place-items-center gap-2 py-1">
<Icon icon={mdiCheck} />
{$t('partner_can_access_assets')}
</li>
<li class="flex place-items-center gap-2 py-1">
<Icon icon={mdiCheck} />
{$t('partner_can_access_location')}
</li>
</ul>
{/if}
<!-- this user is sharing assets with me -->
{#if partner.sharedWithMe}
<hr class="my-4 border border-gray-200 dark:border-gray-700" />
<Text class="my-4" size="small" fontWeight="medium">
{$t('shared_from_partner', { values: { partner: partner.user.name } })}
</Text>
<SettingSwitch
title={$t('show_in_timeline')}
subtitle={$t('show_in_timeline_setting_description')}
bind:checked={partner.inTimeline}
onToggle={(isChecked) => handleShowOnTimelineChanged(partner, isChecked)}
/>
{/if}
</div>
<div class="mt-5 flex justify-end">
<Button shape="round" size="small" onclick={() => handleCreatePartners()}>{$t('add_partner')}</Button>
</div>
{/each}
{/if}
<div class="mt-5 flex justify-end">
<Button shape="round" size="small" onclick={() => handleCreatePartners()}>{$t('add_partner')}</Button>
</SettingAccordion>
</div>
</section>