mirror of
https://github.com/open-webui/open-webui.git
synced 2026-08-28 05:27:35 +00:00
Some checks failed
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_CUDA=true
USE_CUDA_VER=cu126
free_disk:true name:cuda126 suffix:-cuda126]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_CUDA=true free_disk:true name:cuda suffix:-cuda]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_OLLAMA=true free_disk:false name:ollama suffix:-ollama]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_SLIM=true free_disk:false name:slim suffix:-slim]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_OLLAMA=true free_disk:false name:ollama suffix:-ollama]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_SLIM=true free_disk:false name:slim suffix:-slim]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args: free_disk:false name:main suffix:]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args: free_disk:false name:main suffix:]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_CUDA=true
USE_CUDA_VER=cu126
free_disk:true name:cuda126 suffix:-cuda126]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_CUDA=true free_disk:true name:cuda suffix:-cuda]) (push) Has been cancelled
Frontend Build / Format & Build (push) Has been cancelled
Frontend Build / Unit Tests (push) Has been cancelled
Python CI / Ruff Format (3.11) (push) Has been cancelled
Python CI / Ruff Format (3.12) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:cuda suffix:-cuda]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Has been cancelled
Create and publish Docker images with specific build args / notify-helm-charts (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Has been cancelled
544 lines
16 KiB
Svelte
544 lines
16 KiB
Svelte
<script lang="ts">
|
|
import { WEBUI_API_BASE_URL } from '$lib/constants';
|
|
import { adminUserCount, config, user } from '$lib/stores';
|
|
import { getContext, onDestroy } from 'svelte';
|
|
|
|
import dayjs from 'dayjs';
|
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
|
import localizedFormat from 'dayjs/plugin/localizedFormat';
|
|
dayjs.extend(relativeTime);
|
|
dayjs.extend(localizedFormat);
|
|
|
|
import { toast } from 'svelte-sonner';
|
|
|
|
import { getUsers, deleteUserById } from '$lib/apis/users';
|
|
|
|
import Pagination from '$lib/components/common/Pagination.svelte';
|
|
import ChatBubbles from '$lib/components/icons/ChatBubbles.svelte';
|
|
import EditPencil from '$lib/components/icons/EditPencil.svelte';
|
|
import Eye from '$lib/components/icons/Eye.svelte';
|
|
import Trash from '$lib/components/icons/Trash.svelte';
|
|
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
|
|
|
import EditUserModal from '$lib/components/admin/Users/UserList/EditUserModal.svelte';
|
|
import UserChatsModal from '$lib/components/admin/Users/UserList/UserChatsModal.svelte';
|
|
import AddUserModal from '$lib/components/admin/Users/UserList/AddUserModal.svelte';
|
|
|
|
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
|
|
|
import Search from '$lib/components/icons/Search.svelte';
|
|
import XMark from '$lib/components/icons/XMark.svelte';
|
|
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
|
|
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
|
|
import Banner from '$lib/components/common/Banner.svelte';
|
|
import Spinner from '$lib/components/common/Spinner.svelte';
|
|
import ProfilePreview from '$lib/components/channel/Messages/Message/ProfilePreview.svelte';
|
|
import UserPreviewModal from '$lib/components/admin/UserPreviewModal.svelte';
|
|
|
|
const i18n = getContext('i18n');
|
|
|
|
let page = 1;
|
|
|
|
let users = null;
|
|
let total = null;
|
|
|
|
let query = '';
|
|
let searchDebounceTimer: ReturnType<typeof setTimeout>;
|
|
let orderBy = 'created_at'; // default sort key
|
|
let direction = 'asc'; // default sort order
|
|
|
|
let selectedUser = null;
|
|
|
|
let showDeleteConfirmDialog = false;
|
|
let showAddUserModal = false;
|
|
|
|
let showUserChatsModal = false;
|
|
let showEditUserModal = false;
|
|
let showUserPreviewModal = false;
|
|
|
|
const deleteUserHandler = async (id) => {
|
|
const res = await deleteUserById(localStorage.token, id).catch((error) => {
|
|
toast.error(`${error}`);
|
|
return null;
|
|
});
|
|
|
|
// if the user is deleted and the current page has only one user, go back to the previous page
|
|
if (users.length === 1 && page > 1) {
|
|
page -= 1;
|
|
}
|
|
|
|
if (res) {
|
|
getUserList();
|
|
}
|
|
};
|
|
|
|
const sortState = (key) =>
|
|
orderBy === key ? (direction === 'asc' ? 'ascending' : 'descending') : 'none';
|
|
|
|
const setSortKey = (key) => {
|
|
if (orderBy === key) {
|
|
direction = direction === 'asc' ? 'desc' : 'asc';
|
|
} else {
|
|
orderBy = key;
|
|
direction = 'asc';
|
|
}
|
|
};
|
|
|
|
const roleClass = (role) => {
|
|
if (role === 'admin') {
|
|
return 'text-[#4f6f93] dark:text-[#8ba6c6]';
|
|
}
|
|
if (role === 'user') {
|
|
return 'text-[#4f7a5a] dark:text-[#8db395]';
|
|
}
|
|
return 'text-gray-500 dark:text-gray-400';
|
|
};
|
|
|
|
const getUserList = async () => {
|
|
try {
|
|
const res = await getUsers(localStorage.token, query, orderBy, direction, page).catch(
|
|
(error) => {
|
|
toast.error(`${error}`);
|
|
return null;
|
|
}
|
|
);
|
|
|
|
if (res) {
|
|
users = res.users;
|
|
total = res.total;
|
|
adminUserCount.set(total);
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
}
|
|
};
|
|
|
|
const handleSearchInput = () => {
|
|
clearTimeout(searchDebounceTimer);
|
|
searchDebounceTimer = setTimeout(() => {
|
|
if (page !== 1) {
|
|
page = 1;
|
|
} else {
|
|
getUserList();
|
|
}
|
|
}, 300);
|
|
};
|
|
|
|
$: if (page !== null && orderBy !== null && direction !== null) {
|
|
getUserList();
|
|
}
|
|
|
|
onDestroy(() => {
|
|
clearTimeout(searchDebounceTimer);
|
|
});
|
|
</script>
|
|
|
|
<ConfirmDialog
|
|
bind:show={showDeleteConfirmDialog}
|
|
on:confirm={() => {
|
|
deleteUserHandler(selectedUser.id);
|
|
}}
|
|
/>
|
|
|
|
<AddUserModal
|
|
bind:show={showAddUserModal}
|
|
on:save={async () => {
|
|
getUserList();
|
|
}}
|
|
/>
|
|
|
|
<EditUserModal
|
|
bind:show={showEditUserModal}
|
|
{selectedUser}
|
|
sessionUser={$user}
|
|
on:save={async () => {
|
|
getUserList();
|
|
}}
|
|
/>
|
|
|
|
{#if selectedUser}
|
|
<UserChatsModal bind:show={showUserChatsModal} user={selectedUser} />
|
|
{/if}
|
|
|
|
{#if ($config?.license_metadata?.seats ?? null) !== null && total && total > $config?.license_metadata?.seats}
|
|
<div class=" mt-1 mb-2 text-xs text-red-500">
|
|
<Banner
|
|
className="mx-0"
|
|
banner={{
|
|
type: 'error',
|
|
title: 'License Error',
|
|
content:
|
|
'Exceeded the number of seats in your license. Please contact support to increase the number of seats.'
|
|
}}
|
|
/>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if users === null || total === null}
|
|
<div class="my-10">
|
|
<Spinner className="size-5" />
|
|
</div>
|
|
{:else}
|
|
<div class="sticky top-0 z-10 bg-white dark:bg-gray-900">
|
|
<div class="flex h-8 flex-1 items-center w-full gap-2">
|
|
<div class="flex min-w-0 flex-1 items-center">
|
|
<div class="self-center ml-1 mr-3">
|
|
<Search className="size-3.5" />
|
|
</div>
|
|
<input
|
|
class="w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
|
|
bind:value={query}
|
|
on:input={handleSearchInput}
|
|
aria-label={$i18n.t('Search')}
|
|
placeholder={$i18n.t('Search')}
|
|
/>
|
|
|
|
{#if query}
|
|
<div class="self-center pl-1.5 translate-y-[0.5px] rounded-l-xl bg-transparent">
|
|
<button
|
|
class="p-0.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-900 transition"
|
|
aria-label={$i18n.t('Clear search')}
|
|
on:click={() => {
|
|
query = '';
|
|
handleSearchInput();
|
|
}}
|
|
>
|
|
<XMark className="size-3" strokeWidth="2" />
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<button
|
|
class="ml-1 shrink-0 rounded-lg bg-gray-50 px-2.5 py-1 text-xs text-gray-900 transition ring-1 ring-gray-200 hover:bg-gray-100 dark:bg-gray-850 dark:text-gray-100 dark:ring-gray-800 dark:hover:bg-gray-800"
|
|
on:click={() => {
|
|
showAddUserModal = !showAddUserModal;
|
|
}}
|
|
>
|
|
{$i18n.t('Add User')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full">
|
|
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto max-w-full">
|
|
<thead class="text-xs text-gray-800 uppercase bg-transparent dark:text-gray-200">
|
|
<tr class=" border-b-[1.5px] border-gray-50 dark:border-gray-850/30">
|
|
<th scope="col" class="font-normal select-none" aria-sort={sortState('name')}>
|
|
<button
|
|
type="button"
|
|
class="flex w-full gap-1.5 items-center px-2.5 py-1.5"
|
|
on:click={() => setSortKey('name')}
|
|
>
|
|
{$i18n.t('Name')}
|
|
|
|
{#if orderBy === 'name'}
|
|
<span class="font-normal"
|
|
>{#if direction === 'asc'}
|
|
<ChevronUp className="size-2" />
|
|
{:else}
|
|
<ChevronDown className="size-2" />
|
|
{/if}
|
|
</span>
|
|
{:else}
|
|
<span class="invisible">
|
|
<ChevronUp className="size-2" />
|
|
</span>
|
|
{/if}
|
|
</button>
|
|
</th>
|
|
<th scope="col" class="font-normal select-none" aria-sort={sortState('role')}>
|
|
<button
|
|
type="button"
|
|
class="flex w-full gap-1.5 items-center px-2.5 py-1.5"
|
|
on:click={() => setSortKey('role')}
|
|
>
|
|
{$i18n.t('Role')}
|
|
|
|
{#if orderBy === 'role'}
|
|
<span class="font-normal"
|
|
>{#if direction === 'asc'}
|
|
<ChevronUp className="size-2" />
|
|
{:else}
|
|
<ChevronDown className="size-2" />
|
|
{/if}
|
|
</span>
|
|
{:else}
|
|
<span class="invisible">
|
|
<ChevronUp className="size-2" />
|
|
</span>
|
|
{/if}
|
|
</button>
|
|
</th>
|
|
<th scope="col" class="font-normal select-none" aria-sort={sortState('email')}>
|
|
<button
|
|
type="button"
|
|
class="flex w-full gap-1.5 items-center px-2.5 py-1.5"
|
|
on:click={() => setSortKey('email')}
|
|
>
|
|
{$i18n.t('Email')}
|
|
|
|
{#if orderBy === 'email'}
|
|
<span class="font-normal"
|
|
>{#if direction === 'asc'}
|
|
<ChevronUp className="size-2" />
|
|
{:else}
|
|
<ChevronDown className="size-2" />
|
|
{/if}
|
|
</span>
|
|
{:else}
|
|
<span class="invisible">
|
|
<ChevronUp className="size-2" />
|
|
</span>
|
|
{/if}
|
|
</button>
|
|
</th>
|
|
|
|
<th scope="col" class="font-normal select-none" aria-sort={sortState('last_active_at')}>
|
|
<button
|
|
type="button"
|
|
class="flex w-full gap-1.5 items-center px-2.5 py-1.5"
|
|
on:click={() => setSortKey('last_active_at')}
|
|
>
|
|
{$i18n.t('Last Active')}
|
|
<!-- {$i18n.t('Last Modified')} -->
|
|
|
|
{#if orderBy === 'last_active_at'}
|
|
<span class="font-normal"
|
|
>{#if direction === 'asc'}
|
|
<ChevronUp className="size-2" />
|
|
{:else}
|
|
<ChevronDown className="size-2" />
|
|
{/if}
|
|
</span>
|
|
{:else}
|
|
<span class="invisible">
|
|
<ChevronUp className="size-2" />
|
|
</span>
|
|
{/if}
|
|
</button>
|
|
</th>
|
|
<th scope="col" class="font-normal select-none" aria-sort={sortState('created_at')}>
|
|
<button
|
|
type="button"
|
|
class="flex w-full gap-1.5 items-center px-2.5 py-1.5"
|
|
on:click={() => setSortKey('created_at')}
|
|
>
|
|
{$i18n.t('Created at')}
|
|
{#if orderBy === 'created_at'}
|
|
<span class="font-normal"
|
|
>{#if direction === 'asc'}
|
|
<ChevronUp className="size-2" />
|
|
{:else}
|
|
<ChevronDown className="size-2" />
|
|
{/if}
|
|
</span>
|
|
{:else}
|
|
<span class="invisible">
|
|
<ChevronUp className="size-2" />
|
|
</span>
|
|
{/if}
|
|
</button>
|
|
</th>
|
|
|
|
<th scope="col" class="px-2.5 py-1.5 font-normal text-right"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="">
|
|
{#each users as user (user.id)}
|
|
<tr class="dark:border-gray-850 text-xs">
|
|
<td class="px-3 py-1 font-normal text-gray-900 dark:text-white max-w-48">
|
|
<div class="flex items-center gap-2">
|
|
<ProfilePreview {user} side="right" align="center" sideOffset={6}>
|
|
<img
|
|
class="rounded-full size-5.5 object-cover flex-shrink-0"
|
|
src={`${WEBUI_API_BASE_URL}/users/${user.id}/profile/image`}
|
|
alt="user"
|
|
on:error={(e) => {
|
|
// LICENSE covers this Open WebUI fallback logo.
|
|
// Do not alter, remove, obscure, or replace it except as LICENSE permits:
|
|
// https://docs.openwebui.com/license.
|
|
e.currentTarget.src = '/favicon.png';
|
|
}}
|
|
/>
|
|
</ProfilePreview>
|
|
|
|
<div class="font-normal truncate">{user.name}</div>
|
|
|
|
{#if user?.last_active_at && Date.now() / 1000 - user.last_active_at < 180}
|
|
<div>
|
|
<span class="relative flex size-1.5">
|
|
<span
|
|
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75"
|
|
></span>
|
|
<span class="relative inline-flex size-1.5 rounded-full bg-green-500"></span>
|
|
</span>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</td>
|
|
<td class="px-3 py-1 min-w-[5rem] w-20">
|
|
<button
|
|
class="text-xs font-normal leading-4 capitalize transition {roleClass(user.role)}"
|
|
aria-label={$i18n.t('Change User Role')}
|
|
on:click={() => {
|
|
selectedUser = user;
|
|
showEditUserModal = !showEditUserModal;
|
|
}}
|
|
>
|
|
{$i18n.t(user.role)}
|
|
</button>
|
|
</td>
|
|
<td class=" px-3 py-1 max-w-48 truncate"> {user.email} </td>
|
|
|
|
<td class=" px-3 py-1">
|
|
{dayjs(user.last_active_at * 1000).fromNow()}
|
|
</td>
|
|
|
|
<td class=" px-3 py-1">
|
|
{dayjs(user.created_at * 1000).format('LL')}
|
|
</td>
|
|
|
|
<td class="px-3 py-1 text-right">
|
|
<div class="flex justify-end w-full">
|
|
{#if $config.features.enable_admin_chat_access && user.role !== 'admin'}
|
|
<Tooltip content={$i18n.t('Chats')}>
|
|
<button
|
|
class="self-center w-fit p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg"
|
|
aria-label={$i18n.t('Chats')}
|
|
on:click={async () => {
|
|
showUserChatsModal = !showUserChatsModal;
|
|
selectedUser = user;
|
|
}}
|
|
>
|
|
<ChatBubbles className="size-3.5" />
|
|
</button>
|
|
</Tooltip>
|
|
{/if}
|
|
|
|
{#if user.role !== 'admin'}
|
|
<Tooltip content={$i18n.t('Preview Access')}>
|
|
<button
|
|
class="self-center w-fit p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg"
|
|
aria-label={$i18n.t('Preview Access')}
|
|
on:click={() => {
|
|
selectedUser = user;
|
|
showUserPreviewModal = true;
|
|
}}
|
|
>
|
|
<Eye className="size-3.5" />
|
|
</button>
|
|
</Tooltip>
|
|
{/if}
|
|
|
|
<Tooltip content={$i18n.t('Edit User')}>
|
|
<button
|
|
class="self-center w-fit p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg"
|
|
aria-label={$i18n.t('Edit User')}
|
|
on:click={async () => {
|
|
showEditUserModal = !showEditUserModal;
|
|
selectedUser = user;
|
|
}}
|
|
>
|
|
<EditPencil className="size-3.5" />
|
|
</button>
|
|
</Tooltip>
|
|
|
|
{#if user.role !== 'admin'}
|
|
<Tooltip content={$i18n.t('Delete User')}>
|
|
<button
|
|
class="self-center w-fit p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg"
|
|
aria-label={$i18n.t('Delete User')}
|
|
on:click={async () => {
|
|
showDeleteConfirmDialog = true;
|
|
selectedUser = user;
|
|
}}
|
|
>
|
|
<Trash className="size-3.5" />
|
|
</button>
|
|
</Tooltip>
|
|
{/if}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div class=" text-gray-500 text-xs mt-1.5 text-right">
|
|
ⓘ {$i18n.t("Click on the user role button to change a user's role.")}
|
|
</div>
|
|
|
|
{#if total > 30}
|
|
<Pagination bind:page count={total} perPage={30} />
|
|
{/if}
|
|
{/if}
|
|
|
|
{#if !$config?.license_metadata}
|
|
{#if total > 50}
|
|
<!-- LICENSE covers the Open WebUI branding narrative here.
|
|
Do not alter, remove, obscure, or replace it except as LICENSE permits:
|
|
https://docs.openwebui.com/license. -->
|
|
<div class="mt-3 mb-3 pb-1 text-gray-700 dark:text-gray-300">
|
|
<div class="max-w-3xl text-xs leading-5">
|
|
<div class="text-gray-900 dark:text-gray-100">
|
|
<!-- LICENSE covers this Open WebUI wordmark.
|
|
Do not alter, remove, obscure, or replace it except as LICENSE permits:
|
|
https://docs.openwebui.com/license. -->
|
|
{$i18n.t('Running Open WebUI for a team?')}
|
|
</div>
|
|
<div class="mt-2 space-y-2">
|
|
<p>
|
|
<!-- LICENSE covers this Open WebUI branding copy.
|
|
Do not alter, remove, obscure, or replace it except as LICENSE permits:
|
|
https://docs.openwebui.com/license. -->
|
|
{$i18n.t(
|
|
'You have more than 50 users, which often means this workspace is supporting organizational use. Open WebUI is free to use as-is, with no restrictions or hidden limits, and we want to keep it that way.'
|
|
)}
|
|
</p>
|
|
<p class="text-gray-500 dark:text-gray-400">
|
|
<!-- LICENSE covers this Open WebUI branding copy.
|
|
Do not alter, remove, obscure, or replace it except as LICENSE permits:
|
|
https://docs.openwebui.com/license. -->
|
|
{$i18n.t(
|
|
'By supporting the project through sponsorship or an enterprise license, you help us stay independent, ship new features faster, improve stability, and grow Open WebUI for the long haul.'
|
|
)}
|
|
</p>
|
|
<p class="text-gray-500 dark:text-gray-400">
|
|
{$i18n.t(
|
|
'Enterprise licenses also include dedicated support, customization options, and more, at a fraction of the cost of building and maintaining this stack internally.'
|
|
)}
|
|
</p>
|
|
</div>
|
|
|
|
<div class="mt-2 flex items-center gap-3">
|
|
<a
|
|
class="text-xs text-gray-700 underline transition hover:text-gray-900 dark:text-gray-300 dark:hover:text-gray-100"
|
|
href="https://docs.openwebui.com/enterprise"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
>
|
|
{$i18n.t('Enterprise licensing')}
|
|
</a>
|
|
<a
|
|
class="text-xs text-gray-500 underline transition hover:text-gray-900 dark:text-gray-500 dark:hover:text-gray-100"
|
|
href="https://github.com/sponsors/open-webui"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
>
|
|
{$i18n.t('Sponsor on GitHub')}
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
|
|
{#if selectedUser}
|
|
<UserPreviewModal
|
|
bind:show={showUserPreviewModal}
|
|
userId={selectedUser.id}
|
|
userName={selectedUser.name}
|
|
/>
|
|
{/if}
|