feat: groups

This commit is contained in:
Jason Rasmussen
2025-07-30 18:18:38 -04:00
parent 641a3baadd
commit 4a881022c3
76 changed files with 6515 additions and 124 deletions

View File

@@ -34,9 +34,8 @@
} from '$lib/utils/album-utils';
import { downloadAlbum } from '$lib/utils/asset-utils';
import type { ContextMenuPosition } from '$lib/utils/context-menu';
import { handleError } from '$lib/utils/handle-error';
import { normalizeSearchString } from '$lib/utils/string-utils';
import { addUsersToAlbum, deleteAlbum, isHttpError, type AlbumResponseDto, type AlbumUserAddDto } from '@immich/sdk';
import { deleteAlbum, getAlbumInfo, isHttpError, type AlbumResponseDto } from '@immich/sdk';
import { modalManager } from '@immich/ui';
import { mdiDeleteOutline, mdiFolderDownloadOutline, mdiRenameOutline, mdiShareVariantOutline } from '@mdi/js';
import { groupBy } from 'lodash-es';
@@ -324,25 +323,6 @@
updateRecentAlbumInfo(album);
};
const handleAddUsers = async (albumUsers: AlbumUserAddDto[]) => {
if (!albumToShare) {
return;
}
try {
const album = await addUsersToAlbum({
id: albumToShare.id,
addUsersDto: {
albumUsers,
},
});
updateAlbumInfo(album);
} catch (error) {
handleError(error, $t('errors.unable_to_add_album_users'));
} finally {
albumToShare = null;
}
};
const handleSharedLinkCreated = (album: AlbumResponseDto) => {
album.shared = true;
album.hasSharedLink = true;
@@ -356,11 +336,13 @@
albumToShare = contextMenuTargetAlbum;
closeAlbumContextMenu();
const result = await modalManager.show(AlbumShareModal, { album: albumToShare });
const action = await modalManager.show(AlbumShareModal, { album: albumToShare });
switch (result?.action) {
case 'sharedUsers': {
await handleAddUsers(result.data);
switch (action) {
case 'update': {
const album = await getAlbumInfo({ id: albumToShare.id, withoutAssets: true });
updateAlbumInfo(album);
albumToShare = null;
return;
}

View File

@@ -0,0 +1,35 @@
<script lang="ts">
import { Icon, type Size } from '@immich/ui';
import { mdiAccountMultipleOutline } from '@mdi/js';
type Group = {
name: string;
description: string | null;
};
interface Props {
group: Group;
size?: Size;
}
let { group, size = 'medium' }: Props = $props();
const getDescription = (group: Group) => {
return group.name + (group.description ? ` - ${group.description}` : '');
};
const title = $derived(getDescription(group));
const sizes: Record<Size, string> = {
tiny: 'h-5 w-5 text-xs',
small: 'h-7 w-7 text-sm',
medium: 'h-10 w-10 text-base',
large: 'h-12 w-12 text-lg',
giant: 'h-16 w-16 text-xl',
};
</script>
<!-- <Avatar name={group.name} {color} {size} /> -->
<span class="{sizes[size]} shrink-0 bg-subtle text-primary rounded-full p-2 border border-light" {title}>
<Icon size="100%" icon={mdiAccountMultipleOutline} />
</span>

View File

@@ -18,6 +18,7 @@ export enum AssetAction {
export enum AppRoute {
ADMIN_USERS = '/admin/users',
ADMIN_GROUPS = '/admin/groups',
ADMIN_LIBRARY_MANAGEMENT = '/admin/library-management',
ADMIN_SETTINGS = '/admin/system-settings',
ADMIN_STATS = '/admin/server-status',

View File

@@ -2,19 +2,24 @@
import AlbumSharedLink from '$lib/components/album-page/album-shared-link.svelte';
import Dropdown from '$lib/components/elements/dropdown.svelte';
import Icon from '$lib/components/elements/icon.svelte';
import GroupAvatar from '$lib/components/shared-components/GroupAvatar.svelte';
import { AppRoute } from '$lib/constants';
import QrCodeModal from '$lib/modals/QrCodeModal.svelte';
import { makeSharedLinkUrl } from '$lib/utils';
import {
addGroupsToAlbum,
addUsersToAlbum,
AlbumUserRole,
getAllSharedLinks,
getGroupsForAlbum,
searchMyGroups,
searchUsers,
type AlbumResponseDto,
type AlbumUserAddDto,
type GroupResponseDto,
type SharedLinkResponseDto,
type UserResponseDto,
} from '@immich/sdk';
import { Button, Link, Modal, ModalBody, Stack, Text } from '@immich/ui';
import { Button, Heading, Link, Modal, ModalBody, Stack, Text } from '@immich/ui';
import { mdiCheck, mdiEye, mdiLink, mdiPencil } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
@@ -22,13 +27,25 @@
interface Props {
album: AlbumResponseDto;
onClose: (result?: { action: 'sharedLink' } | { action: 'sharedUsers'; data: AlbumUserAddDto[] }) => void;
onClose: (action?: 'sharedLink' | 'update') => void;
}
let { album, onClose }: Props = $props();
let users: UserResponseDto[] = $state([]);
let selectedUsers: Record<string, { user: UserResponseDto; role: AlbumUserRole }> = $state({});
let groups: GroupResponseDto[] = $state([]);
let selectedUsers: Record<string, { item: UserResponseDto; role: AlbumUserRole }> = $state({});
let selectedGroups: Record<string, { item: GroupResponseDto; role: AlbumUserRole }> = $state({});
type SelectedItem =
| { type: 'user'; item: UserResponseDto; role: AlbumUserRole }
| { type: 'group'; item: GroupResponseDto; role: AlbumUserRole };
const selectedItems: SelectedItem[] = $derived([
...Object.values(selectedGroups).map((item) => ({ ...item, type: 'group' }) as const),
...Object.values(selectedUsers).map((item) => ({ ...item, type: 'user' }) as const),
]);
let sharedLinkUrl = $state('');
const handleViewQrCode = (sharedLink: SharedLinkResponseDto) => {
@@ -38,38 +55,81 @@
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' },
{ title: $t('remove'), value: 'none' },
];
let sharedLinks: SharedLinkResponseDto[] = $state([]);
onMount(async () => {
sharedLinks = await getAllSharedLinks({ albumId: album.id });
const data = await searchUsers();
const [allUsers, allMyGroups, albumGroups] = await Promise.all([
searchUsers(),
searchMyGroups(),
getGroupsForAlbum({ id: album.id }),
]);
// remove album owner
users = data.filter((user) => user.id !== album.ownerId);
users = allUsers
.filter((user) => user.id !== album.ownerId)
.filter((user) => !album.albumUsers.some(({ user: sharedUser }) => user.id === sharedUser.id));
// Remove the existed shared users from the album
for (const sharedUser of album.albumUsers) {
users = users.filter((user) => user.id !== sharedUser.user.id);
}
groups = allMyGroups.filter((myGroup) => !albumGroups.some(({ id }) => myGroup.id === id));
});
const handleToggle = (user: UserResponseDto) => {
const handleToggleUser = (user: UserResponseDto) => {
if (Object.keys(selectedUsers).includes(user.id)) {
delete selectedUsers[user.id];
} else {
selectedUsers[user.id] = { user, role: AlbumUserRole.Editor };
selectedUsers[user.id] = { item: user, role: AlbumUserRole.Editor };
}
};
const handleChangeRole = (user: UserResponseDto, role: AlbumUserRole | 'none') => {
if (role === 'none') {
delete selectedUsers[user.id];
const handleToggleGroups = (group: GroupResponseDto) => {
if (Object.keys(selectedGroups).includes(group.id)) {
delete selectedGroups[group.id];
} else {
selectedUsers[user.id].role = role;
selectedGroups[group.id] = { item: group, role: AlbumUserRole.Editor };
}
};
const handleChangeRole = (selectedItem: SelectedItem, role: AlbumUserRole | 'none') => {
const { item, type } = selectedItem;
if (role === 'none') {
if (type === 'user') {
delete selectedUsers[item.id];
} else {
delete selectedGroups[item.id];
}
} else {
selectedItem.role = role;
}
};
const handleAdd = async () => {
const albumUsers = Object.values(selectedUsers).map(({ item: user, ...rest }) => ({ userId: user.id, ...rest }));
if (albumUsers.length > 0) {
await addUsersToAlbum({
id: album.id,
addUsersDto: {
albumUsers,
},
});
}
const groups = Object.values(selectedGroups).map(({ item: group, ...rest }) => ({ groupId: group.id, ...rest }));
if (groups.length > 0) {
await addGroupsToAlbum({
id: album.id,
albumGroupCreateAllDto: {
groups,
},
});
}
onClose('update');
selectedUsers = {};
selectedGroups = {};
sharedLinks = await getAllSharedLinks({ albumId: album.id });
};
</script>
{#if sharedLinkUrl}
@@ -77,37 +137,45 @@
{:else}
<Modal size="small" title={$t('share')} {onClose}>
<ModalBody>
{#if Object.keys(selectedUsers).length > 0}
{#if selectedItems.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">
<Heading size="tiny">{$t('selected')}</Heading>
<div class="flex my-2 flex-col gap-2">
{#each selectedItems as selectedItem (selectedItem.item.id)}
<div class="flex place-items-center gap-2 px-2">
{#if selectedItem.type === 'group'}
<GroupAvatar group={selectedItem.item} />
<div class="text-start grow p-2">
<p class="text-immich-fg dark:text-immich-dark-fg">
{selectedItem.item.name}
</p>
<p class="text-xs">
{selectedItem.item.description}
</p>
</div>
{:else}
<div
class="flex h-10 w-10 items-center justify-center rounded-full border bg-green-600 text-3xl text-white"
>
<Icon path={mdiCheck} size={24} />
</div>
<!-- <UserAvatar {user} size="md" /> -->
<div class="text-start grow">
<div class="text-start grow p-2">
<p class="text-immich-fg dark:text-immich-dark-fg">
{user.name}
{selectedItem.item.name}
</p>
<p class="text-xs">
{user.email}
{selectedItem.item.email}
</p>
</div>
{/if}
<Dropdown
title={$t('role')}
options={roleOptions}
render={({ title, icon }) => ({ title, icon })}
onSelect={({ value }) => handleChangeRole(user, value)}
/>
</div>
{/key}
<Dropdown
title={$t('role')}
options={roleOptions}
render={({ title, icon }) => ({ title, icon })}
onSelect={({ value }) => handleChangeRole(selectedItem, value)}
/>
</div>
{/each}
</div>
</div>
@@ -121,16 +189,18 @@
<div class="immich-scrollbar max-h-[500px] overflow-y-auto">
{#if users.length > 0 && users.length !== Object.keys(selectedUsers).length}
<Text>{$t('users')}</Text>
<Heading size="tiny">{$t('users')}</Heading>
<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">
<div
class="flex place-items-center transition-all hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl gap-2"
>
<button
type="button"
onclick={() => handleToggle(user)}
class="flex w-full place-items-center gap-4 p-4"
onclick={() => handleToggleUser(user)}
class="flex w-full place-items-center gap-4 p-2"
>
<UserAvatar {user} size="md" />
<div class="text-start grow">
@@ -147,21 +217,41 @@
{/each}
</div>
{/if}
{#if groups.length > 0 && groups.length !== Object.keys(selectedGroups).length}
<Heading size="tiny">{$t('groups')}</Heading>
<div class="my-2">
{#each groups as group (group.id)}
{#if !Object.keys(selectedGroups).includes(group.id)}
<div class="flex place-items-center transition-all hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl">
<button
type="button"
onclick={() => handleToggleGroups(group)}
class="flex w-full place-items-center gap-4 p-2"
>
<GroupAvatar {group} />
<div class="text-start grow">
<p class="text-immich-fg dark:text-immich-dark-fg">
{group.name}
</p>
<p class="text-xs">
{group.description}
</p>
</div>
</button>
</div>
{/if}
{/each}
</div>
{/if}
</div>
{#if users.length > 0}
{#if users.length > 0 || groups.length > 0}
<div class="py-3">
<Button
size="small"
fullWidth
shape="round"
disabled={Object.keys(selectedUsers).length === 0}
onclick={() =>
onClose({
action: 'sharedUsers',
data: Object.values(selectedUsers).map(({ user, ...rest }) => ({ userId: user.id, ...rest })),
})}>{$t('add')}</Button
>
<Button size="small" fullWidth shape="round" disabled={selectedItems.length === 0} onclick={handleAdd}>
{$t('add')}
</Button>
</div>
{/if}
@@ -181,12 +271,8 @@
</Stack>
{/if}
<Button
leadingIcon={mdiLink}
size="small"
shape="round"
fullWidth
onclick={() => onClose({ action: 'sharedLink' })}>{$t('create_link')}</Button
<Button leadingIcon={mdiLink} size="small" shape="round" fullWidth onclick={() => onClose('sharedLink')}
>{$t('create_link')}</Button
>
</Stack>
</ModalBody>

View File

@@ -0,0 +1,69 @@
<script lang="ts">
import { handleError } from '$lib/utils/handle-error';
import { createGroupAdmin, type GroupAdminResponseDto } from '@immich/sdk';
import { Alert, Button, Field, HStack, Input, Modal, ModalBody, ModalFooter, Stack } from '@immich/ui';
import { t } from 'svelte-i18n';
interface Props {
onClose: (group?: GroupAdminResponseDto) => void;
}
let { onClose }: Props = $props();
let error = $state('');
let name = $state('');
let description = $state('');
let isPending = $state(false);
let valid = $derived(name.length > 0);
const onSubmit = async (event: Event) => {
event.preventDefault();
if (!valid) {
return;
}
isPending = true;
error = '';
try {
const group = await createGroupAdmin({ groupAdminCreateDto: { name, description } });
onClose(group);
return;
} catch (error) {
handleError(error, $t('errors.unable_to_create_group'));
} finally {
isPending = false;
}
};
</script>
<Modal title={$t('create_new_group')} {onClose} size="small">
<ModalBody>
<form onsubmit={onSubmit} autocomplete="off" id="create-new-group-form">
{#if error}
<Alert color="danger" size="small" title={error} closable />
{/if}
<Stack gap={4}>
<Field label={$t('name')} required>
<Input bind:value={name} />
</Field>
<Field label={$t('description')}>
<Input bind:value={description} />
</Field>
</Stack>
</form>
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button color="secondary" fullWidth onclick={() => onClose()} shape="round">{$t('cancel')}</Button>
<Button type="submit" disabled={!valid} fullWidth shape="round" form="create-new-group-form"
>{$t('create')}
</Button>
</HStack>
</ModalFooter>
</Modal>

View File

@@ -0,0 +1,62 @@
<script lang="ts">
import { handleError } from '$lib/utils/handle-error';
import { updateGroupAdmin, type GroupAdminResponseDto } from '@immich/sdk';
import { Button, Field, HStack, Input, Modal, ModalBody, ModalFooter, Stack } from '@immich/ui';
import { mdiAccountEditOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
group: GroupAdminResponseDto;
onClose: (group?: GroupAdminResponseDto) => void;
}
let { group, onClose }: Props = $props();
let name = $derived(group.name);
let description = $derived(group.description ?? '');
const handleEditGroup = async () => {
try {
const newGroup = await updateGroupAdmin({
id: group.id,
groupAdminUpdateDto: {
name,
description: description ?? null,
},
});
onClose(newGroup);
} catch (error) {
handleError(error, $t('errors.unable_to_update_group'));
}
};
const onSubmit = async (event: Event) => {
event.preventDefault();
await handleEditGroup();
};
</script>
<Modal title={$t('edit_group')} size="small" icon={mdiAccountEditOutline} {onClose}>
<ModalBody>
<form onsubmit={onSubmit} autocomplete="off" id="edit-group-form">
<Stack gap={4}>
<Field label={$t('name')} required>
<Input bind:value={name} />
</Field>
<Field label={$t('description')} required>
<Input bind:value={description} />
</Field>
</Stack>
</form>
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button shape="round" color="secondary" fullWidth form="edit-group-form" onclick={() => onClose()}
>{$t('cancel')}</Button
>
<Button type="submit" shape="round" fullWidth form="edit-group-form">{$t('confirm')}</Button>
</HStack>
</ModalFooter>
</Modal>

View File

@@ -0,0 +1,175 @@
<script lang="ts">
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
import { handlePromiseError } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import {
addUsersToGroupAdmin,
removeUsersFromGroupAdmin,
searchUsersAdmin,
type GroupAdminResponseDto,
type GroupUserAdminResponseDto,
type UserAdminResponseDto,
type UserResponseDto,
} from '@immich/sdk';
import { Button, Heading, HStack, Icon, Modal, ModalBody, ModalFooter, Text } from '@immich/ui';
import { mdiAccountMultipleOutline, mdiCheck, mdiClose } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
group: GroupAdminResponseDto;
users: GroupUserAdminResponseDto[];
onClose: (changed?: boolean) => void;
}
let { group, users, onClose }: Props = $props();
let allUsers: UserAdminResponseDto[] = $state([]);
let selectedUsers: Record<string, UserResponseDto> = $state(Object.fromEntries(users.map((user) => [user.id, user])));
handlePromiseError(
searchUsersAdmin({}).then((result) => {
console.log(result);
allUsers = result;
}),
);
const handleToggle = (user: UserResponseDto) => {
if (Object.keys(selectedUsers).includes(user.id)) {
delete selectedUsers[user.id];
} else {
selectedUsers[user.id] = user;
}
};
const handleConfirm = async () => {
try {
const existingUsers = Object.fromEntries(users.map((user) => [user.id, user]));
const addUsers: UserAdminResponseDto[] = [];
const removeUsers: UserAdminResponseDto[] = [];
for (const user of allUsers) {
const currentlyAdded = !!selectedUsers[user.id];
const previouslyAdded = !!existingUsers[user.id];
if (!previouslyAdded && currentlyAdded) {
addUsers.push(user);
continue;
}
if (previouslyAdded && !currentlyAdded) {
removeUsers.push(user);
continue;
}
}
if (addUsers.length > 0) {
await addUsersToGroupAdmin({
id: group.id,
groupUserCreateAllDto: {
users: addUsers.map((user) => ({ userId: user.id })),
},
});
}
if (removeUsers.length > 0) {
await removeUsersFromGroupAdmin({
id: group.id,
groupUserDeleteAllDto: { userIds: removeUsers.map(({ id }) => id) },
});
}
onClose(addUsers.length > 0 || removeUsers.length > 0);
} catch (error) {
handleError(error, $t('errors.unable_to_update_user'));
}
};
</script>
<Modal
title={users.length === 0 ? $t('add_users') : $t('edit_users')}
size="small"
icon={mdiAccountMultipleOutline}
{onClose}
>
<ModalBody>
<div class="immich-scrollbar max-h-[500px] overflow-y-auto">
{#if Object.values(selectedUsers).length === 0}
<div class="my-4">
<Text size="large">{$t('empty_group_message')}</Text>
</div>
{/if}
{#if Object.keys(selectedUsers).length > 0}
<div class="mb-2 py-2 sticky">
<Heading size="tiny">{$t('group_users')}</Heading>
<div class="my-2">
{#each Object.values(selectedUsers) as user (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} />
</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>
<Button leadingIcon={mdiClose} color="secondary" size="small" onclick={() => handleToggle(user)}>
{$t('remove')}
</Button>
<!--
<Dropdown
title={$t('role')}
options={roleOptions}
render={({ title, icon }) => ({ title, icon })}
onSelect={({ value }) => handleChangeRole(user, value)}
/> -->
</div>
{/each}
</div>
</div>
{/if}
{#if allUsers.length > 0 && allUsers.length !== Object.keys(selectedUsers).length}
<Heading size="tiny">{$t('other_users')}</Heading>
<div class="my-2">
{#each allUsers 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>
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
<Button shape="round" fullWidth onclick={handleConfirm}>{$t('confirm')}</Button>
</HStack>
</ModalFooter>
</Modal>

View File

@@ -2,13 +2,14 @@
import BottomInfo from '$lib/components/shared-components/side-bar/bottom-info.svelte';
import { AppRoute } from '$lib/constants';
import { NavbarItem } from '@immich/ui';
import { mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiServer, mdiSync } from '@mdi/js';
import { mdiAccountGroupOutline, mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiServer, mdiSync } from '@mdi/js';
import { t } from 'svelte-i18n';
</script>
<div class="h-full flex flex-col justify-between gap-2">
<div class="flex flex-col pt-8 pe-4 gap-1">
<NavbarItem title={$t('users')} href={AppRoute.ADMIN_USERS} icon={mdiAccountMultipleOutline} />
<NavbarItem title={$t('groups')} href={AppRoute.ADMIN_GROUPS} icon={mdiAccountGroupOutline} />
<NavbarItem title={$t('jobs')} href={AppRoute.ADMIN_JOBS} icon={mdiSync} />
<NavbarItem title={$t('settings')} href={AppRoute.ADMIN_SETTINGS} icon={mdiCog} />
<NavbarItem title={$t('external_libraries')} href={AppRoute.ADMIN_LIBRARY_MANAGEMENT} icon={mdiBookshelf} />

View File

@@ -27,6 +27,7 @@
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 ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import GroupAvatar from '$lib/components/shared-components/GroupAvatar.svelte';
import {
NotificationType,
notificationController,
@@ -63,11 +64,9 @@
AssetOrder,
AssetVisibility,
addAssetsToAlbum,
addUsersToAlbum,
deleteAlbum,
getAlbumInfo,
updateAlbumInfo,
type AlbumUserAddDto,
} from '@immich/sdk';
import { Button, IconButton, modalManager } from '@immich/ui';
import {
@@ -104,6 +103,7 @@
let isCreatingSharedAlbum = $state(false);
let isShowActivity = $state(false);
let albumOrder: AssetOrder | undefined = $state(data.album.order);
let groups = $derived(data.groups);
const assetInteraction = new AssetInteraction();
const timelineInteraction = new AssetInteraction();
@@ -224,22 +224,6 @@
await setModeToView();
};
const handleAddUsers = async (albumUsers: AlbumUserAddDto[]) => {
try {
await addUsersToAlbum({
id: album.id,
addUsersDto: {
albumUsers,
},
});
await refreshAlbum();
viewMode = AlbumPageViewMode.VIEW;
} catch (error) {
handleError(error, $t('errors.error_adding_users_to_album'));
}
};
const handleDownloadAlbum = async () => {
await downloadAlbum(album);
};
@@ -385,16 +369,17 @@
);
const handleShare = async () => {
const result = await modalManager.show(AlbumShareModal, { album });
const action = await modalManager.show(AlbumShareModal, { album });
switch (result?.action) {
case 'sharedLink': {
await handleShareLink();
switch (action) {
case 'update': {
await refreshAlbum();
viewMode = AlbumPageViewMode.VIEW;
return;
}
case 'sharedUsers': {
await handleAddUsers(result.data);
case 'sharedLink': {
await handleShareLink();
return;
}
}
@@ -470,7 +455,7 @@
{/if}
<!-- ALBUM SHARING -->
{#if album.albumUsers.length > 0 || (album.hasSharedLink && isOwned)}
{#if album.albumUsers.length > 0 || (album.hasSharedLink && isOwned) || groups.length > 0}
<div class="my-3 flex gap-x-1">
<!-- link -->
{#if album.hasSharedLink && isOwned}
@@ -508,6 +493,12 @@
/>
{/if}
{#each groups as group (group.id)}
<!-- <button type="button" onclick={handleEditGroups}> -->
<GroupAvatar {group} />
<!-- </button> -->
{/each}
{#if isOwned}
<IconButton
shape="round"

View File

@@ -1,17 +1,19 @@
import { authenticate } from '$lib/utils/auth';
import { getAssetInfoFromParam } from '$lib/utils/navigation';
import { getAlbumInfo } from '@immich/sdk';
import { getAlbumInfo, getGroupsForAlbum } from '@immich/sdk';
import type { PageLoad } from './$types';
export const load = (async ({ params, url }) => {
await authenticate(url);
const [album, asset] = await Promise.all([
const [album, groups, asset] = await Promise.all([
getAlbumInfo({ id: params.albumId, withoutAssets: true }),
getGroupsForAlbum({ id: params.albumId }),
getAssetInfoFromParam(params),
]);
return {
album,
groups,
asset,
meta: {
title: album.albumName,

View File

@@ -0,0 +1,79 @@
<script lang="ts">
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
import GroupAvatar from '$lib/components/shared-components/GroupAvatar.svelte';
import { AppRoute } from '$lib/constants';
import GroupCreateModal from '$lib/modals/GroupCreateModal.svelte';
import { searchGroupsAdmin, type GroupAdminResponseDto } from '@immich/sdk';
import { Button, HStack, IconButton, Text, modalManager } from '@immich/ui';
import { mdiEyeOutline, mdiPlusBoxOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
interface Props {
data: PageData;
}
let { data }: Props = $props();
let groups: GroupAdminResponseDto[] = $derived(data.groups);
const refresh = async () => {
groups = await searchGroupsAdmin({});
};
const handleCreate = async () => {
await modalManager.show(GroupCreateModal);
await refresh();
};
</script>
<AdminPageLayout title={data.meta.title}>
{#snippet buttons()}
<HStack gap={1}>
<Button leadingIcon={mdiPlusBoxOutline} onclick={handleCreate} size="small" variant="ghost" color="secondary">
<Text class="hidden md:block">{$t('create_group')}</Text>
</Button>
</HStack>
{/snippet}
<section id="setting-content" class="flex place-content-center sm:mx-4">
<section class="w-full pb-28 lg:w-[850px]">
<table class="my-5 w-full">
<thead
class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-immich-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-primary"
>
<tr class="flex w-full place-items-center">
<th class="p-4 text-start w-4/12 text-sm font-medium">{$t('name')}</th>
<th class="p-4 text-start w-6/12 text-sm font-medium">{$t('description')}</th>
<th class="p-4 text-start w-2/12 text-sm font-medium">{$t('action')}</th>
</tr>
</thead>
<tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray">
{#if groups}
{#each groups as group (group.id)}
<tr
class="flex h-[80px] overflow-hidden w-full place-items-center dark:text-immich-dark-fg even:bg-subtle/20 odd:bg-subtle/80"
>
<td class="p-4 w-4/12 text-ellipsis break-all text-sm">
<div class="flex items-center gap-2">
<GroupAvatar {group} />
{group.name}
</div>
</td>
<td class="p-4 w-6/12 text-ellipsis break-all text-sm">{group.description}</td>
<td class="p-4 w-2/12 flex flex-row flex-wrap gap-x-2 gap-y-1 text-ellipsis break-all text-sm">
<IconButton
shape="round"
size="medium"
icon={mdiEyeOutline}
href={`${AppRoute.ADMIN_GROUPS}/${group.id}`}
aria-label={$t('view_group')}
/>
</td>
</tr>
{/each}
{/if}
</tbody>
</table>
</section>
</section>
</AdminPageLayout>

View File

@@ -0,0 +1,17 @@
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { searchGroupsAdmin } from '@immich/sdk';
import type { PageLoad } from './$types';
export const load = (async ({ url }) => {
await authenticate(url, { admin: true });
const groups = await searchGroupsAdmin({});
const $t = await getFormatter();
return {
groups,
meta: {
title: $t('admin.group_management'),
},
};
}) satisfies PageLoad;

View File

@@ -0,0 +1,191 @@
<script lang="ts">
import { goto } from '$app/navigation';
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
import GroupAvatar from '$lib/components/shared-components/GroupAvatar.svelte';
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
import { AppRoute } from '$lib/constants';
import GroupEditModal from '$lib/modals/GroupEditModal.svelte';
import GroupEditUsersModal from '$lib/modals/GroupEditUsersModal.svelte';
import { deleteGroupAdmin, getUsersForGroupAdmin } from '@immich/sdk';
import {
Alert,
Button,
Card,
CardBody,
CardHeader,
CardTitle,
Code,
Container,
Heading,
HStack,
Icon,
IconButton,
modalManager,
Stack,
Text,
} from '@immich/ui';
import {
mdiAccountMultipleOutline,
mdiAccountOutline,
mdiEyeOutline,
mdiPencilOutline,
mdiTrashCanOutline,
} from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
interface Props {
data: PageData;
}
let { data }: Props = $props();
let group = $derived(data.group);
let users = $derived(data.users);
const handleEdit = async () => {
const result = await modalManager.show(GroupEditModal, { group });
if (result) {
group = result;
}
};
const handleDelete = async () => {
const confirmed = await modalManager.showDialog({
prompt: $t('confirm_delete_name', { values: { name: group.name } }),
confirmColor: 'danger',
icon: mdiTrashCanOutline,
});
if (confirmed) {
await deleteGroupAdmin({ id: group.id });
await goto(AppRoute.ADMIN_GROUPS);
}
};
const handleEditUsers = async () => {
const changed = await modalManager.show(GroupEditUsersModal, { group, users });
if (changed) {
users = await getUsersForGroupAdmin({ id: group.id });
}
};
</script>
<AdminPageLayout title={data.meta.title}>
{#snippet buttons()}
<HStack gap={0}>
<Button
color="secondary"
size="small"
variant="ghost"
leadingIcon={mdiPencilOutline}
onclick={() => handleEdit()}
>
<Text class="hidden md:block">{$t('edit_group')}</Text>
</Button>
<Button
color="danger"
size="small"
variant="ghost"
leadingIcon={mdiTrashCanOutline}
onclick={() => handleDelete()}
>
<Text class="hidden md:block">{$t('delete_group')}</Text>
</Button>
</HStack>
{/snippet}
<div>
<Container size="large" center>
<div class="col-span-full flex gap-4 items-center my-4">
<GroupAvatar {group} size="giant" />
<div class="flex flex-col gap-1">
<Heading tag="h1" size="large">{group.name}</Heading>
{#if group.description}
<Text color="muted">{group.description}</Text>
{/if}
</div>
</div>
<Stack gap={4}>
<div class="flex flex-col gap-2">
<div class="flex justify-between items-center gap-2">
<div class="flex items-center gap-2 px-4 text-primary my-4">
<Icon icon={mdiAccountMultipleOutline} size="2rem" />
<Heading>Users</Heading>
</div>
<div>
<Button leadingIcon={mdiPencilOutline} color="primary" size="small" onclick={() => handleEditUsers()}
>{users.length === 0 ? $t('add_users') : $t('edit_users')}</Button
>
</div>
</div>
{#if users.length === 0}
<Alert color="secondary" title={$t('empty_group_message')} icon={mdiAccountMultipleOutline} />
{:else}
<table class="w-full">
<thead
class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-immich-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-primary"
>
<tr class="flex w-full place-items-center">
<th class="p-4 text-start w-4/12 text-sm font-medium">{$t('name')}</th>
<th class="p-4 text-start w-6/12 text-sm font-medium">{$t('email')}</th>
<th class="p-4 text-start w-2/12 text-sm font-medium">{$t('action')}</th>
</tr>
</thead>
<tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray">
{#each users as user (user.id)}
<tr
class="flex h-[80px] overflow-hidden w-full place-items-center dark:text-immich-dark-fg even:bg-subtle/20 odd:bg-subtle/80"
>
<td class="px-4 py-2 w-4/12 text-ellipsis break-all text-sm">
<div class="flex items-center gap-2">
<UserAvatar {user} size="sm" />
{user.name}
</div>
</td>
<td class="px-4 py-2 w-6/12 text-ellipsis break-all text-sm">{user.email}</td>
<td class="px-4 w-2/12 flex flex-row flex-wrap gap-x-2 gap-y-1 text-ellipsis break-all text-sm">
<IconButton
shape="round"
size="medium"
icon={mdiEyeOutline}
href={`${AppRoute.ADMIN_USERS}/${user.id}`}
aria-label={$t('view_user')}
/>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
<Card color="secondary">
<CardHeader>
<div class="flex items-center gap-2 px-4 py-2 text-primary">
<Icon icon={mdiAccountOutline} size="1.5rem" />
<CardTitle>{$t('details')}</CardTitle>
</div>
</CardHeader>
<CardBody>
<div class="px-4 pb-7">
<Stack gap={2}>
<div>
<Heading tag="h3" size="tiny">{$t('created_at')}</Heading>
<Text>{group.createdAt}</Text>
</div>
<div>
<Heading tag="h3" size="tiny">{$t('updated_at')}</Heading>
<Text>{group.updatedAt}</Text>
</div>
<div>
<Heading tag="h3" size="tiny">{$t('id')}</Heading>
<Code>{group.id}</Code>
</div>
</Stack>
</div>
</CardBody>
</Card>
</Stack>
</Container>
</div>
</AdminPageLayout>

View File

@@ -0,0 +1,26 @@
import { AppRoute } from '$lib/constants';
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getUsersForGroupAdmin, searchGroupsAdmin } from '@immich/sdk';
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
export const load = (async ({ params, url }) => {
await authenticate(url, { admin: true });
const $t = await getFormatter();
const [group] = await searchGroupsAdmin({ id: params.id }).catch(() => []);
if (!group) {
redirect(302, AppRoute.ADMIN_GROUPS);
}
const users = await getUsersForGroupAdmin({ id: params.id });
return {
group,
users,
meta: {
title: $t('admin.group_details'),
},
};
}) satisfies PageLoad;