Files
Immich/web/src/routes/(user)/user-settings/SharingSettings.svelte
T

402 lines
13 KiB
Svelte

<script lang="ts">
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 { handleError } from '$lib/utils/handle-error';
import {
acceptClusterGroupRequest,
clusterGroupRegeneratePeople,
createClusterGroupRequest,
createPartner,
deleteClusterGroupRequest,
getClusterGroupRequests,
getClusterGroupRequestsForGroup,
getClusterGroupUsers,
getMyUser,
getPartners,
leaveClusterGroup,
PartnerDirection,
removePartner,
searchUsers,
updatePartner,
type ClusterGroupRequestResponseDto,
type PartnerResponseDto,
type UserResponseDto,
} from '@immich/sdk';
import { 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';
type PartnerSharing = {
user: UserResponseDto;
sharedByMe: boolean;
sharedWithMe: boolean;
inTimeline: boolean;
};
let clusterGroupId: string = $state('');
let users: UserResponseDto[] = $state([]);
let sentRequests: ClusterGroupRequestResponseDto[] = $state([]);
let receivedRequests: ClusterGroupRequestResponseDto[] = $state([]);
// a request is sent to someone outside of the group, so they come from elsewhere
let candidates: Record<string, UserResponseDto> = $state({});
let partners: Array<PartnerSharing> = $state([]);
const canLeave = $derived(users.length > 1);
onMount(async () => {
await Promise.all([refresh(), refreshPartners()]);
});
const refresh = async () => {
try {
const { clusterGroupId: id } = await getMyUser();
clusterGroupId = id;
const [groupUsers, sent, received, allUsers] = await Promise.all([
getClusterGroupUsers({ id }),
getClusterGroupRequestsForGroup({ id }),
getClusterGroupRequests(),
searchUsers(),
]);
users = groupUsers;
sentRequests = sent;
receivedRequests = received;
candidates = Object.fromEntries(allUsers.map((user) => [user.id, user]));
} catch (error) {
handleError(error, $t('errors.unable_to_load_cluster_group'));
}
};
const handleAddUsers = async () => {
const excludedUserIds = [...users.map(({ id }) => id), ...sentRequests.map(({ userId }) => userId)];
const selected = await modalManager.show(ClusterGroupUserSelectionModal, { excludedUserIds });
if (!selected) {
return;
}
try {
for (const user of selected) {
await createClusterGroupRequest({ id: clusterGroupId, clusterGroupRequestCreateDto: { userId: user.id } });
}
await refresh();
} catch (error) {
handleError(error, $t('errors.something_went_wrong'));
}
};
const handleViewGroup = async (request: ClusterGroupRequestResponseDto) => {
const result = await modalManager.show(ClusterGroupUsersModal, { clusterGroupId: request.clusterGroupId });
if (result === 'accept') {
await handleAcceptRequest(request);
} else if (result === 'decline') {
await handleDeleteRequest(request);
}
};
const handleAcceptRequest = async (request: ClusterGroupRequestResponseDto) => {
try {
await acceptClusterGroupRequest({ id: request.id });
await refresh();
} catch (error) {
handleError(error, $t('errors.something_went_wrong'));
}
};
const handleDeleteRequest = async (request: ClusterGroupRequestResponseDto) => {
try {
await deleteClusterGroupRequest({ id: request.id });
await refresh();
} catch (error) {
handleError(error, $t('errors.something_went_wrong'));
}
};
const handleLeave = async () => {
const isConfirmed = await modalManager.showDialog({
title: $t('leave_group'),
prompt: $t('leave_group_description'),
});
if (!isConfirmed) {
return;
}
try {
await leaveClusterGroup({ id: clusterGroupId });
await refresh();
} catch (error) {
handleError(error, $t('errors.unable_to_leave_cluster_group'));
}
};
const refreshPartners = async () => {
partners = [];
const [sharedBy, sharedWith] = await Promise.all([
getPartners({ direction: PartnerDirection.SharedBy }),
getPartners({ direction: PartnerDirection.SharedWith }),
]);
for (const candidate of sharedBy) {
partners = [
...partners,
{
user: candidate,
sharedByMe: true,
sharedWithMe: false,
inTimeline: candidate.inTimeline ?? false,
},
];
}
for (const candidate of sharedWith) {
const existIndex = partners.findIndex((p) => candidate.id === p.user.id);
if (existIndex === -1) {
partners = [
...partners,
{
user: candidate,
sharedByMe: false,
sharedWithMe: true,
inTimeline: candidate.inTimeline ?? false,
},
];
} else {
partners[existIndex].sharedWithMe = true;
partners[existIndex].inTimeline = candidate.inTimeline ?? false;
}
}
};
const handleRemovePartner = async (partner: PartnerResponseDto) => {
const isConfirmed = await modalManager.showDialog({
title: $t('stop_photo_sharing'),
prompt: $t('stop_photo_sharing_description', { values: { partner: partner.name } }),
});
if (!isConfirmed) {
return;
}
try {
await removePartner({ id: partner.id });
await refreshPartners();
} catch (error) {
handleError(error, $t('errors.unable_to_remove_partner'));
}
};
const handleCreatePartners = async () => {
const users = await modalManager.show(PartnerSelectionModal, {});
if (!users) {
return;
}
try {
for (const user of users) {
await createPartner({ partnerCreateDto: { sharedWithId: user.id } });
}
await refreshPartners();
} catch (error) {
handleError(error, $t('errors.unable_to_add_partners'));
}
};
const handleShowOnTimelineChanged = async (partner: PartnerSharing, inTimeline: boolean) => {
try {
await updatePartner({ id: partner.user.id, partnerUpdateDto: { inTimeline } });
partner.inTimeline = inTimeline;
} catch (error) {
handleError(error, $t('errors.unable_to_update_timeline_display_status'));
}
};
const handleRerunFacialRecognition = async () => {
const confirmed = await modalManager.showDialog({
title: $t('cluster_group_facial_recognition'),
prompt: $t('cluster_group_facial_recognition_prompt'),
size: 'medium',
});
if (!confirmed) {
return;
}
try {
await clusterGroupRegeneratePeople({ id: clusterGroupId });
} catch (error) {
handleError(error, $t('errors.something_went_wrong'));
}
};
</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}
<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>
</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>
{/each}
{/if}
<div class="mt-5 flex justify-end">
<Button shape="round" size="small" onclick={() => handleCreatePartners()}>{$t('add_partner')}</Button>
</div>
</section>