feat: groups
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
35
web/src/lib/components/shared-components/GroupAvatar.svelte
Normal file
35
web/src/lib/components/shared-components/GroupAvatar.svelte
Normal 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>
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
69
web/src/lib/modals/GroupCreateModal.svelte
Normal file
69
web/src/lib/modals/GroupCreateModal.svelte
Normal 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>
|
||||
62
web/src/lib/modals/GroupEditModal.svelte
Normal file
62
web/src/lib/modals/GroupEditModal.svelte
Normal 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>
|
||||
175
web/src/lib/modals/GroupEditUsersModal.svelte
Normal file
175
web/src/lib/modals/GroupEditUsersModal.svelte
Normal 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>
|
||||
@@ -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} />
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
79
web/src/routes/admin/groups/+page.svelte
Normal file
79
web/src/routes/admin/groups/+page.svelte
Normal 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>
|
||||
17
web/src/routes/admin/groups/+page.ts
Normal file
17
web/src/routes/admin/groups/+page.ts
Normal 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;
|
||||
191
web/src/routes/admin/groups/[id]/+page.svelte
Normal file
191
web/src/routes/admin/groups/[id]/+page.svelte
Normal 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>
|
||||
26
web/src/routes/admin/groups/[id]/+page.ts
Normal file
26
web/src/routes/admin/groups/[id]/+page.ts
Normal 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;
|
||||
Reference in New Issue
Block a user