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:
Daniel Dietzler 2026-01-14 14:18:02 -06:00 committed by GitHub
parent 2190921c85
commit 56dfdfd033
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 280 additions and 542 deletions

View File

@ -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>

View File

@ -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;
}

View File

@ -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];

View 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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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) => {

View File

@ -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}