This commit is contained in:
Timothy Jaeryang Baek
2026-07-15 00:14:01 -04:00
parent 254888cf15
commit 63ada24706
31 changed files with 2560 additions and 2754 deletions
+100 -107
View File
@@ -124,119 +124,108 @@
});
</script>
<div id="tab-account" class="flex flex-col h-full justify-between text-sm">
<div class="flex-1 min-h-0 overflow-y-auto scrollbar-hover pr-1.5 -mr-1.5">
<div class="space-y-1">
<div>
<div class="text-xs text-gray-400 dark:text-gray-600 mb-2">
{$i18n.t('Your Account')}
</div>
<div class="text-xs text-gray-500 mt-0.5">
{$i18n.t('Manage your account information.')}
</div>
</div>
<div id="tab-account" class="flex h-full flex-col text-sm">
<div class="flex-1 min-h-0 w-full overflow-y-auto scrollbar-hover pr-1.5 -mr-1.5 py-1">
<h2 class="mb-4 text-sm font-medium text-gray-900 dark:text-white">{$i18n.t('Account')}</h2>
<div class="space-y-3">
<!-- <div class=" text-sm font-normal">{$i18n.t('Account')}</div> -->
<div class="flex space-x-5 my-4">
<UserProfileImage bind:profileImageUrl user={$user} />
<UserProfileImage
bind:profileImageUrl
user={$user}
variant="account"
displayName={$user?.name}
/>
<div class="flex flex-1 flex-col">
<div class=" flex-1">
<div class="flex flex-col w-full">
<div class=" mb-1 text-xs font-normal">{$i18n.t('Name')}</div>
<div class="flex flex-col w-full">
<div class="mb-1 text-xs text-gray-400 dark:text-gray-600">{$i18n.t('Name')}</div>
<div class="flex-1">
<input
class="w-full text-sm dark:text-gray-300 bg-transparent outline-hidden"
type="text"
bind:value={name}
aria-label={$i18n.t('Name')}
required
placeholder={$i18n.t('Enter your name')}
/>
</div>
</div>
<input
class="w-full bg-transparent py-1 text-[0.8125rem] text-gray-700 outline-hidden placeholder:text-gray-300 dark:text-gray-300 dark:placeholder:text-gray-700"
type="text"
bind:value={name}
aria-label={$i18n.t('Name')}
required
placeholder={$i18n.t('Enter your name')}
/>
</div>
<div class="flex flex-col w-full mt-2">
<div class=" mb-1 text-xs font-normal">{$i18n.t('Bio')}</div>
<div class="mt-2 flex flex-col w-full">
<div class="mb-1 text-xs text-gray-400 dark:text-gray-600">{$i18n.t('Bio')}</div>
<div class="flex-1">
<Textarea
className="w-full text-sm dark:text-gray-300 bg-transparent outline-hidden"
minSize={60}
bind:value={bio}
ariaLabel={$i18n.t('Bio')}
placeholder={$i18n.t('Share your background and interests')}
/>
</div>
</div>
<Textarea
className="w-full bg-transparent py-1 text-[0.8125rem] text-gray-700 outline-hidden placeholder:text-gray-300 dark:text-gray-300 dark:placeholder:text-gray-700"
minSize={60}
bind:value={bio}
ariaLabel={$i18n.t('Bio')}
placeholder={$i18n.t('Share your background and interests')}
/>
</div>
<div class="flex flex-col w-full mt-2">
<div class=" mb-1 text-xs font-normal">{$i18n.t('Gender')}</div>
<div class="flex-1">
<select
class="w-full text-sm dark:text-gray-300 bg-transparent outline-hidden"
bind:value={_gender}
aria-label={$i18n.t('Gender')}
on:change={(e) => {
console.log(_gender);
if (_gender === 'custom') {
// Handle custom gender input
gender = '';
} else {
gender = _gender;
}
}}
>
<option value="" selected>{$i18n.t('Prefer not to say')}</option>
<option value="male">{$i18n.t('Male')}</option>
<option value="female">{$i18n.t('Female')}</option>
<option value="custom">{$i18n.t('Custom')}</option>
</select>
</div>
{#if _gender === 'custom'}
<input
class="w-full text-sm dark:text-gray-300 bg-transparent outline-hidden mt-1"
type="text"
required
aria-label={$i18n.t('Custom Gender')}
placeholder={$i18n.t('Enter your gender')}
bind:value={gender}
/>
{/if}
</div>
<div class="flex flex-col w-full mt-2">
<div class=" mb-1 text-xs font-normal">{$i18n.t('Birth Date')}</div>
<div class="flex-1">
<input
class="w-full text-sm dark:text-gray-300 dark:placeholder:text-gray-300 bg-transparent outline-hidden"
type="date"
aria-label={$i18n.t('Birth Date')}
bind:value={dateOfBirth}
required
/>
</div>
</div>
</div>
<div class="mt-2 flex flex-col w-full">
<div class="mb-1 text-xs text-gray-400 dark:text-gray-600">
{$i18n.t('Gender')}
</div>
<select
class="w-full bg-transparent py-1 text-[0.8125rem] text-gray-700 outline-hidden dark:text-gray-300"
bind:value={_gender}
aria-label={$i18n.t('Gender')}
on:change={(e) => {
console.log(_gender);
if (_gender === 'custom') {
// Handle custom gender input
gender = '';
} else {
gender = _gender;
}
}}
>
<option value="" selected>{$i18n.t('Prefer not to say')}</option>
<option value="male">{$i18n.t('Male')}</option>
<option value="female">{$i18n.t('Female')}</option>
<option value="custom">{$i18n.t('Custom')}</option>
</select>
{#if _gender === 'custom'}
<input
class="mt-1 w-full bg-transparent py-1 text-[0.8125rem] text-gray-700 outline-hidden placeholder:text-gray-300 dark:text-gray-300 dark:placeholder:text-gray-700"
type="text"
required
aria-label={$i18n.t('Custom Gender')}
placeholder={$i18n.t('Enter your gender')}
bind:value={gender}
/>
{/if}
</div>
<div class="mt-2 flex flex-col w-full">
<div class="mb-1 text-xs text-gray-400 dark:text-gray-600">
{$i18n.t('Birth Date')}
</div>
<input
class="w-full bg-transparent py-1 text-[0.8125rem] text-gray-700 outline-hidden dark:text-gray-300 dark:placeholder:text-gray-700"
type="date"
aria-label={$i18n.t('Birth Date')}
bind:value={dateOfBirth}
required
/>
</div>
</div>
{#if $config?.features?.enable_user_webhooks && ($user?.role === 'admin' || ($user?.permissions?.features?.webhooks ?? false))}
<div class="mt-2">
<div class="mt-3">
<div class="flex flex-col w-full">
<div class=" mb-1 text-xs font-normal">{$i18n.t('Notification Webhook')}</div>
<div class="mb-1 text-xs text-gray-400 dark:text-gray-600">
{$i18n.t('Notification Webhook')}
</div>
<div class="flex-1">
<input
class="w-full text-sm outline-hidden"
class="w-full bg-transparent py-1 text-[0.8125rem] text-gray-700 outline-hidden placeholder:text-gray-300 dark:text-gray-300 dark:placeholder:text-gray-700"
type="url"
placeholder={$i18n.t('Enter your webhook URL')}
aria-label={$i18n.t('Notification Webhook')}
@@ -248,7 +237,7 @@
</div>
{/if}
<hr class="border-gray-50 dark:border-gray-850/30 my-4" />
<hr class="my-4 border-gray-100/40 dark:border-white/[0.03]" />
{#if $config?.features.enable_login_form && $config?.features.enable_password_change_form}
<div class="mt-2">
@@ -257,10 +246,10 @@
{/if}
{#if ($config?.features?.enable_api_keys ?? true) && ($user?.role === 'admin' || ($user?.permissions?.features?.api_keys ?? false))}
<div class="flex justify-between items-center text-sm mt-2">
<div class=" font-normal">{$i18n.t('API keys')}</div>
<div class="mt-3 flex items-center justify-between text-sm">
<div class="text-xs text-gray-400 dark:text-gray-600">{$i18n.t('API keys')}</div>
<button
class=" text-xs font-normal text-gray-500"
class="text-xs font-normal text-gray-500 transition-colors hover:text-gray-700 dark:hover:text-gray-300"
type="button"
on:click={() => {
showAPIKeys = !showAPIKeys;
@@ -273,14 +262,16 @@
{#if $user?.role === 'admin'}
<div class="justify-between w-full mt-2">
<div class="flex justify-between w-full">
<div class="self-center text-xs font-normal mb-1">{$i18n.t('JWT Token')}</div>
<div class="mb-1 self-center text-xs text-gray-400 dark:text-gray-600">
{$i18n.t('JWT Token')}
</div>
</div>
<div class="flex">
<SensitiveInput value={localStorage.token} readOnly={true} />
<button
class="ml-1.5 px-1.5 py-1 dark:hover:bg-gray-850 transition rounded-lg"
class="ml-1.5 rounded-sm px-1.5 py-1 text-gray-500 transition hover:bg-gray-100 hover:text-gray-700 dark:hover:bg-gray-850 dark:hover:text-gray-300"
aria-label={$i18n.t('Copy Token')}
on:click={() => {
copyToClipboard(localStorage.token);
@@ -331,7 +322,9 @@
<div class="justify-between w-full mt-2">
{#if $user?.role === 'admin'}
<div class="flex justify-between w-full">
<div class="self-center text-xs font-normal mb-1">{$i18n.t('API Key')}</div>
<div class="mb-1 self-center text-xs text-gray-400 dark:text-gray-600">
{$i18n.t('API Key')}
</div>
</div>
{/if}
<div class="flex">
@@ -339,7 +332,7 @@
<SensitiveInput value={APIKey} readOnly={true} />
<button
class="ml-1.5 px-1.5 py-1 dark:hover:bg-gray-850 transition rounded-lg"
class="ml-1.5 rounded-sm px-1.5 py-1 text-gray-500 transition hover:bg-gray-100 hover:text-gray-700 dark:hover:bg-gray-850 dark:hover:text-gray-300"
aria-label={$i18n.t('Copy API Key')}
on:click={() => {
copyToClipboard(APIKey);
@@ -385,7 +378,7 @@
<Tooltip content={$i18n.t('Create new key')}>
<button
class=" px-1.5 py-1 dark:hover:bg-gray-850transition rounded-lg"
class="rounded-sm px-1.5 py-1 text-gray-500 transition hover:bg-gray-100 hover:text-gray-700 dark:hover:bg-gray-850 dark:hover:text-gray-300"
aria-label={$i18n.t('Create new key')}
on:click={() => {
createAPIKeyHandler();
@@ -409,7 +402,7 @@
</Tooltip>
{:else}
<button
class="flex gap-1.5 items-center font-normal px-3.5 py-1.5 rounded-lg bg-gray-100/70 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-850 transition"
class="flex items-center gap-1.5 text-[0.8125rem] font-normal text-gray-600 transition-colors hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
on:click={() => {
createAPIKeyHandler();
}}
@@ -427,7 +420,7 @@
{/if}
</div>
<div class="shrink-0 flex justify-end pt-3 text-sm font-normal">
<div class="shrink-0 flex w-full justify-end pt-3 text-sm font-normal">
<button
class="px-3.5 py-1.5 text-sm font-normal bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
on:click={async () => {
@@ -44,9 +44,9 @@
}}
>
<div class="flex justify-between items-center text-sm">
<div class=" font-normal">{$i18n.t('Change Password')}</div>
<div class="text-xs text-gray-400 dark:text-gray-600">{$i18n.t('Change Password')}</div>
<button
class=" text-xs font-normal text-gray-500"
class="text-xs font-normal text-gray-500 transition-colors hover:text-gray-700 dark:hover:text-gray-300"
type="button"
on:click={() => {
show = !show;
@@ -13,6 +13,8 @@
export let user = null;
export let imageClassName = 'size-14 md:size-18';
export let variant = 'default';
export let displayName = '';
let profileImageInputElement;
</script>
@@ -79,10 +81,11 @@
}}
/>
<div class="flex flex-col self-start group">
<div class="self-center flex">
{#if variant === 'account'}
<div class="mb-6 flex items-center gap-4">
<button
class="relative rounded-full dark:bg-gray-700"
class="group relative flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-full bg-gray-100
ring-1 ring-gray-200 transition-all duration-200 hover:ring-2 hover:ring-gray-300 dark:bg-white/8 dark:ring-white/10 dark:hover:ring-white/20"
type="button"
on:click={() => {
profileImageInputElement.click();
@@ -91,61 +94,154 @@
<img
src={profileImageUrl !== '' ? profileImageUrl : generateInitialsImage(user?.name)}
alt="profile"
class=" rounded-full {imageClassName} object-cover"
class="h-full w-full 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
class="absolute inset-0 flex items-center justify-center bg-black/0 transition-colors duration-200 group-hover:bg-black/40"
>
<svg
class="h-4 w-4 text-white opacity-0 drop-shadow-sm transition-opacity duration-200 group-hover:opacity-100"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6.827 6.175A2.31 2.31 0 0 1 5.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 0 0-1.134-.175 2.31 2.31 0 0 1-1.64-1.055l-.822-1.316a2.192 2.192 0 0 0-1.736-1.039 48.774 48.774 0 0 0-5.232 0 2.192 2.192 0 0 0-1.736 1.039l-.821 1.316Z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.5 12.75a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0Z"
/>
</svg>
</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
<div class="flex flex-col gap-1">
<span class="text-sm font-medium text-gray-900 dark:text-white"
>{displayName || user?.name}</span
>
<div class="flex flex-wrap items-center gap-2">
<button
class="text-[0.6875rem] text-gray-400 transition-colors duration-100 hover:text-gray-600 dark:hover:text-gray-300"
type="button"
on:click={() => {
profileImageInputElement.click();
}}>{$i18n.t('Upload Photo')}</button
>
<span class="text-[0.6875rem] text-gray-300 dark:text-gray-700">·</span>
<button
class="text-[0.6875rem] text-gray-400 transition-colors duration-100 hover:text-gray-600 dark:hover:text-gray-300"
type="button"
on:click={async () => {
profileImageUrl = `${WEBUI_BASE_URL}/user.png`;
}}>{$i18n.t('Remove')}</button
>
<span class="text-[0.6875rem] text-gray-300 dark:text-gray-700">·</span>
<button
class="text-[0.6875rem] text-gray-400 transition-colors duration-100 hover:text-gray-600 dark:hover:text-gray-300"
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
>
}}>{$i18n.t('Initials')}</button
>
<span class="text-[0.6875rem] text-gray-300 dark:text-gray-700">·</span>
<button
class="text-[0.6875rem] text-gray-400 transition-colors duration-100 hover:text-gray-600 dark:hover:text-gray-300"
type="button"
on:click={async () => {
const url = await getGravatarUrl(localStorage.token, user?.email);
<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
>
profileImageUrl = url;
}}>{$i18n.t('Gravatar')}</button
>
</div>
</div>
</div>
</div>
{:else}
<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 {imageClassName} 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>
{/if}