feat: bulk person access

This commit is contained in:
Jason Rasmussen
2026-09-29 18:15:30 -04:00
parent f77847dda0
commit c0e2937826
4 changed files with 311 additions and 0 deletions
+9
View File
@@ -23,6 +23,7 @@
"add_exclusion_pattern": "Add exclusion pattern",
"add_location": "Add location",
"add_partner": "Add partner",
"add_people": "Add people",
"add_photos": "Add photos",
"add_step": "Add step",
"add_tag": "Add tag",
@@ -769,6 +770,7 @@
"common_create_new_album": "Create new album",
"completed": "Completed",
"configuration": "Configuration",
"configure": "Configure",
"confirm": "Confirm",
"confirm_admin_password": "Confirm Admin Password",
"confirm_delete_face": "Are you sure you want to delete {name} face from the asset?",
@@ -1678,6 +1680,9 @@
"person_hidden": "{name}{hidden, select, true { (hidden)} other {}}",
"person_read_access_message": "You only have read access to this person.",
"person_recognized": "Person recognized",
"person_role_admin": "Admin",
"person_role_read": "Read",
"person_role_write": "Write",
"person_share_no_users": "Every user in your cluster group already has access to this person.",
"person_update_strategy": "Apply changes to",
"person_update_strategy_description": "Which people to update when you change a name or date of birth",
@@ -1962,6 +1967,7 @@
"selected": "Selected",
"selected_count": "{count, plural, other {# selected}}",
"selected_gps_coordinates": "Selected GPS Coordinates",
"selected_with_count": "Selected ({count})",
"send_message": "Send message",
"send_welcome_email": "Send welcome email",
"server_endpoint": "Server Endpoint",
@@ -2005,8 +2011,11 @@
"setup_pin_code": "Setup a PIN code",
"share": "Share",
"share_dialog_preparing": "Preparing...",
"share_everyone": "Share everyone",
"share_everyone_description": "Give {user} access to all of your people.",
"share_link": "Share Link",
"share_original": "Use original (large)",
"share_people_with_user": "Share people with {user}",
"share_preview": "Use thumbnail (small)",
"share_quality_body": "Press and hold the share button to choose the image quality before you share.",
"share_quality_title": "Choose your share quality",
@@ -0,0 +1,96 @@
<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 { Button, HStack, ListButton, Modal, ModalBody, ModalFooter, Text } from '@immich/ui';
import { t } from 'svelte-i18n';
type Props = {
people: PersonResponseDto[];
onClose: (people?: PersonResponseDto[]) => void;
};
let { people, onClose }: Props = $props();
let searchName = $state('');
let selectedPeople: PersonResponseDto[] = $state([]);
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 isSelected = (person: PersonResponseDto) => selectedPeople.some(({ id }) => id === person.id);
const selectPerson = (person: PersonResponseDto) => {
selectedPeople = selectedPeople.some(({ id }) => id === person.id)
? selectedPeople.filter(({ id }) => id !== person.id)
: [...selectedPeople, person];
};
</script>
<Modal title={$t('add_people')} {onClose} size="small">
<ModalBody>
{#if people.length > 0}
<div class="flex flex-col gap-4">
<SearchBar bind:name={searchName} placeholder={$t('search_people')} showLoadingSpinner={false} />
<div class="flex max-h-75 immich-scrollbar flex-col gap-2 overflow-y-auto">
{#each namedPeople as person (person.id)}
<ListButton onclick={() => selectPerson(person)} selected={isSelected(person)}>
<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 {isSelected(person)
? '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={() => (selectedPeople = people)}
disabled={selectedPeople.length === people.length}
>
{$t('select_all')}
</Button>
<Button
shape="round"
fullWidth
onclick={() => onClose(selectedPeople)}
disabled={selectedPeople.length === 0}
>
{$t('add')}
</Button>
</HStack>
</ModalFooter>
{:else}
<Text color="muted">{$t('no_people_found')}</Text>
{/if}
</ModalBody>
</Modal>
@@ -0,0 +1,196 @@
<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 { getPeopleThumbnailUrl } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import {
addUsersToPeople,
getAllPeople,
getUsersForPeople,
PersonUserRole,
removeUsersFromPeople,
type PersonResponseDto,
type UserResponseDto,
} from '@immich/sdk';
import {
Button,
Field,
FormModal,
IconButton,
LoadingSpinner,
modalManager,
Select,
Switch,
Text,
toastManager,
} from '@immich/ui';
import { mdiCheck, mdiClose, mdiPlus } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
type Props = {
user: UserResponseDto;
onClose: (saved?: boolean) => void;
};
let { user, onClose }: Props = $props();
let loading = $state(true);
let saving = $state(false);
let shareEveryone = $state(false);
let role = $state<PersonUserRole>(PersonUserRole.Read);
let people: PersonResponseDto[] = $state([]);
const existingRoles = new SvelteMap<string, PersonUserRole>();
const selectedIds = new SvelteSet<string>();
const roleOptions = $derived([
{ label: $t('person_role_read'), value: PersonUserRole.Read },
{ label: $t('person_role_write'), value: PersonUserRole.Write },
{ label: $t('person_role_admin'), value: PersonUserRole.Admin },
]);
const selectedPeople = $derived(people.filter(({ id }) => selectedIds.has(id)));
const availablePeople = $derived(people.filter(({ id }) => !selectedIds.has(id)));
const canSubmit = $derived(!loading && !saving && (shareEveryone || selectedIds.size > 0 || existingRoles.size > 0));
onMount(async () => {
try {
const [shares, { people: allPeople }] = await Promise.all([
getUsersForPeople({ sharedById: authManager.user.id, sharedWithId: user.id }),
getAllPeople({ withHidden: false }),
]);
people = allPeople;
for (const share of shares) {
existingRoles.set(share.personId, share.role);
selectedIds.add(share.personId);
}
role = shares[0]?.role ?? PersonUserRole.Read;
} catch (error) {
handleError(error, $t('errors.something_went_wrong'));
} finally {
loading = false;
}
});
const handleAddPeople = async () => {
const added = await modalManager.show(PeopleSelectionModal, { people: availablePeople });
for (const { id } of added ?? []) {
selectedIds.add(id);
}
};
const onSubmit = async () => {
saving = true;
try {
if (shareEveryone) {
await addUsersToPeople({ personUsersCreateDto: { sharedWithIds: [user.id], role } });
} else {
const personIds = [...selectedIds].filter((id) => existingRoles.get(id) !== role);
const removed = [...existingRoles.keys()].filter((id) => !selectedIds.has(id));
if (personIds.length > 0) {
await addUsersToPeople({ personUsersCreateDto: { personIds, sharedWithIds: [user.id], role } });
}
if (removed.length > 0) {
await removeUsersFromPeople({
personUsersDeleteDto: removed.map((personId) => ({ personId, sharedWithId: user.id })),
});
}
}
toastManager.primary({ icon: mdiCheck, title: $t('saved') });
onClose(true);
} catch (error) {
handleError(error, $t('errors.something_went_wrong'));
} finally {
saving = false;
}
};
</script>
<FormModal
title={$t('share_people_with_user', { values: { user: user.name } })}
submitText={$t('save')}
size="small"
disabled={!canSubmit}
{onSubmit}
onClose={() => onClose()}
>
{#if loading}
<div class="flex w-full place-content-center place-items-center">
<LoadingSpinner />
</div>
{:else}
<div class="flex flex-col gap-4">
<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>
<Field label={$t('role')}>
<Select bind:value={role} options={roleOptions} />
</Field>
<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('selected_with_count', { values: { count: selectedIds.size } })}
</Text>
<Button
shape="semi-round"
size="small"
color="secondary"
variant="filled"
leadingIcon={mdiPlus}
disabled={availablePeople.length === 0}
onclick={handleAddPeople}
>
{$t('add_people')}
</Button>
</div>
<div class="max-h-64 immich-scrollbar overflow-y-auto">
{#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>
<IconButton
shape="round"
size="small"
color="secondary"
variant="ghost"
icon={mdiClose}
aria-label={$t('remove')}
onclick={() => selectedIds.delete(person.id)}
/>
</div>
{:else}
<Text size="small" color="muted" class="p-2">{$t('no_people_found')}</Text>
{/each}
</div>
</div>
{/if}
</div>
{/if}
</FormModal>
@@ -5,6 +5,7 @@
import ClusterGroupUserSelectionModal from '$lib/modals/ClusterGroupUserSelectionModal.svelte';
import ClusterGroupUsersModal from '$lib/modals/ClusterGroupUsersModal.svelte';
import PartnerSelectionModal from '$lib/modals/PartnerSelectionModal.svelte';
import PersonBulkShareModal from '$lib/modals/PersonBulkShareModal.svelte';
import { handleError } from '$lib/utils/handle-error';
import {
acceptClusterGroupRequest,
@@ -268,6 +269,15 @@
<Button shape="round" size="small" color="secondary" onclick={() => handleLeave()}>
{$t('leave')}
</Button>
{:else if user.id !== authManager.user.id}
<Button
shape="round"
size="small"
color="secondary"
onclick={() => modalManager.show(PersonBulkShareModal, { user })}
>
{$t('configure')}
</Button>
{/if}
</div>
{/each}