Forráskód Böngészése

refac: user list loading indicator

Timothy Jaeryang Baek 5 hónapja
szülő
commit
015ac2f532
1 módosított fájl, 307 hozzáadás és 300 törlés
  1. 307 300
      src/lib/components/admin/Users/UserList.svelte

+ 307 - 300
src/lib/components/admin/Users/UserList.svelte

@@ -32,13 +32,14 @@
 	import About from '$lib/components/chat/Settings/About.svelte';
 	import Banner from '$lib/components/common/Banner.svelte';
 	import Markdown from '$lib/components/chat/Messages/Markdown.svelte';
+	import Spinner from '$lib/components/common/Spinner.svelte';
 
 	const i18n = getContext('i18n');
 
 	let page = 1;
 
-	let users = [];
-	let total = 0;
+	let users = null;
+	let total = null;
 
 	let query = '';
 	let orderBy = 'created_at'; // default sort key
@@ -181,314 +182,293 @@
 	</div>
 {/if}
 
-<div class="mt-0.5 mb-2 gap-1 flex flex-col md:flex-row justify-between">
-	<div class="flex md:self-center text-lg font-medium px-0.5">
-		<div class="flex-shrink-0">
-			{$i18n.t('Users')}
-		</div>
-		<div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-50 dark:bg-gray-850" />
-
-		{#if ($config?.license_metadata?.seats ?? null) !== null}
-			{#if total > $config?.license_metadata?.seats}
-				<span class="text-lg font-medium text-red-500"
-					>{total} of {$config?.license_metadata?.seats}
-					<span class="text-sm font-normal">available users</span></span
-				>
-			{:else}
-				<span class="text-lg font-medium text-gray-500 dark:text-gray-300"
-					>{total} of {$config?.license_metadata?.seats}
-					<span class="text-sm font-normal">available users</span></span
-				>
-			{/if}
-		{:else}
-			<span class="text-lg font-medium text-gray-500 dark:text-gray-300">{total}</span>
-		{/if}
+{#if users === null || total === null}
+	<div class="my-10">
+		<Spinner />
 	</div>
+{:else}
+	<div class="mt-0.5 mb-2 gap-1 flex flex-col md:flex-row justify-between">
+		<div class="flex md:self-center text-lg font-medium px-0.5">
+			<div class="flex-shrink-0">
+				{$i18n.t('Users')}
+			</div>
+			<div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-50 dark:bg-gray-850" />
 
-	<div class="flex gap-1">
-		<div class=" flex w-full space-x-2">
-			<div class="flex flex-1">
-				<div class=" self-center ml-1 mr-3">
-					<svg
-						xmlns="http://www.w3.org/2000/svg"
-						viewBox="0 0 20 20"
-						fill="currentColor"
-						class="w-4 h-4"
+			{#if ($config?.license_metadata?.seats ?? null) !== null}
+				{#if total > $config?.license_metadata?.seats}
+					<span class="text-lg font-medium text-red-500"
+						>{total} of {$config?.license_metadata?.seats}
+						<span class="text-sm font-normal">available users</span></span
+					>
+				{:else}
+					<span class="text-lg font-medium text-gray-500 dark:text-gray-300"
+						>{total} of {$config?.license_metadata?.seats}
+						<span class="text-sm font-normal">available users</span></span
 					>
-						<path
-							fill-rule="evenodd"
-							d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z"
-							clip-rule="evenodd"
-						/>
-					</svg>
+				{/if}
+			{:else}
+				<span class="text-lg font-medium text-gray-500 dark:text-gray-300">{total}</span>
+			{/if}
+		</div>
+
+		<div class="flex gap-1">
+			<div class=" flex w-full space-x-2">
+				<div class="flex flex-1">
+					<div class=" self-center ml-1 mr-3">
+						<svg
+							xmlns="http://www.w3.org/2000/svg"
+							viewBox="0 0 20 20"
+							fill="currentColor"
+							class="w-4 h-4"
+						>
+							<path
+								fill-rule="evenodd"
+								d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z"
+								clip-rule="evenodd"
+							/>
+						</svg>
+					</div>
+					<input
+						class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
+						bind:value={query}
+						placeholder={$i18n.t('Search')}
+					/>
 				</div>
-				<input
-					class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
-					bind:value={query}
-					placeholder={$i18n.t('Search')}
-				/>
-			</div>
 
-			<div>
-				<Tooltip content={$i18n.t('Add User')}>
-					<button
-						class=" p-2 rounded-xl hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-850 transition font-medium text-sm flex items-center space-x-1"
-						on:click={() => {
-							showAddUserModal = !showAddUserModal;
-						}}
-					>
-						<Plus className="size-3.5" />
-					</button>
-				</Tooltip>
+				<div>
+					<Tooltip content={$i18n.t('Add User')}>
+						<button
+							class=" p-2 rounded-xl hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-850 transition font-medium text-sm flex items-center space-x-1"
+							on:click={() => {
+								showAddUserModal = !showAddUserModal;
+							}}
+						>
+							<Plus className="size-3.5" />
+						</button>
+					</Tooltip>
+				</div>
 			</div>
 		</div>
 	</div>
-</div>
 
-<div
-	class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full rounded-sm pt-0.5"
->
-	<table
-		class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto max-w-full rounded-sm"
+	<div
+		class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full rounded-sm pt-0.5"
 	>
-		<thead
-			class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-850 dark:text-gray-400 -translate-y-0.5"
+		<table
+			class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto max-w-full rounded-sm"
 		>
-			<tr class="">
-				<th
-					scope="col"
-					class="px-3 py-1.5 cursor-pointer select-none"
-					on:click={() => setSortKey('role')}
-				>
-					<div class="flex gap-1.5 items-center">
-						{$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}
-					</div>
-				</th>
-				<th
-					scope="col"
-					class="px-3 py-1.5 cursor-pointer select-none"
-					on:click={() => setSortKey('name')}
-				>
-					<div class="flex gap-1.5 items-center">
-						{$i18n.t('Name')}
-
-						{#if orderBy === 'name'}
-							<span class="font-normal"
-								>{#if direction === 'asc'}
+			<thead
+				class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-850 dark:text-gray-400 -translate-y-0.5"
+			>
+				<tr class="">
+					<th
+						scope="col"
+						class="px-3 py-1.5 cursor-pointer select-none"
+						on:click={() => setSortKey('role')}
+					>
+						<div class="flex gap-1.5 items-center">
+							{$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" />
-								{:else}
-									<ChevronDown className="size-2" />
-								{/if}
-							</span>
-						{:else}
-							<span class="invisible">
-								<ChevronUp className="size-2" />
-							</span>
-						{/if}
-					</div>
-				</th>
-				<th
-					scope="col"
-					class="px-3 py-1.5 cursor-pointer select-none"
-					on:click={() => setSortKey('email')}
-				>
-					<div class="flex gap-1.5 items-center">
-						{$i18n.t('Email')}
-
-						{#if orderBy === 'email'}
-							<span class="font-normal"
-								>{#if direction === 'asc'}
+								</span>
+							{/if}
+						</div>
+					</th>
+					<th
+						scope="col"
+						class="px-3 py-1.5 cursor-pointer select-none"
+						on:click={() => setSortKey('name')}
+					>
+						<div class="flex gap-1.5 items-center">
+							{$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" />
-								{:else}
-									<ChevronDown className="size-2" />
-								{/if}
-							</span>
-						{:else}
-							<span class="invisible">
-								<ChevronUp className="size-2" />
-							</span>
-						{/if}
-					</div>
-				</th>
-
-				<th
-					scope="col"
-					class="px-3 py-1.5 cursor-pointer select-none"
-					on:click={() => setSortKey('last_active_at')}
-				>
-					<div class="flex gap-1.5 items-center">
-						{$i18n.t('Last Active')}
-
-						{#if orderBy === 'last_active_at'}
-							<span class="font-normal"
-								>{#if direction === 'asc'}
+								</span>
+							{/if}
+						</div>
+					</th>
+					<th
+						scope="col"
+						class="px-3 py-1.5 cursor-pointer select-none"
+						on:click={() => setSortKey('email')}
+					>
+						<div class="flex gap-1.5 items-center">
+							{$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" />
-								{:else}
-									<ChevronDown className="size-2" />
-								{/if}
-							</span>
-						{:else}
-							<span class="invisible">
-								<ChevronUp className="size-2" />
-							</span>
-						{/if}
-					</div>
-				</th>
-				<th
-					scope="col"
-					class="px-3 py-1.5 cursor-pointer select-none"
-					on:click={() => setSortKey('created_at')}
-				>
-					<div class="flex gap-1.5 items-center">
-						{$i18n.t('Created at')}
-						{#if orderBy === 'created_at'}
-							<span class="font-normal"
-								>{#if direction === 'asc'}
+								</span>
+							{/if}
+						</div>
+					</th>
+
+					<th
+						scope="col"
+						class="px-3 py-1.5 cursor-pointer select-none"
+						on:click={() => setSortKey('last_active_at')}
+					>
+						<div class="flex gap-1.5 items-center">
+							{$i18n.t('Last Active')}
+
+							{#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" />
-								{:else}
-									<ChevronDown className="size-2" />
-								{/if}
-							</span>
-						{:else}
-							<span class="invisible">
-								<ChevronUp className="size-2" />
-							</span>
-						{/if}
-					</div>
-				</th>
-
-				<th
-					scope="col"
-					class="px-3 py-1.5 cursor-pointer select-none"
-					on:click={() => setSortKey('oauth_sub')}
-				>
-					<div class="flex gap-1.5 items-center">
-						{$i18n.t('OAuth ID')}
-
-						{#if orderBy === 'oauth_sub'}
-							<span class="font-normal"
-								>{#if direction === 'asc'}
+								</span>
+							{/if}
+						</div>
+					</th>
+					<th
+						scope="col"
+						class="px-3 py-1.5 cursor-pointer select-none"
+						on:click={() => setSortKey('created_at')}
+					>
+						<div class="flex gap-1.5 items-center">
+							{$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" />
-								{:else}
-									<ChevronDown className="size-2" />
-								{/if}
-							</span>
-						{:else}
-							<span class="invisible">
-								<ChevronUp className="size-2" />
-							</span>
-						{/if}
-					</div>
-				</th>
-
-				<th scope="col" class="px-3 py-2 text-right" />
-			</tr>
-		</thead>
-		<tbody class="">
-			{#each users as user, userIdx}
-				<tr class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs">
-					<td class="px-3 py-1 min-w-[7rem] w-28">
-						<button
-							class=" translate-y-0.5"
-							on:click={() => {
-								selectedUser = user;
-								showUpdateRoleModal = true;
-							}}
-						>
-							<Badge
-								type={user.role === 'admin' ? 'info' : user.role === 'user' ? 'success' : 'muted'}
-								content={$i18n.t(user.role)}
-							/>
-						</button>
-					</td>
-					<td class="px-3 py-1 font-medium text-gray-900 dark:text-white w-max">
-						<div class="flex flex-row w-max">
-							<img
-								class=" rounded-full w-6 h-6 object-cover mr-2.5"
-								src={user.profile_image_url.startsWith(WEBUI_BASE_URL) ||
-								user.profile_image_url.startsWith('https://www.gravatar.com/avatar/') ||
-								user.profile_image_url.startsWith('data:')
-									? user.profile_image_url
-									: `/user.png`}
-								alt="user"
-							/>
-
-							<div class=" font-medium self-center">{user.name}</div>
+								</span>
+							{/if}
 						</div>
-					</td>
-					<td class=" px-3 py-1"> {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>
+					</th>
 
-					<td class=" px-3 py-1"> {user.oauth_sub ?? ''} </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 text-sm px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
-										on:click={async () => {
-											showUserChatsModal = !showUserChatsModal;
-											selectedUser = user;
-										}}
-									>
-										<ChatBubbles />
-									</button>
-								</Tooltip>
+					<th
+						scope="col"
+						class="px-3 py-1.5 cursor-pointer select-none"
+						on:click={() => setSortKey('oauth_sub')}
+					>
+						<div class="flex gap-1.5 items-center">
+							{$i18n.t('OAuth ID')}
+
+							{#if orderBy === 'oauth_sub'}
+								<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}
+						</div>
+					</th>
 
-							<Tooltip content={$i18n.t('Edit User')}>
-								<button
-									class="self-center w-fit text-sm px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
-									on:click={async () => {
-										showEditUserModal = !showEditUserModal;
-										selectedUser = user;
-									}}
-								>
-									<svg
-										xmlns="http://www.w3.org/2000/svg"
-										fill="none"
-										viewBox="0 0 24 24"
-										stroke-width="1.5"
-										stroke="currentColor"
-										class="w-4 h-4"
-									>
-										<path
-											stroke-linecap="round"
-											stroke-linejoin="round"
-											d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125"
-										/>
-									</svg>
-								</button>
-							</Tooltip>
-
-							{#if user.role !== 'admin'}
-								<Tooltip content={$i18n.t('Delete User')}>
+					<th scope="col" class="px-3 py-2 text-right" />
+				</tr>
+			</thead>
+			<tbody class="">
+				{#each users as user, userIdx}
+					<tr class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs">
+						<td class="px-3 py-1 min-w-[7rem] w-28">
+							<button
+								class=" translate-y-0.5"
+								on:click={() => {
+									selectedUser = user;
+									showUpdateRoleModal = true;
+								}}
+							>
+								<Badge
+									type={user.role === 'admin' ? 'info' : user.role === 'user' ? 'success' : 'muted'}
+									content={$i18n.t(user.role)}
+								/>
+							</button>
+						</td>
+						<td class="px-3 py-1 font-medium text-gray-900 dark:text-white w-max">
+							<div class="flex flex-row w-max">
+								<img
+									class=" rounded-full w-6 h-6 object-cover mr-2.5"
+									src={user.profile_image_url.startsWith(WEBUI_BASE_URL) ||
+									user.profile_image_url.startsWith('https://www.gravatar.com/avatar/') ||
+									user.profile_image_url.startsWith('data:')
+										? user.profile_image_url
+										: `/user.png`}
+									alt="user"
+								/>
+
+								<div class=" font-medium self-center">{user.name}</div>
+							</div>
+						</td>
+						<td class=" px-3 py-1"> {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"> {user.oauth_sub ?? ''} </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 text-sm px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
+											on:click={async () => {
+												showUserChatsModal = !showUserChatsModal;
+												selectedUser = user;
+											}}
+										>
+											<ChatBubbles />
+										</button>
+									</Tooltip>
+								{/if}
+
+								<Tooltip content={$i18n.t('Edit User')}>
 									<button
 										class="self-center w-fit text-sm px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
 										on:click={async () => {
-											showDeleteConfirmDialog = true;
+											showEditUserModal = !showEditUserModal;
 											selectedUser = user;
 										}}
 									>
@@ -503,25 +483,52 @@
 											<path
 												stroke-linecap="round"
 												stroke-linejoin="round"
-												d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
+												d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125"
 											/>
 										</svg>
 									</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 user.role !== 'admin'}
+									<Tooltip content={$i18n.t('Delete User')}>
+										<button
+											class="self-center w-fit text-sm px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
+											on:click={async () => {
+												showDeleteConfirmDialog = true;
+												selectedUser = user;
+											}}
+										>
+											<svg
+												xmlns="http://www.w3.org/2000/svg"
+												fill="none"
+												viewBox="0 0 24 24"
+												stroke-width="1.5"
+												stroke="currentColor"
+												class="w-4 h-4"
+											>
+												<path
+													stroke-linecap="round"
+													stroke-linejoin="round"
+													d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
+												/>
+											</svg>
+										</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>
 
-<Pagination bind:page count={total} perPage={30} />
+	<Pagination bind:page count={total} perPage={30} />
+{/if}
 
 {#if !$config?.license_metadata}
 	{#if total > 50}