feat: replace settings with a full page
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
<script setup lang="ts">
|
||||
import { Err, Ok, type Result } from '~~/types/result';
|
||||
|
||||
const AVATAR_SIZE = 256;
|
||||
const MAX_SOURCE_SIZE = 10 * 1024 * 1024;
|
||||
const ACCEPTED_TYPES = ['image/png', 'image/jpeg', 'image/webp'];
|
||||
|
||||
const { user, updateProfile } = useAuth();
|
||||
|
||||
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>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user