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

refac: edit user modal

Timothy Jaeryang Baek 1 hete
szülő
commit
51a138aec3

+ 102 - 103
src/lib/components/admin/Users/UserList/EditUserModal.svelte

@@ -12,6 +12,7 @@
 	import localizedFormat from 'dayjs/plugin/localizedFormat';
 	import XMark from '$lib/components/icons/XMark.svelte';
 	import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
+	import UserProfileImage from '$lib/components/chat/Settings/Account/UserProfileImage.svelte';
 
 	const i18n = getContext('i18n');
 	const dispatch = createEventDispatcher();
@@ -83,120 +84,118 @@
 						submitHandler();
 					}}
 				>
-					<div class=" flex items-center rounded-md px-5 py-2 w-full">
-						<div class=" self-center mr-5">
-							<img
-								src={selectedUser.profile_image_url}
-								class=" max-w-[55px] object-cover rounded-full"
-								alt="User profile"
-							/>
-						</div>
-
-						<div class="overflow-hidden w-full">
-							<div class=" self-center capitalize font-medium truncate">{selectedUser.name}</div>
-
-							<div class="text-xs text-gray-500">
-								{$i18n.t('Created at')}
-								{dayjs(selectedUser.created_at * 1000).format('LL')}
+					<div class=" px-5 pt-3 pb-5 w-full">
+						<div class="flex self-center w-full">
+							<div class=" self-start h-full mr-6">
+								<UserProfileImage bind:profileImageUrl={_user.profile_image_url} user={_user} />
 							</div>
-						</div>
-					</div>
 
-					<div class=" px-5 pt-3 pb-5">
-						<div class=" flex flex-col space-y-1.5">
-							<div class="flex flex-col w-full">
-								<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Role')}</div>
-
-								<div class="flex-1">
-									<select
-										class="w-full dark:bg-gray-900 text-sm bg-transparent disabled:text-gray-500 dark:disabled:text-gray-500 outline-hidden"
-										bind:value={_user.role}
-										disabled={_user.id == sessionUser.id}
-										required
-									>
-										<option value="admin">{$i18n.t('Admin')}</option>
-										<option value="user">{$i18n.t('User')}</option>
-										<option value="pending">{$i18n.t('Pending')}</option>
-									</select>
+							<div class=" flex-1">
+								<div class="overflow-hidden w-ful mb-2">
+									<div class=" self-center capitalize font-medium truncate">
+										{selectedUser.name}
+									</div>
+
+									<div class="text-xs text-gray-500">
+										{$i18n.t('Created at')}
+										{dayjs(selectedUser.created_at * 1000).format('LL')}
+									</div>
 								</div>
-							</div>
 
-							{#if userGroups}
-								<div class="flex flex-col w-full text-sm">
-									<div class="mb-1 text-xs text-gray-500">{$i18n.t('User Groups')}</div>
-
-									{#if userGroups.length}
-										<div class="flex flex-wrap gap-1 my-0.5 -mx-1">
-											{#each userGroups as userGroup}
-												<span class="px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-850 text-xs">
-													<a
-														href={'/admin/users/groups?id=' + userGroup.id}
-														on:click|preventDefault={() =>
-															goto('/admin/users/groups?id=' + userGroup.id)}
+								<div class=" flex flex-col space-y-1.5">
+									{#if (userGroups ?? []).length > 0}
+										<div class="flex flex-col w-full text-sm">
+											<div class="mb-1 text-xs text-gray-500">{$i18n.t('User Groups')}</div>
+
+											<div class="flex flex-wrap gap-1 my-0.5 -mx-1">
+												{#each userGroups as userGroup}
+													<span
+														class="px-1.5 py-0.5 rounded-xl bg-gray-100 dark:bg-gray-850 text-xs"
 													>
-														{userGroup.name}
-													</a>
-												</span>
-											{/each}
+														<a
+															href={'/admin/users/groups?id=' + userGroup.id}
+															on:click|preventDefault={() =>
+																goto('/admin/users/groups?id=' + userGroup.id)}
+														>
+															{userGroup.name}
+														</a>
+													</span>
+												{/each}
+											</div>
 										</div>
-									{:else}
-										<span>-</span>
 									{/if}
-								</div>
-							{/if}
-
-							<div class="flex flex-col w-full">
-								<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Name')}</div>
-
-								<div class="flex-1">
-									<input
-										class="w-full text-sm bg-transparent outline-hidden"
-										type="text"
-										bind:value={_user.name}
-										placeholder={$i18n.t('Enter Your Name')}
-										autocomplete="off"
-										required
-									/>
-								</div>
-							</div>
 
-							<div class="flex flex-col w-full">
-								<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Email')}</div>
-
-								<div class="flex-1">
-									<input
-										class="w-full text-sm bg-transparent disabled:text-gray-500 dark:disabled:text-gray-500 outline-hidden"
-										type="email"
-										bind:value={_user.email}
-										placeholder={$i18n.t('Enter Your Email')}
-										autocomplete="off"
-										required
-									/>
-								</div>
-							</div>
+									<div class="flex flex-col w-full">
+										<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Role')}</div>
+
+										<div class="flex-1">
+											<select
+												class="w-full dark:bg-gray-900 text-sm bg-transparent disabled:text-gray-500 dark:disabled:text-gray-500 outline-hidden"
+												bind:value={_user.role}
+												disabled={_user.id == sessionUser.id}
+												required
+											>
+												<option value="admin">{$i18n.t('Admin')}</option>
+												<option value="user">{$i18n.t('User')}</option>
+												<option value="pending">{$i18n.t('Pending')}</option>
+											</select>
+										</div>
+									</div>
 
-							{#if _user?.oauth_sub}
-								<div class="flex flex-col w-full">
-									<div class=" mb-1 text-xs text-gray-500">{$i18n.t('OAuth ID')}</div>
+									<div class="flex flex-col w-full">
+										<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Name')}</div>
+
+										<div class="flex-1">
+											<input
+												class="w-full text-sm bg-transparent outline-hidden"
+												type="text"
+												bind:value={_user.name}
+												placeholder={$i18n.t('Enter Your Name')}
+												autocomplete="off"
+												required
+											/>
+										</div>
+									</div>
 
-									<div class="flex-1 text-sm break-all mb-1">
-										{_user.oauth_sub ?? ''}
+									<div class="flex flex-col w-full">
+										<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Email')}</div>
+
+										<div class="flex-1">
+											<input
+												class="w-full text-sm bg-transparent disabled:text-gray-500 dark:disabled:text-gray-500 outline-hidden"
+												type="email"
+												bind:value={_user.email}
+												placeholder={$i18n.t('Enter Your Email')}
+												autocomplete="off"
+												required
+											/>
+										</div>
+									</div>
+
+									{#if _user?.oauth_sub}
+										<div class="flex flex-col w-full">
+											<div class=" mb-1 text-xs text-gray-500">{$i18n.t('OAuth ID')}</div>
+
+											<div class="flex-1 text-sm break-all mb-1">
+												{_user.oauth_sub ?? ''}
+											</div>
+										</div>
+									{/if}
+
+									<div class="flex flex-col w-full">
+										<div class=" mb-1 text-xs text-gray-500">{$i18n.t('New Password')}</div>
+
+										<div class="flex-1">
+											<SensitiveInput
+												class="w-full text-sm bg-transparent outline-hidden"
+												type="password"
+												placeholder={$i18n.t('Enter New Password')}
+												bind:value={_user.password}
+												autocomplete="new-password"
+												required={false}
+											/>
+										</div>
 									</div>
-								</div>
-							{/if}
-
-							<div class="flex flex-col w-full">
-								<div class=" mb-1 text-xs text-gray-500">{$i18n.t('New Password')}</div>
-
-								<div class="flex-1">
-									<SensitiveInput
-										class="w-full text-sm bg-transparent outline-hidden"
-										type="password"
-										placeholder={$i18n.t('Enter New Password')}
-										bind:value={_user.password}
-										autocomplete="new-password"
-										required={false}
-									/>
 								</div>
 							</div>
 						</div>

+ 4 - 129
src/lib/components/chat/Settings/Account.svelte

@@ -15,6 +15,8 @@
 	import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
 	import Textarea from '$lib/components/common/Textarea.svelte';
 	import { getUserById } from '$lib/apis/users';
+	import User from '$lib/components/icons/User.svelte';
+	import UserProfileImage from './Account/UserProfileImage.svelte';
 
 	const i18n = getContext('i18n');
 
@@ -118,68 +120,6 @@
 
 <div id="tab-account" class="flex flex-col h-full justify-between text-sm">
 	<div class=" overflow-y-scroll max-h-[28rem] md:max-h-full">
-		<input
-			id="profile-image-input"
-			bind:this={profileImageInputElement}
-			type="file"
-			hidden
-			accept="image/*"
-			on:change={(e) => {
-				const files = profileImageInputElement.files ?? [];
-				let reader = new FileReader();
-				reader.onload = (event) => {
-					let originalImageUrl = `${event.target.result}`;
-
-					const img = new Image();
-					img.src = originalImageUrl;
-
-					img.onload = function () {
-						const canvas = document.createElement('canvas');
-						const ctx = canvas.getContext('2d');
-
-						// Calculate the aspect ratio of the image
-						const aspectRatio = img.width / img.height;
-
-						// Calculate the new width and height to fit within 250x250
-						let newWidth, newHeight;
-						if (aspectRatio > 1) {
-							newWidth = 250 * aspectRatio;
-							newHeight = 250;
-						} else {
-							newWidth = 250;
-							newHeight = 250 / aspectRatio;
-						}
-
-						// Set the canvas size
-						canvas.width = 250;
-						canvas.height = 250;
-
-						// Calculate the position to center the image
-						const offsetX = (250 - newWidth) / 2;
-						const offsetY = (250 - newHeight) / 2;
-
-						// Draw the image on the canvas
-						ctx.drawImage(img, offsetX, offsetY, newWidth, newHeight);
-
-						// Get the base64 representation of the compressed image
-						const compressedSrc = canvas.toDataURL('image/jpeg');
-
-						// Display the compressed image
-						profileImageUrl = compressedSrc;
-
-						profileImageInputElement.files = null;
-					};
-				};
-
-				if (
-					files.length > 0 &&
-					['image/gif', 'image/webp', 'image/jpeg', 'image/png'].includes(files[0]['type'])
-				) {
-					reader.readAsDataURL(files[0]);
-				}
-			}}
-		/>
-
 		<div class="space-y-1">
 			<div>
 				<div class="text-base font-medium">{$i18n.t('Your Account')}</div>
@@ -192,73 +132,8 @@
 			<!-- <div class=" text-sm font-medium">{$i18n.t('Account')}</div> -->
 
 			<div class="flex space-x-5 my-4">
-				<div class="flex flex-col self-start group">
-					<div class="self-center flex">
-						<button
-							class="relative rounded-full dark:bg-gray-700"
-							type="button"
-							on:click={() => {
-								profileImageInputElement.click();
-							}}
-						>
-							<img
-								src={profileImageUrl !== '' ? profileImageUrl : generateInitialsImage(name)}
-								alt="profile"
-								class=" rounded-full size-14 md:size-18 object-cover"
-							/>
-
-							<div class="absolute bottom-0 right-0 opacity-0 group-hover:opacity-100 transition">
-								<div class="p-1 rounded-full bg-white text-black border-gray-100 shadow">
-									<svg
-										xmlns="http://www.w3.org/2000/svg"
-										viewBox="0 0 20 20"
-										fill="currentColor"
-										class="size-3"
-									>
-										<path
-											d="m2.695 14.762-1.262 3.155a.5.5 0 0 0 .65.65l3.155-1.262a4 4 0 0 0 1.343-.886L17.5 5.501a2.121 2.121 0 0 0-3-3L3.58 13.419a4 4 0 0 0-.885 1.343Z"
-										/>
-									</svg>
-								</div>
-							</div>
-						</button>
-					</div>
-					<div class="flex flex-col w-full justify-center mt-2">
-						<button
-							class=" text-xs text-center text-gray-500 rounded-lg py-0.5 opacity-0 group-hover:opacity-100 transition-all"
-							on:click={async () => {
-								profileImageUrl = `${WEBUI_BASE_URL}/user.png`;
-							}}>{$i18n.t('Remove')}</button
-						>
-
-						<button
-							class=" text-xs text-center text-gray-800 dark:text-gray-400 rounded-lg py-0.5 opacity-0 group-hover:opacity-100 transition-all"
-							on:click={async () => {
-								if (canvasPixelTest()) {
-									profileImageUrl = generateInitialsImage(name);
-								} else {
-									toast.info(
-										$i18n.t(
-											'Fingerprint spoofing detected: Unable to use initials as avatar. Defaulting to default profile image.'
-										),
-										{
-											duration: 1000 * 10
-										}
-									);
-								}
-							}}>{$i18n.t('Initials')}</button
-						>
-
-						<button
-							class=" text-xs text-center text-gray-800 dark:text-gray-400 rounded-lg py-0.5 opacity-0 group-hover:opacity-100 transition-all"
-							on:click={async () => {
-								const url = await getGravatarUrl(localStorage.token, $user?.email);
-
-								profileImageUrl = url;
-							}}>{$i18n.t('Gravatar')}</button
-						>
-					</div>
-				</div>
+				<UserProfileImage bind:profileImageUrl user={$user} />
+
 				<div class="flex flex-1 flex-col">
 					<div class=" flex-1">
 						<div class="flex flex-col w-full">

+ 149 - 0
src/lib/components/chat/Settings/Account/UserProfileImage.svelte

@@ -0,0 +1,149 @@
+<script lang="ts">
+	import { toast } from 'svelte-sonner';
+	import { getContext } from 'svelte';
+
+	const i18n = getContext('i18n');
+
+	import { getGravatarUrl } from '$lib/apis/utils';
+	import { canvasPixelTest, generateInitialsImage } from '$lib/utils';
+
+	import { WEBUI_BASE_URL } from '$lib/constants';
+
+	export let profileImageUrl;
+	export let user = null;
+
+	let profileImageInputElement;
+</script>
+
+<input
+	id="profile-image-input"
+	bind:this={profileImageInputElement}
+	type="file"
+	hidden
+	accept="image/*"
+	on:change={(e) => {
+		const files = profileImageInputElement.files ?? [];
+		let reader = new FileReader();
+		reader.onload = (event) => {
+			let originalImageUrl = `${event.target.result}`;
+
+			const img = new Image();
+			img.src = originalImageUrl;
+
+			img.onload = function () {
+				const canvas = document.createElement('canvas');
+				const ctx = canvas.getContext('2d');
+
+				// Calculate the aspect ratio of the image
+				const aspectRatio = img.width / img.height;
+
+				// Calculate the new width and height to fit within 250x250
+				let newWidth, newHeight;
+				if (aspectRatio > 1) {
+					newWidth = 250 * aspectRatio;
+					newHeight = 250;
+				} else {
+					newWidth = 250;
+					newHeight = 250 / aspectRatio;
+				}
+
+				// Set the canvas size
+				canvas.width = 250;
+				canvas.height = 250;
+
+				// Calculate the position to center the image
+				const offsetX = (250 - newWidth) / 2;
+				const offsetY = (250 - newHeight) / 2;
+
+				// Draw the image on the canvas
+				ctx.drawImage(img, offsetX, offsetY, newWidth, newHeight);
+
+				// Get the base64 representation of the compressed image
+				const compressedSrc = canvas.toDataURL('image/jpeg');
+
+				// Display the compressed image
+				profileImageUrl = compressedSrc;
+
+				profileImageInputElement.files = null;
+			};
+		};
+
+		if (
+			files.length > 0 &&
+			['image/gif', 'image/webp', 'image/jpeg', 'image/png'].includes(files[0]['type'])
+		) {
+			reader.readAsDataURL(files[0]);
+		}
+	}}
+/>
+
+<div class="flex flex-col self-start group">
+	<div class="self-center flex">
+		<button
+			class="relative rounded-full dark:bg-gray-700"
+			type="button"
+			on:click={() => {
+				profileImageInputElement.click();
+			}}
+		>
+			<img
+				src={profileImageUrl !== '' ? profileImageUrl : generateInitialsImage(user?.name)}
+				alt="profile"
+				class=" rounded-full size-14 md:size-18 object-cover"
+			/>
+
+			<div class="absolute bottom-0 right-0 opacity-0 group-hover:opacity-100 transition">
+				<div class="p-1 rounded-full bg-white text-black border-gray-100 shadow">
+					<svg
+						xmlns="http://www.w3.org/2000/svg"
+						viewBox="0 0 20 20"
+						fill="currentColor"
+						class="size-3"
+					>
+						<path
+							d="m2.695 14.762-1.262 3.155a.5.5 0 0 0 .65.65l3.155-1.262a4 4 0 0 0 1.343-.886L17.5 5.501a2.121 2.121 0 0 0-3-3L3.58 13.419a4 4 0 0 0-.885 1.343Z"
+						/>
+					</svg>
+				</div>
+			</div>
+		</button>
+	</div>
+	<div class="flex flex-col w-full justify-center mt-2">
+		<button
+			class=" text-xs text-center text-gray-500 rounded-lg py-0.5 opacity-0 group-hover:opacity-100 transition-all"
+			type="button"
+			on:click={async () => {
+				profileImageUrl = `${WEBUI_BASE_URL}/user.png`;
+			}}>{$i18n.t('Remove')}</button
+		>
+
+		<button
+			class=" text-xs text-center text-gray-800 dark:text-gray-400 rounded-lg py-0.5 opacity-0 group-hover:opacity-100 transition-all"
+			type="button"
+			on:click={async () => {
+				if (canvasPixelTest()) {
+					profileImageUrl = generateInitialsImage(user?.name);
+				} else {
+					toast.info(
+						$i18n.t(
+							'Fingerprint spoofing detected: Unable to use initials as avatar. Defaulting to default profile image.'
+						),
+						{
+							duration: 1000 * 10
+						}
+					);
+				}
+			}}>{$i18n.t('Initials')}</button
+		>
+
+		<button
+			class=" text-xs text-center text-gray-800 dark:text-gray-400 rounded-lg py-0.5 opacity-0 group-hover:opacity-100 transition-all"
+			type="button"
+			on:click={async () => {
+				const url = await getGravatarUrl(localStorage.token, user?.email);
+
+				profileImageUrl = url;
+			}}>{$i18n.t('Gravatar')}</button
+		>
+	</div>
+</div>