mirror of
https://github.com/immich-app/immich.git
synced 2026-01-15 00:20:38 -05:00
refactor: album share and options modals (#25212)
* refactor: album share modals * chore: clean up --------- Co-authored-by: Jason Rasmussen <jason@rasm.me>
This commit is contained in:
parent
2190921c85
commit
56dfdfd033
@ -32,7 +32,7 @@
|
||||
.filter(Boolean)
|
||||
.join(' • ');
|
||||
|
||||
const { ViewQrCode, Copy } = $derived(getSharedLinkActions($t, sharedLink));
|
||||
const { ViewQrCode, Copy, Delete } = $derived(getSharedLinkActions($t, sharedLink));
|
||||
</script>
|
||||
|
||||
<div class="flex justify-between items-center">
|
||||
@ -43,5 +43,6 @@
|
||||
<div class="flex">
|
||||
<ActionButton action={ViewQrCode} />
|
||||
<ActionButton action={Copy} />
|
||||
<ActionButton action={Delete} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
import RightClickContextMenu from '$lib/components/shared-components/context-menu/right-click-context-menu.svelte';
|
||||
import AlbumEditModal from '$lib/modals/AlbumEditModal.svelte';
|
||||
import AlbumShareModal from '$lib/modals/AlbumShareModal.svelte';
|
||||
import AlbumOptionsModal from '$lib/modals/AlbumOptionsModal.svelte';
|
||||
import { handleDeleteAlbum, handleDownloadAlbum } from '$lib/services/album.service';
|
||||
import {
|
||||
AlbumFilter,
|
||||
@ -202,7 +202,7 @@
|
||||
}
|
||||
|
||||
case 'share': {
|
||||
await modalManager.show(AlbumShareModal, { album: selectedAlbum });
|
||||
await modalManager.show(AlbumOptionsModal, { album: selectedAlbum });
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@ import type { ReleaseEvent } from '$lib/types';
|
||||
import type { TreeNode } from '$lib/utils/tree-utils';
|
||||
import type {
|
||||
AlbumResponseDto,
|
||||
AlbumUserRole,
|
||||
ApiKeyResponseDto,
|
||||
AssetResponseDto,
|
||||
LibraryResponseDto,
|
||||
@ -39,6 +40,8 @@ export type Events = {
|
||||
AlbumUpdate: [AlbumResponseDto];
|
||||
AlbumDelete: [AlbumResponseDto];
|
||||
AlbumShare: [];
|
||||
AlbumUserUpdate: [{ albumId: string; userId: string; role: AlbumUserRole }];
|
||||
AlbumUserDelete: [{ albumId: string; userId: string }];
|
||||
|
||||
PersonUpdate: [PersonResponseDto];
|
||||
|
||||
|
||||
56
web/src/lib/modals/AlbumAddUsersModal.svelte
Normal file
56
web/src/lib/modals/AlbumAddUsersModal.svelte
Normal file
@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
|
||||
import { handleAddUsersToAlbum } from '$lib/services/album.service';
|
||||
import { searchUsers, type AlbumResponseDto, type UserResponseDto } from '@immich/sdk';
|
||||
import { FormModal, ListButton, Stack, Text } from '@immich/ui';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
|
||||
type Props = {
|
||||
album: AlbumResponseDto;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const { album, onClose }: Props = $props();
|
||||
|
||||
let users: UserResponseDto[] = $state([]);
|
||||
const excludedUserIds = $derived([album.ownerId, ...album.albumUsers.map(({ user: { id } }) => id)]);
|
||||
const filteredUsers = $derived(users.filter(({ id }) => !excludedUserIds.includes(id)));
|
||||
const selectedUsers = new SvelteMap<string, UserResponseDto>();
|
||||
|
||||
const handleToggle = (user: UserResponseDto) => {
|
||||
if (selectedUsers.has(user.id)) {
|
||||
selectedUsers.delete(user.id);
|
||||
} else {
|
||||
selectedUsers.set(user.id, user);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
const success = await handleAddUsersToAlbum(album, [...selectedUsers.values()]);
|
||||
if (success) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
users = await searchUsers();
|
||||
});
|
||||
</script>
|
||||
|
||||
<FormModal title={$t('users')} submitText={$t('add')} {onSubmit} disabled={selectedUsers.size === 0} {onClose}>
|
||||
<Stack>
|
||||
{#each filteredUsers as user (user.id)}
|
||||
<ListButton selected={selectedUsers.has(user.id)} onclick={() => handleToggle(user)}>
|
||||
<UserAvatar {user} size="md" />
|
||||
<div class="text-start grow">
|
||||
<Text>{user.name}</Text>
|
||||
<Text size="small">{user.email}</Text>
|
||||
</div>
|
||||
</ListButton>
|
||||
{:else}
|
||||
<Text>{$t('album_share_no_users')}</Text>
|
||||
{/each}
|
||||
</Stack>
|
||||
</FormModal>
|
||||
@ -1,184 +1,163 @@
|
||||
<script lang="ts">
|
||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import AlbumSharedLink from '$lib/components/album-page/album-shared-link.svelte';
|
||||
import HeaderActionButton from '$lib/components/HeaderActionButton.svelte';
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
|
||||
import type { RenderedOption } from '$lib/elements/Dropdown.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import {
|
||||
getAlbumActions,
|
||||
handleRemoveUserFromAlbum,
|
||||
handleUpdateAlbum,
|
||||
handleUpdateUserAlbumRole,
|
||||
} from '$lib/services/album.service';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import {
|
||||
AlbumUserRole,
|
||||
AssetOrder,
|
||||
removeUserFromAlbum,
|
||||
updateAlbumInfo,
|
||||
updateAlbumUser,
|
||||
getAlbumInfo,
|
||||
getAllSharedLinks,
|
||||
type AlbumResponseDto,
|
||||
type SharedLinkResponseDto,
|
||||
type UserResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { Icon, Modal, ModalBody, modalManager, toastManager } from '@immich/ui';
|
||||
import { mdiArrowDownThin, mdiArrowUpThin, mdiDotsVertical, mdiPlus } from '@mdi/js';
|
||||
import { findKey } from 'lodash-es';
|
||||
import { Field, Heading, HStack, Modal, ModalBody, Select, Stack, Switch, Text } from '@immich/ui';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import SettingDropdown from '../components/shared-components/settings/setting-dropdown.svelte';
|
||||
|
||||
interface Props {
|
||||
type Props = {
|
||||
album: AlbumResponseDto;
|
||||
order: AssetOrder | undefined;
|
||||
user: UserResponseDto;
|
||||
onClose: (
|
||||
result?: { action: 'changeOrder'; order: AssetOrder } | { action: 'shareUser' } | { action: 'refreshAlbum' },
|
||||
) => void;
|
||||
}
|
||||
|
||||
let { album, order, user, onClose }: Props = $props();
|
||||
|
||||
const options: Record<AssetOrder, RenderedOption> = {
|
||||
[AssetOrder.Asc]: { icon: mdiArrowUpThin, title: $t('oldest_first') },
|
||||
[AssetOrder.Desc]: { icon: mdiArrowDownThin, title: $t('newest_first') },
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
let selectedOption = $derived(order ? options[order] : options[AssetOrder.Desc]);
|
||||
let { album, onClose }: Props = $props();
|
||||
|
||||
const handleToggleOrder = async (returnedOption: RenderedOption): Promise<void> => {
|
||||
if (selectedOption === returnedOption) {
|
||||
return;
|
||||
}
|
||||
let order: AssetOrder = AssetOrder.Desc;
|
||||
order = findKey(options, (option) => option === returnedOption) as AssetOrder;
|
||||
const orderOptions = [
|
||||
{ label: $t('newest_first'), value: AssetOrder.Desc },
|
||||
{ label: $t('oldest_first'), value: AssetOrder.Asc },
|
||||
];
|
||||
|
||||
try {
|
||||
await updateAlbumInfo({
|
||||
id: album.id,
|
||||
updateAlbumDto: {
|
||||
order,
|
||||
},
|
||||
});
|
||||
onClose({ action: 'changeOrder', order });
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_save_album'));
|
||||
}
|
||||
};
|
||||
const roleOptions: Array<{ label: string; value: AlbumUserRole | 'none'; icon?: string }> = [
|
||||
{ label: $t('role_editor'), value: AlbumUserRole.Editor },
|
||||
{ label: $t('role_viewer'), value: AlbumUserRole.Viewer },
|
||||
{ label: $t('remove_user'), value: 'none' },
|
||||
];
|
||||
|
||||
const handleToggleActivity = async () => {
|
||||
try {
|
||||
album = await updateAlbumInfo({
|
||||
id: album.id,
|
||||
updateAlbumDto: {
|
||||
isActivityEnabled: !album.isActivityEnabled,
|
||||
},
|
||||
});
|
||||
const selectedOrderOption = $derived(
|
||||
album.order ? orderOptions.find(({ value }) => value === album.order) : orderOptions[0],
|
||||
);
|
||||
|
||||
toastManager.success($t('activity_changed', { values: { enabled: album.isActivityEnabled } }));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.cant_change_activity', { values: { enabled: album.isActivityEnabled } }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveUser = async (user: UserResponseDto): Promise<void> => {
|
||||
const confirmed = await modalManager.showDialog({
|
||||
title: $t('album_remove_user'),
|
||||
prompt: $t('album_remove_user_confirmation', { values: { user: user.name } }),
|
||||
confirmText: $t('remove_user'),
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
const handleRoleSelect = async (user: UserResponseDto, role: AlbumUserRole | 'none') => {
|
||||
if (role === 'none') {
|
||||
await handleRemoveUserFromAlbum(album, user);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await removeUserFromAlbum({ id: album.id, userId: user.id });
|
||||
onClose({ action: 'refreshAlbum' });
|
||||
toastManager.success($t('album_user_removed', { values: { user: user.name } }));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_remove_album_users'));
|
||||
}
|
||||
await handleUpdateUserAlbumRole({ albumId: album.id, userId: user.id, role });
|
||||
};
|
||||
|
||||
const handleUpdateSharedUserRole = async (user: UserResponseDto, role: AlbumUserRole) => {
|
||||
try {
|
||||
await updateAlbumUser({ id: album.id, userId: user.id, updateAlbumUserDto: { role } });
|
||||
const message = $t('user_role_set', {
|
||||
values: { user: user.name, role: role == AlbumUserRole.Viewer ? $t('role_viewer') : $t('role_editor') },
|
||||
});
|
||||
onClose({ action: 'refreshAlbum' });
|
||||
toastManager.success(message);
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_change_album_user_role'));
|
||||
}
|
||||
const refreshAlbum = async () => {
|
||||
album = await getAlbumInfo({ id: album.id, withoutAssets: true });
|
||||
};
|
||||
|
||||
const onAlbumUserDelete = async ({ userId }: { userId: string }) => {
|
||||
album.albumUsers = album.albumUsers.filter(({ user: { id } }) => id !== userId);
|
||||
await refreshAlbum();
|
||||
};
|
||||
|
||||
const onSharedLinkCreate = (sharedLink: SharedLinkResponseDto) => {
|
||||
sharedLinks.push(sharedLink);
|
||||
};
|
||||
|
||||
const onSharedLinkDelete = (sharedLink: SharedLinkResponseDto) => {
|
||||
sharedLinks = sharedLinks.filter(({ id }) => sharedLink.id !== id);
|
||||
};
|
||||
|
||||
const { AddUsers, CreateSharedLink } = $derived(getAlbumActions($t, album));
|
||||
|
||||
let sharedLinks: SharedLinkResponseDto[] = $state([]);
|
||||
|
||||
onMount(async () => {
|
||||
sharedLinks = await getAllSharedLinks({ albumId: album.id });
|
||||
});
|
||||
</script>
|
||||
|
||||
<Modal title={$t('options')} onClose={() => onClose({ action: 'refreshAlbum' })} size="small">
|
||||
<OnEvents
|
||||
{onAlbumUserDelete}
|
||||
onAlbumShare={refreshAlbum}
|
||||
{onSharedLinkCreate}
|
||||
{onSharedLinkDelete}
|
||||
onAlbumUpdate={(newAlbum) => (album = newAlbum)}
|
||||
/>
|
||||
|
||||
<Modal title={$t('options')} {onClose} size="small">
|
||||
<ModalBody>
|
||||
<div class="items-center justify-center">
|
||||
<div class="py-2">
|
||||
<h2 class="uppercase text-gray text-sm mb-2">{$t('settings')}</h2>
|
||||
<div class="grid p-2 gap-y-2">
|
||||
{#if order}
|
||||
<SettingDropdown
|
||||
title={$t('display_order')}
|
||||
options={Object.values(options)}
|
||||
selectedOption={options[order]}
|
||||
onToggle={handleToggleOrder}
|
||||
/>
|
||||
<Stack gap={6}>
|
||||
<div>
|
||||
<Heading size="tiny" class="mb-2">{$t('settings')}</Heading>
|
||||
<div class="grid gap-y-2 ps-2">
|
||||
{#if album.order}
|
||||
<Field label={$t('display_order')}>
|
||||
<Select
|
||||
data={orderOptions}
|
||||
value={selectedOrderOption}
|
||||
onChange={({ value }) => handleUpdateAlbum(album, { order: value })}
|
||||
/>
|
||||
</Field>
|
||||
{/if}
|
||||
<SettingSwitch
|
||||
title={$t('comments_and_likes')}
|
||||
subtitle={$t('let_others_respond')}
|
||||
checked={album.isActivityEnabled}
|
||||
onToggle={handleToggleActivity}
|
||||
/>
|
||||
<Field label={$t('comments_and_likes')} description={$t('let_others_respond')}>
|
||||
<Switch
|
||||
checked={album.isActivityEnabled}
|
||||
onCheckedChange={(checked) => handleUpdateAlbum(album, { isActivityEnabled: checked })}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
<div class="py-2">
|
||||
<div class="uppercase text-gray text-sm mb-3">{$t('people')}</div>
|
||||
<div class="p-2">
|
||||
<button type="button" class="flex items-center gap-2" onclick={() => onClose({ action: 'shareUser' })}>
|
||||
<div class="rounded-full w-10 h-10 border border-gray-500 flex items-center justify-center">
|
||||
<div><Icon icon={mdiPlus} size="25" /></div>
|
||||
</div>
|
||||
<div>{$t('invite_people')}</div>
|
||||
</button>
|
||||
|
||||
<div class="flex items-center gap-2 py-2 mt-2">
|
||||
<div>
|
||||
<HStack fullWidth class="justify-between mb-2">
|
||||
<Heading size="tiny">{$t('people')}</Heading>
|
||||
<HeaderActionButton action={AddUsers} />
|
||||
</HStack>
|
||||
<div class="ps-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<div>
|
||||
<UserAvatar {user} size="md" />
|
||||
<UserAvatar user={$user} size="md" />
|
||||
</div>
|
||||
<div class="w-full">{user.name}</div>
|
||||
<div>{$t('owner')}</div>
|
||||
<div class="w-full">{$user.name}</div>
|
||||
<Field disabled class="w-32 shrink-0">
|
||||
<Select data={[{ label: $t('owner'), value: 'owner' }]} value={{ label: $t('owner'), value: 'owner' }} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{#each album.albumUsers as { user, role } (user.id)}
|
||||
<div class="flex items-center gap-2 py-2">
|
||||
<div>
|
||||
<UserAvatar {user} size="md" />
|
||||
<div class="flex items-center justify-between gap-4 py-2">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<div>
|
||||
<UserAvatar {user} size="md" />
|
||||
</div>
|
||||
<Text>{user.name}</Text>
|
||||
</div>
|
||||
<div class="w-full">{user.name}</div>
|
||||
{#if role === AlbumUserRole.Viewer}
|
||||
{$t('role_viewer')}
|
||||
{:else}
|
||||
{$t('role_editor')}
|
||||
{/if}
|
||||
{#if user.id !== album.ownerId}
|
||||
<ButtonContextMenu icon={mdiDotsVertical} size="medium" title={$t('options')}>
|
||||
{#if role === AlbumUserRole.Viewer}
|
||||
<MenuOption
|
||||
onClick={() => handleUpdateSharedUserRole(user, AlbumUserRole.Editor)}
|
||||
text={$t('allow_edits')}
|
||||
/>
|
||||
{:else}
|
||||
<MenuOption
|
||||
onClick={() => handleUpdateSharedUserRole(user, AlbumUserRole.Viewer)}
|
||||
text={$t('disallow_edits')}
|
||||
/>
|
||||
{/if}
|
||||
<!-- Allow deletion for non-owners -->
|
||||
<MenuOption onClick={() => handleRemoveUser(user)} text={$t('remove')} />
|
||||
</ButtonContextMenu>
|
||||
{/if}
|
||||
<Field class="w-32">
|
||||
<Select
|
||||
data={roleOptions}
|
||||
value={roleOptions.find(({ value }) => value === role)}
|
||||
onChange={({ value }) => handleRoleSelect(user, value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<HStack class="justify-between mb-2">
|
||||
<Heading size="tiny">{$t('shared_links')}</Heading>
|
||||
<HeaderActionButton action={CreateSharedLink} />
|
||||
</HStack>
|
||||
|
||||
<Stack gap={4}>
|
||||
{#each sharedLinks as sharedLink (sharedLink.id)}
|
||||
<AlbumSharedLink {album} {sharedLink} />
|
||||
{/each}
|
||||
</Stack>
|
||||
</div>
|
||||
</Stack>
|
||||
</ModalBody>
|
||||
</Modal>
|
||||
|
||||
@ -1,192 +0,0 @@
|
||||
<script lang="ts">
|
||||
import AlbumSharedLink from '$lib/components/album-page/album-shared-link.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import Dropdown from '$lib/elements/Dropdown.svelte';
|
||||
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
|
||||
import { handleAddUsersToAlbum } from '$lib/services/album.service';
|
||||
import {
|
||||
AlbumUserRole,
|
||||
getAllSharedLinks,
|
||||
searchUsers,
|
||||
type AlbumResponseDto,
|
||||
type SharedLinkResponseDto,
|
||||
type UserResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { Button, Icon, Link, Modal, ModalBody, modalManager, Stack, Text } from '@immich/ui';
|
||||
import { mdiCheck, mdiEye, mdiLink, mdiPencil } from '@mdi/js';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import UserAvatar from '../components/shared-components/user-avatar.svelte';
|
||||
|
||||
interface Props {
|
||||
album: AlbumResponseDto;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { album, onClose }: Props = $props();
|
||||
|
||||
let users: UserResponseDto[] = $state([]);
|
||||
let selectedUsers: Record<string, { user: UserResponseDto; role: AlbumUserRole }> = $state({});
|
||||
|
||||
const roleOptions: Array<{ title: string; value: AlbumUserRole | 'none'; icon?: string }> = [
|
||||
{ title: $t('role_editor'), value: AlbumUserRole.Editor, icon: mdiPencil },
|
||||
{ title: $t('role_viewer'), value: AlbumUserRole.Viewer, icon: mdiEye },
|
||||
{ title: $t('remove_user'), value: 'none' },
|
||||
];
|
||||
|
||||
let sharedLinks: SharedLinkResponseDto[] = $state([]);
|
||||
onMount(async () => {
|
||||
sharedLinks = await getAllSharedLinks({ albumId: album.id });
|
||||
const data = await searchUsers();
|
||||
|
||||
// remove album owner
|
||||
users = data.filter((user) => user.id !== album.ownerId);
|
||||
|
||||
// Remove the existed shared users from the album
|
||||
for (const sharedUser of album.albumUsers) {
|
||||
users = users.filter((user) => user.id !== sharedUser.user.id);
|
||||
}
|
||||
});
|
||||
|
||||
const handleToggle = (user: UserResponseDto) => {
|
||||
if (Object.keys(selectedUsers).includes(user.id)) {
|
||||
delete selectedUsers[user.id];
|
||||
} else {
|
||||
selectedUsers[user.id] = { user, role: AlbumUserRole.Editor };
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeRole = (user: UserResponseDto, role: AlbumUserRole | 'none') => {
|
||||
if (role === 'none') {
|
||||
delete selectedUsers[user.id];
|
||||
} else {
|
||||
selectedUsers[user.id].role = role;
|
||||
}
|
||||
};
|
||||
|
||||
const onShareUser = async () => {
|
||||
const success = await handleAddUsersToAlbum(
|
||||
album,
|
||||
Object.values(selectedUsers).map(({ user, ...rest }) => ({ userId: user.id, ...rest })),
|
||||
);
|
||||
if (success) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const onShareLink = () => {
|
||||
void modalManager.show(SharedLinkCreateModal, { albumId: album.id });
|
||||
onClose();
|
||||
};
|
||||
</script>
|
||||
|
||||
<Modal size="small" title={$t('share')} {onClose}>
|
||||
<ModalBody>
|
||||
{#if Object.keys(selectedUsers).length > 0}
|
||||
<div class="mb-2 py-2 sticky">
|
||||
<p class="text-xs font-medium">{$t('selected')}</p>
|
||||
<div class="my-2">
|
||||
{#each Object.values(selectedUsers) as { user } (user.id)}
|
||||
{#key user.id}
|
||||
<div class="flex place-items-center gap-4 p-4">
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full border bg-green-600 text-3xl text-white"
|
||||
>
|
||||
<Icon icon={mdiCheck} size="24" />
|
||||
</div>
|
||||
|
||||
<!-- <UserAvatar {user} size="md" /> -->
|
||||
<div class="text-start grow">
|
||||
<p class="text-immich-fg dark:text-immich-dark-fg">
|
||||
{user.name}
|
||||
</p>
|
||||
<p class="text-xs">
|
||||
{user.email}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Dropdown
|
||||
title={$t('role')}
|
||||
options={roleOptions}
|
||||
render={({ title, icon }) => ({ title, icon })}
|
||||
onSelect={({ value }) => handleChangeRole(user, value)}
|
||||
/>
|
||||
</div>
|
||||
{/key}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if users.length + Object.keys(selectedUsers).length === 0}
|
||||
<p class="p-5 text-sm">
|
||||
{$t('album_share_no_users')}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="immich-scrollbar max-h-125 overflow-y-auto">
|
||||
{#if users.length > 0 && users.length !== Object.keys(selectedUsers).length}
|
||||
<Text>{$t('users')}</Text>
|
||||
|
||||
<div class="my-2">
|
||||
{#each users as user (user.id)}
|
||||
{#if !Object.keys(selectedUsers).includes(user.id)}
|
||||
<div class="flex place-items-center transition-all hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleToggle(user)}
|
||||
class="flex w-full place-items-center gap-4 p-4"
|
||||
>
|
||||
<UserAvatar {user} size="md" />
|
||||
<div class="text-start grow">
|
||||
<p class="text-immich-fg dark:text-immich-dark-fg">
|
||||
{user.name}
|
||||
</p>
|
||||
<p class="text-xs">
|
||||
{user.email}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if users.length > 0}
|
||||
<div class="py-3">
|
||||
<Button
|
||||
size="small"
|
||||
fullWidth
|
||||
shape="round"
|
||||
disabled={Object.keys(selectedUsers).length === 0}
|
||||
onclick={onShareUser}
|
||||
>
|
||||
{$t('add')}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<hr class="my-4" />
|
||||
|
||||
<Stack gap={6}>
|
||||
{#if sharedLinks.length > 0}
|
||||
<div class="flex justify-between items-center">
|
||||
<Text>{$t('shared_links')}</Text>
|
||||
<Link href={AppRoute.SHARED_LINKS} onclick={() => onClose()} class="text-sm">{$t('view_all')}</Link>
|
||||
</div>
|
||||
|
||||
<Stack gap={4}>
|
||||
{#each sharedLinks as sharedLink (sharedLink.id)}
|
||||
<AlbumSharedLink {album} {sharedLink} />
|
||||
{/each}
|
||||
</Stack>
|
||||
{/if}
|
||||
|
||||
<Button leadingIcon={mdiLink} size="small" shape="round" fullWidth onclick={onShareLink}>
|
||||
{$t('create_link')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</ModalBody>
|
||||
</Modal>
|
||||
@ -1,148 +0,0 @@
|
||||
<script lang="ts">
|
||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import {
|
||||
AlbumUserRole,
|
||||
getMyUser,
|
||||
removeUserFromAlbum,
|
||||
updateAlbumUser,
|
||||
type AlbumResponseDto,
|
||||
type UserResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { Button, Modal, ModalBody, Text, modalManager, toastManager } from '@immich/ui';
|
||||
import { mdiDotsVertical } from '@mdi/js';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
album: AlbumResponseDto;
|
||||
onClose: (changed?: boolean) => void;
|
||||
}
|
||||
|
||||
let { album, onClose }: Props = $props();
|
||||
|
||||
let currentUser: UserResponseDto | undefined = $state();
|
||||
|
||||
let isOwned = $derived(currentUser?.id == album.ownerId);
|
||||
|
||||
// Build a map of contributor counts by user id; avoid casts/derived
|
||||
const contributorCounts: Record<string, number> = {};
|
||||
if (album.contributorCounts) {
|
||||
for (const { userId, assetCount } of album.contributorCounts) {
|
||||
contributorCounts[userId] = assetCount;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
currentUser = await getMyUser();
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_refresh_user'));
|
||||
}
|
||||
});
|
||||
|
||||
const handleRemoveUser = async (user: UserResponseDto) => {
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = user.id === currentUser?.id ? 'me' : user.id;
|
||||
let confirmed: boolean | undefined;
|
||||
|
||||
// eslint-disable-next-line unicorn/prefer-ternary
|
||||
if (userId === 'me') {
|
||||
confirmed = await modalManager.showDialog({
|
||||
title: $t('album_leave'),
|
||||
prompt: $t('album_leave_confirmation', { values: { album: album.albumName } }),
|
||||
confirmText: $t('leave'),
|
||||
});
|
||||
} else {
|
||||
confirmed = await modalManager.showDialog({
|
||||
title: $t('album_remove_user'),
|
||||
prompt: $t('album_remove_user_confirmation', { values: { user: user.name } }),
|
||||
confirmText: $t('remove_user'),
|
||||
});
|
||||
}
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await removeUserFromAlbum({ id: album.id, userId });
|
||||
const message =
|
||||
userId === 'me'
|
||||
? $t('album_user_left', { values: { album: album.albumName } })
|
||||
: $t('album_user_removed', { values: { user: user.name } });
|
||||
toastManager.success(message);
|
||||
onClose(true);
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_remove_album_users'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeRole = async (user: UserResponseDto, role: AlbumUserRole) => {
|
||||
try {
|
||||
await updateAlbumUser({ id: album.id, userId: user.id, updateAlbumUserDto: { role } });
|
||||
const message = $t('user_role_set', {
|
||||
values: { user: user.name, role: role == AlbumUserRole.Viewer ? $t('role_viewer') : $t('role_editor') },
|
||||
});
|
||||
toastManager.success(message);
|
||||
onClose(true);
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_change_album_user_role'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<Modal title={$t('options')} size="small" {onClose}>
|
||||
<ModalBody>
|
||||
<section class="immich-scrollbar max-h-100 overflow-y-auto pb-4">
|
||||
{#each [{ user: album.owner, role: 'owner' }, ...album.albumUsers] as { user, role } (user.id)}
|
||||
<div class="flex w-full place-items-center justify-between gap-4 p-5 rounded-xl transition-colors">
|
||||
<div class="flex place-items-center gap-4">
|
||||
<UserAvatar {user} size="md" />
|
||||
<div class="flex flex-col">
|
||||
<p class="font-medium">{user.name}</p>
|
||||
<Text color="muted" size="tiny">
|
||||
{#if role === 'owner'}
|
||||
{$t('owner')}
|
||||
{:else if role === AlbumUserRole.Viewer}
|
||||
{$t('role_viewer')}
|
||||
{:else}
|
||||
{$t('role_editor')}
|
||||
{/if}
|
||||
{#if user.id in contributorCounts}
|
||||
<span>-</span>
|
||||
{$t('items_count', { values: { count: contributorCounts[user.id] } })}
|
||||
{/if}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="icon-{user.id}" class="flex place-items-center">
|
||||
{#if isOwned}
|
||||
<ButtonContextMenu icon={mdiDotsVertical} size="medium" title={$t('options')}>
|
||||
{#if role === AlbumUserRole.Viewer}
|
||||
<MenuOption onClick={() => handleChangeRole(user, AlbumUserRole.Editor)} text={$t('allow_edits')} />
|
||||
{:else}
|
||||
<MenuOption
|
||||
onClick={() => handleChangeRole(user, AlbumUserRole.Viewer)}
|
||||
text={$t('disallow_edits')}
|
||||
/>
|
||||
{/if}
|
||||
<MenuOption onClick={() => handleRemoveUser(user)} text={$t('remove')} />
|
||||
</ButtonContextMenu>
|
||||
{:else if user.id == currentUser?.id}
|
||||
<Button shape="round" variant="ghost" leadingIcon={undefined} onclick={() => handleRemoveUser(user)}
|
||||
>{$t('leave')}</Button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
</ModalBody>
|
||||
</Modal>
|
||||
@ -3,7 +3,9 @@ import ToastAction from '$lib/components/ToastAction.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import AlbumShareModal from '$lib/modals/AlbumShareModal.svelte';
|
||||
import AlbumAddUsersModal from '$lib/modals/AlbumAddUsersModal.svelte';
|
||||
import AlbumOptionsModal from '$lib/modals/AlbumOptionsModal.svelte';
|
||||
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { downloadArchive } from '$lib/utils/asset-utils';
|
||||
import { openFileUploadDialog } from '$lib/utils/file-uploader';
|
||||
@ -12,14 +14,17 @@ import { getFormatter } from '$lib/utils/i18n';
|
||||
import {
|
||||
addAssetsToAlbum,
|
||||
addUsersToAlbum,
|
||||
AlbumUserRole,
|
||||
deleteAlbum,
|
||||
removeUserFromAlbum,
|
||||
updateAlbumInfo,
|
||||
updateAlbumUser,
|
||||
type AlbumResponseDto,
|
||||
type AlbumUserAddDto,
|
||||
type UpdateAlbumDto,
|
||||
type UserResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
|
||||
import { mdiPlusBoxOutline, mdiShareVariantOutline, mdiUpload } from '@mdi/js';
|
||||
import { mdiLink, mdiPlus, mdiPlusBoxOutline, mdiShareVariantOutline, mdiUpload } from '@mdi/js';
|
||||
import { type MessageFormatter } from 'svelte-i18n';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
@ -31,10 +36,24 @@ export const getAlbumActions = ($t: MessageFormatter, album: AlbumResponseDto) =
|
||||
type: $t('command'),
|
||||
icon: mdiShareVariantOutline,
|
||||
$if: () => isOwned,
|
||||
onAction: () => modalManager.show(AlbumShareModal, { album }),
|
||||
onAction: () => modalManager.show(AlbumOptionsModal, { album }),
|
||||
};
|
||||
|
||||
return { Share };
|
||||
const AddUsers: ActionItem = {
|
||||
title: $t('invite_people'),
|
||||
type: $t('command'),
|
||||
icon: mdiPlus,
|
||||
onAction: () => modalManager.show(AlbumAddUsersModal, { album }),
|
||||
};
|
||||
|
||||
const CreateSharedLink: ActionItem = {
|
||||
title: $t('create_link'),
|
||||
type: $t('command'),
|
||||
icon: mdiLink,
|
||||
onAction: () => modalManager.show(SharedLinkCreateModal, { albumId: album.id }),
|
||||
};
|
||||
|
||||
return { Share, AddUsers, CreateSharedLink };
|
||||
};
|
||||
|
||||
export const getAlbumAssetsActions = ($t: MessageFormatter, album: AlbumResponseDto, assets: TimelineAsset[]) => {
|
||||
@ -72,18 +91,56 @@ const addAssets = async (album: AlbumResponseDto, assets: TimelineAsset[]) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const handleAddUsersToAlbum = async (album: AlbumResponseDto, albumUsers: AlbumUserAddDto[]) => {
|
||||
export const handleUpdateUserAlbumRole = async ({
|
||||
albumId,
|
||||
userId,
|
||||
role,
|
||||
}: {
|
||||
albumId: string;
|
||||
userId: string;
|
||||
role: AlbumUserRole;
|
||||
}) => {
|
||||
const $t = await getFormatter();
|
||||
|
||||
try {
|
||||
await addUsersToAlbum({ id: album.id, addUsersDto: { albumUsers } });
|
||||
await updateAlbumUser({ id: albumId, userId, updateAlbumUserDto: { role } });
|
||||
eventManager.emit('AlbumUserUpdate', { albumId, userId, role });
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_change_album_user_role'));
|
||||
}
|
||||
};
|
||||
|
||||
export const handleAddUsersToAlbum = async (album: AlbumResponseDto, users: UserResponseDto[]) => {
|
||||
const $t = await getFormatter();
|
||||
|
||||
try {
|
||||
await addUsersToAlbum({ id: album.id, addUsersDto: { albumUsers: users.map(({ id }) => ({ userId: id })) } });
|
||||
eventManager.emit('AlbumShare');
|
||||
return true;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.error_adding_users_to_album'));
|
||||
}
|
||||
};
|
||||
|
||||
return false;
|
||||
export const handleRemoveUserFromAlbum = async (album: AlbumResponseDto, albumUser: UserResponseDto) => {
|
||||
const $t = await getFormatter();
|
||||
|
||||
const confirmed = await modalManager.showDialog({
|
||||
title: $t('album_remove_user'),
|
||||
prompt: $t('album_remove_user_confirmation', { values: { user: albumUser.name } }),
|
||||
confirmText: $t('remove_user'),
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await removeUserFromAlbum({ id: album.id, userId: albumUser.id });
|
||||
eventManager.emit('AlbumUserDelete', { albumId: album.id, userId: albumUser.id });
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_remove_album_users'));
|
||||
}
|
||||
};
|
||||
|
||||
export const handleUpdateAlbum = async ({ id }: { id: string }, dto: UpdateAlbumDto) => {
|
||||
|
||||
@ -36,8 +36,6 @@
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import AlbumOptionsModal from '$lib/modals/AlbumOptionsModal.svelte';
|
||||
import AlbumShareModal from '$lib/modals/AlbumShareModal.svelte';
|
||||
import AlbumUsersModal from '$lib/modals/AlbumUsersModal.svelte';
|
||||
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
|
||||
import {
|
||||
getAlbumActions,
|
||||
@ -60,14 +58,7 @@
|
||||
navigate,
|
||||
type AssetGridRouteSearchParams,
|
||||
} from '$lib/utils/navigation';
|
||||
import {
|
||||
AlbumUserRole,
|
||||
AssetOrder,
|
||||
AssetVisibility,
|
||||
getAlbumInfo,
|
||||
updateAlbumInfo,
|
||||
type AlbumResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { AlbumUserRole, AssetVisibility, getAlbumInfo, updateAlbumInfo, type AlbumResponseDto } from '@immich/sdk';
|
||||
import { CommandPaletteDefaultProvider, Icon, IconButton, modalManager, toastManager } from '@immich/ui';
|
||||
import {
|
||||
mdiAccountEye,
|
||||
@ -101,7 +92,6 @@
|
||||
|
||||
let backUrl: string = $state(AppRoute.ALBUMS);
|
||||
let viewMode: AlbumPageViewMode = $state(AlbumPageViewMode.VIEW);
|
||||
let albumOrder: AssetOrder | undefined = $state(data.album.order);
|
||||
|
||||
let timelineManager = $state<TimelineManager>() as TimelineManager;
|
||||
let showAlbumUsers = $derived(timelineManager?.showAssetOwners ?? false);
|
||||
@ -266,7 +256,7 @@
|
||||
timelineAlbumId: albumId,
|
||||
};
|
||||
}
|
||||
return { albumId, order: albumOrder };
|
||||
return { albumId, order: album.order };
|
||||
});
|
||||
|
||||
const isShared = $derived(viewMode === AlbumPageViewMode.SELECT_ASSETS ? false : album.albumUsers.length > 0);
|
||||
@ -319,37 +309,6 @@
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditUsers = async () => {
|
||||
const changed = await modalManager.show(AlbumUsersModal, { album });
|
||||
|
||||
if (changed) {
|
||||
await refreshAlbum();
|
||||
}
|
||||
};
|
||||
|
||||
const handleOptions = async () => {
|
||||
const result = await modalManager.show(AlbumOptionsModal, { album, order: albumOrder, user: $user });
|
||||
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (result.action) {
|
||||
case 'changeOrder': {
|
||||
albumOrder = result.order;
|
||||
break;
|
||||
}
|
||||
case 'shareUser': {
|
||||
await modalManager.show(AlbumShareModal, { album });
|
||||
break;
|
||||
}
|
||||
case 'refreshAlbum': {
|
||||
await refreshAlbum();
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onAlbumAddAssets = async () => {
|
||||
await refreshAlbum();
|
||||
timelineInteraction.clearMultiselect();
|
||||
@ -361,12 +320,31 @@
|
||||
await setModeToView();
|
||||
};
|
||||
|
||||
const onAlbumUserUpdate = ({ albumId, userId, role }: { albumId: string; userId: string; role: AlbumUserRole }) => {
|
||||
if (albumId !== album.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
album.albumUsers = album.albumUsers.map((albumUser) =>
|
||||
albumUser.user.id === userId ? { ...albumUser, role } : albumUser,
|
||||
);
|
||||
};
|
||||
|
||||
const { Cast } = $derived(getGlobalActions($t));
|
||||
const { Share } = $derived(getAlbumActions($t, album));
|
||||
const { AddAssets, Upload } = $derived(getAlbumAssetsActions($t, album, timelineInteraction.selectedAssets));
|
||||
</script>
|
||||
|
||||
<OnEvents {onSharedLinkCreate} {onAlbumDelete} {onAlbumAddAssets} {onAlbumShare} />
|
||||
<OnEvents
|
||||
{onSharedLinkCreate}
|
||||
onSharedLinkDelete={refreshAlbum}
|
||||
{onAlbumDelete}
|
||||
{onAlbumAddAssets}
|
||||
{onAlbumShare}
|
||||
{onAlbumUserUpdate}
|
||||
onAlbumUserDelete={refreshAlbum}
|
||||
onAlbumUpdate={(newAlbum) => (album = newAlbum)}
|
||||
/>
|
||||
<CommandPaletteDefaultProvider name={$t('album')} actions={[AddAssets, Upload]} />
|
||||
|
||||
<div class="flex overflow-hidden" use:scrollMemoryClearer={{ routeStartsWith: AppRoute.ALBUMS }}>
|
||||
@ -417,13 +395,13 @@
|
||||
{/if}
|
||||
|
||||
<!-- owner -->
|
||||
<button type="button" onclick={handleEditUsers}>
|
||||
<button type="button" onclick={() => modalManager.show(AlbumOptionsModal, { album })}>
|
||||
<UserAvatar user={album.owner} size="md" />
|
||||
</button>
|
||||
|
||||
<!-- users with write access (collaborators) -->
|
||||
{#each album.albumUsers.filter(({ role }) => role === AlbumUserRole.Editor) as { user } (user.id)}
|
||||
<button type="button" onclick={handleEditUsers}>
|
||||
<button type="button" onclick={() => modalManager.show(AlbumOptionsModal, { album })}>
|
||||
<UserAvatar {user} size="md" />
|
||||
</button>
|
||||
{/each}
|
||||
@ -436,7 +414,7 @@
|
||||
color="secondary"
|
||||
size="medium"
|
||||
icon={mdiDotsVertical}
|
||||
onclick={handleEditUsers}
|
||||
onclick={() => modalManager.show(AlbumOptionsModal, { album })}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@ -601,7 +579,11 @@
|
||||
text={$t('select_album_cover')}
|
||||
onClick={() => (viewMode = AlbumPageViewMode.SELECT_THUMBNAIL)}
|
||||
/>
|
||||
<MenuOption icon={mdiCogOutline} text={$t('options')} onClick={handleOptions} />
|
||||
<MenuOption
|
||||
icon={mdiCogOutline}
|
||||
text={$t('options')}
|
||||
onClick={() => modalManager.show(AlbumOptionsModal, { album })}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if isOwned}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user