mirror of
https://github.com/immich-app/immich.git
synced 2026-09-30 13:23:21 +08:00
feat: bulk person access
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user