276 lines
11 KiB
Vue
276 lines
11 KiB
Vue
<script setup lang="ts">
|
|
import { Err, Ok, type Result } from '~~/types/result';
|
|
import { DialogType } from '~/composables/useDialog';
|
|
|
|
const AVATAR_SIZE = 256;
|
|
const MAX_SOURCE_SIZE = 10 * 1024 * 1024;
|
|
const ACCEPTED_TYPES = ['image/png', 'image/jpeg', 'image/webp'];
|
|
|
|
const { user, isAdmin, updateProfile } = useAuth();
|
|
const { openDialog } = useDialog();
|
|
|
|
// same tinted pill as the states that are on in Security
|
|
const ACCENT_PILL_STYLE = {
|
|
backgroundColor: 'color-mix(in srgb, var(--color-accent) 20%, transparent)',
|
|
borderColor: 'color-mix(in srgb, var(--color-accent) 50%, transparent)',
|
|
};
|
|
|
|
const fileInput = ref<HTMLInputElement | null>(null);
|
|
const avatarBusy = ref(false);
|
|
const avatarError = ref('');
|
|
|
|
const name = ref(user.value?.name ?? '');
|
|
const nameSaving = ref(false);
|
|
const nameError = ref('');
|
|
|
|
watch(() => user.value?.name, (value) => {
|
|
if (!nameSaving.value) name.value = value ?? '';
|
|
});
|
|
|
|
const trimmedName = computed(() => name.value.trim());
|
|
const nameChanged = computed(() => trimmedName.value !== '' && trimmedName.value !== user.value?.name);
|
|
|
|
// Center-crops the image to a square and scales it down, so we only ever
|
|
// store a small file no matter what the user picked.
|
|
const cropToSquare = async (file: File): Promise<Result<Blob, string>> => {
|
|
let bitmap: ImageBitmap;
|
|
try {
|
|
bitmap = await createImageBitmap(file);
|
|
} catch {
|
|
return Err('That file could not be read as an image.');
|
|
}
|
|
|
|
const side = Math.min(bitmap.width, bitmap.height);
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = AVATAR_SIZE;
|
|
canvas.height = AVATAR_SIZE;
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
if (ctx === null) {
|
|
bitmap.close();
|
|
return Err('Your browser could not process the image.');
|
|
}
|
|
|
|
ctx.imageSmoothingQuality = 'high';
|
|
ctx.drawImage(
|
|
bitmap,
|
|
(bitmap.width - side) / 2, (bitmap.height - side) / 2, side, side,
|
|
0, 0, AVATAR_SIZE, AVATAR_SIZE,
|
|
);
|
|
bitmap.close();
|
|
|
|
// browsers that can't encode webp hand back a png instead
|
|
const blob = await new Promise<Blob | null>(resolve => canvas.toBlob(resolve, 'image/webp', 0.9));
|
|
if (blob === null) {
|
|
return Err('Your browser could not process the image.');
|
|
}
|
|
|
|
return Ok(blob);
|
|
};
|
|
|
|
const uploadAvatar = async (blob: Blob): Promise<Result<string, string>> => {
|
|
const extension = blob.type === 'image/webp' ? 'webp' : 'png';
|
|
|
|
try {
|
|
const { url, assetUrl } = await $fetch<{ url: string; assetUrl: string }>('/api/upload/presigned', {
|
|
method: 'POST',
|
|
body: {
|
|
file: {
|
|
name: `avatar.${extension}`,
|
|
mimeType: blob.type,
|
|
},
|
|
},
|
|
});
|
|
|
|
const response = await fetch(url, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': blob.type },
|
|
body: blob,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
return Err('Upload failed. Check that file storage is configured for this instance.');
|
|
}
|
|
|
|
return Ok(assetUrl);
|
|
} catch (error) {
|
|
console.error('Avatar upload failed:', error);
|
|
return Err('Upload failed. Check that file storage is configured for this instance.');
|
|
}
|
|
};
|
|
|
|
const handleFileChange = async (event: Event) => {
|
|
const input = event.target as HTMLInputElement;
|
|
const file = input.files?.[0];
|
|
// reset so picking the same file again still fires a change event
|
|
input.value = '';
|
|
|
|
if (!file) return;
|
|
|
|
avatarError.value = '';
|
|
|
|
if (!ACCEPTED_TYPES.includes(file.type)) {
|
|
avatarError.value = 'Pick a PNG, JPG or WebP image.';
|
|
return;
|
|
}
|
|
|
|
if (file.size > MAX_SOURCE_SIZE) {
|
|
avatarError.value = 'That image is over 10 MB. Pick a smaller one.';
|
|
return;
|
|
}
|
|
|
|
avatarBusy.value = true;
|
|
|
|
const cropped = await cropToSquare(file);
|
|
if (cropped.ok === false) {
|
|
avatarError.value = cropped.error;
|
|
avatarBusy.value = false;
|
|
return;
|
|
}
|
|
|
|
const uploaded = await uploadAvatar(cropped.data);
|
|
if (uploaded.ok === false) {
|
|
avatarError.value = uploaded.error;
|
|
avatarBusy.value = false;
|
|
return;
|
|
}
|
|
|
|
const updated = await updateProfile({ image: uploaded.data });
|
|
if (updated.ok === false) {
|
|
avatarError.value = 'The image uploaded, but your profile could not be updated. Try again.';
|
|
}
|
|
|
|
avatarBusy.value = false;
|
|
};
|
|
|
|
const removeAvatar = async () => {
|
|
avatarError.value = '';
|
|
avatarBusy.value = true;
|
|
|
|
const updated = await updateProfile({ image: null });
|
|
if (updated.ok === false) {
|
|
avatarError.value = 'Your profile picture could not be removed. Try again.';
|
|
}
|
|
|
|
avatarBusy.value = false;
|
|
};
|
|
|
|
const saveName = async () => {
|
|
if (!nameChanged.value || nameSaving.value) return;
|
|
|
|
nameError.value = '';
|
|
nameSaving.value = true;
|
|
|
|
const updated = await updateProfile({ name: trimmedName.value });
|
|
if (updated.ok === false) {
|
|
nameError.value = 'Your name could not be saved. Try again.';
|
|
} else {
|
|
name.value = trimmedName.value;
|
|
}
|
|
|
|
nameSaving.value = false;
|
|
};
|
|
|
|
const memberSince = computed(() => {
|
|
if (!user.value?.createdAt) return '';
|
|
|
|
// fixed locale and time zone so the server and client render the same text
|
|
return new Date(user.value.createdAt).toLocaleDateString('en-US', {
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric',
|
|
timeZone: 'UTC',
|
|
});
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div
|
|
class="flex flex-col gap-5 py-4 overflow-auto [scrollbar-width:thin] [scrollbar-color:#888_transparent] [scrollbar-gutter:stable]">
|
|
<div class="flex flex-col gap-5 w-full max-w-3xl">
|
|
<section class="flex flex-wrap items-center gap-5 pb-5 border-b border-[var(--color-border)]">
|
|
<UserAvatar :user="user" class="w-20 h-20 rounded-xl text-3xl" />
|
|
|
|
<div class="flex flex-col gap-2 flex-1 min-w-60">
|
|
<h2 class="font-medium">Profile picture</h2>
|
|
<div class="flex flex-wrap gap-2">
|
|
<button type="button" :disabled="avatarBusy" @click="fileInput?.click()"
|
|
class="flex items-center gap-2 h-8 px-3 text-sm rounded-md bg-[var(--bg-container)] @hover:bg-[var(--color-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
|
<span :class="avatarBusy ? 'i-svg-spinners-180-ring-with-bg' : 'i-mynaui-upload'"
|
|
class="text-4"></span>
|
|
{{ avatarBusy ? 'Working…' : 'Upload image' }}
|
|
</button>
|
|
<button v-if="user?.image" type="button" :disabled="avatarBusy" @click="removeAvatar"
|
|
class="flex items-center h-8 px-3 text-sm rounded-md border border-[var(--color-border)] text-[var(--text-secondary)] @hover:bg-[var(--color-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
|
Remove
|
|
</button>
|
|
</div>
|
|
<input ref="fileInput" type="file" class="hidden" :accept="ACCEPTED_TYPES.join(',')"
|
|
aria-label="Upload a profile picture" @change="handleFileChange" />
|
|
<p v-if="avatarError" role="alert" class="text-xs text-red-400">{{ avatarError }}</p>
|
|
<p v-else class="text-xs text-[var(--text-secondary)]">
|
|
PNG, JPG or WebP up to 10MB.
|
|
</p>
|
|
</div>
|
|
</section>
|
|
|
|
<form class="flex flex-row flex-wrap items-center justify-between gap-x-16 gap-y-2"
|
|
@submit.prevent="saveName">
|
|
<label class="whitespace-nowrap" for="profile-name">Display name</label>
|
|
<div class="flex flex-col gap-1 flex-1 min-w-60 max-w-120">
|
|
<div class="flex gap-2">
|
|
<input id="profile-name" v-model="name" type="text" autocomplete="name" maxlength="64"
|
|
class="flex-1 min-w-0 h-8.5 px-2.5 text-sm rounded-md bg-[var(--bg-container)] placeholder:text-[var(--text-tertiary)]" />
|
|
<button type="submit" :disabled="!nameChanged || nameSaving"
|
|
class="h-8.5 px-3 items-center text-sm font-semibold rounded-md bg-[var(--color-accent)] text-[var(--color-accent-text)] disabled:opacity-40 disabled:cursor-not-allowed transition-opacity duration-200">
|
|
{{ nameSaving ? 'Saving…' : 'Save' }}
|
|
</button>
|
|
</div>
|
|
<p v-if="nameError" role="alert" class="text-xs text-red-400">{{ nameError }}</p>
|
|
</div>
|
|
</form>
|
|
|
|
<div class="flex flex-row flex-wrap items-center justify-between gap-x-16 gap-y-2">
|
|
<span class="whitespace-nowrap">Email</span>
|
|
<span
|
|
class="flex-1 min-w-60 max-w-120 text-sm text-[var(--text-secondary)] overflow-hidden text-ellipsis">
|
|
{{ user?.email }}
|
|
</span>
|
|
</div>
|
|
|
|
<div class="flex flex-row flex-wrap items-center justify-between gap-x-16 gap-y-2">
|
|
<span class="whitespace-nowrap">Member since</span>
|
|
<span class="flex-1 min-w-60 max-w-120 text-sm text-[var(--text-secondary)]">
|
|
{{ memberSince }}
|
|
</span>
|
|
</div>
|
|
|
|
<div class="flex flex-row flex-wrap items-center justify-between gap-x-16 gap-y-2">
|
|
<span class="whitespace-nowrap">Role</span>
|
|
<div class="flex-1 min-w-60 max-w-120 flex items-center gap-2.5">
|
|
<span v-if="isAdmin"
|
|
class="text-[13px] font-medium px-2 py-0.5 rounded-md border text-[var(--text-primary)]"
|
|
:style="ACCENT_PILL_STYLE">Admin</span>
|
|
<span v-else
|
|
class="text-[13px] font-medium px-2 py-0.5 rounded-md border border-[var(--color-border-active)] text-[var(--text-secondary)]">User</span>
|
|
</div>
|
|
</div>
|
|
|
|
<section
|
|
class="flex flex-wrap items-center justify-between gap-3 mt-3 p-4 rounded-lg border border-red-400/35">
|
|
<div class="flex flex-col gap-0.5 flex-1 min-w-60">
|
|
<h2 class="text-[15px] font-semibold">Delete account</h2>
|
|
<p class="text-[13px] leading-snug text-[var(--text-secondary)]">
|
|
Permanently deletes your agents, topics, and api keys from this instance.
|
|
<template v-if="isAdmin">The last remaining admin can't delete their account.</template>
|
|
</p>
|
|
</div>
|
|
<button type="button" @click="openDialog(DialogType.DeleteAccount)"
|
|
class="flex items-center h-8.5 px-3 text-sm font-medium rounded-md bg-red-600 text-white">
|
|
Delete account
|
|
</button>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
</template>
|