Files
mathew/frontend/app/components/ProfileModal.vue
2026-09-30 01:49:35 +02:00

359 lines
12 KiB
Vue

<script setup lang="ts">
/* Centered profile popup, opened by the account row in the sidebar (and closed
with esc, a backdrop click, or navigation — the same shape as the settings
popup). It holds the account itself: the profile picture, the username and
the password, and the way out. */
const { open, hide } = useProfileMenu()
const { hide: hideSearch } = useSearch()
const { hide: hideSettings } = useSettings()
const { hide: hideSignIn, show: openSignIn } = useSignInPopup()
const { user, signOut, updateUsername, changePassword, setAvatar } = useAuth()
const { t } = useI18n()
const route = useRoute()
const panelEl = ref<HTMLElement | null>(null)
/* the fallback the sidebar wears too: an initial in a circle */
const initials = computed(() => (user.value?.username.charAt(0) ?? '?').toUpperCase())
/* --- the picture ---
The chosen file is shrunk in the browser before it is sent: the avatar is
small everywhere, so there is no reason to store it any bigger. */
const AVATAR_SIZE = 256
const avatarBusy = ref(false)
const avatarError = ref('')
const fileInputEl = ref<HTMLInputElement | null>(null)
/* Reads the file, crops the centre to a square, and scales it down to
AVATAR_SIZE on a canvas. The white fill makes transparent PNGs flatten to
white rather than black. */
async function fileToAvatarDataUrl(file: File): Promise<string> {
const raw = await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result))
reader.onerror = () => reject(new Error(t('profile.readFileFailed')))
reader.readAsDataURL(file)
})
const img = new Image()
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve()
img.onerror = () => reject(new Error(t('profile.notReadable')))
img.src = raw
})
const side = Math.min(img.naturalWidth, img.naturalHeight)
const canvas = document.createElement('canvas')
canvas.width = AVATAR_SIZE
canvas.height = AVATAR_SIZE
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error(t('profile.drawFailed'))
ctx.fillStyle = '#fff'
ctx.fillRect(0, 0, AVATAR_SIZE, AVATAR_SIZE)
ctx.drawImage(
img,
(img.naturalWidth - side) / 2,
(img.naturalHeight - side) / 2,
side,
side,
0,
0,
AVATAR_SIZE,
AVATAR_SIZE
)
return canvas.toDataURL('image/jpeg', 0.86)
}
async function onAvatarPicked(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
input.value = '' // so the same file can be chosen again later
if (!file) return
avatarError.value = ''
if (!file.type.startsWith('image/')) {
avatarError.value = t('profile.notAnImage')
return
}
avatarBusy.value = true
try {
const dataUrl = await fileToAvatarDataUrl(file)
await setAvatar(dataUrl)
} catch (err: any) {
// a failure of the browser's own (reading, drawing) says why in its
// message; a failure of the request goes through describeFailure
avatarError.value =
err?.statusCode != null
? describeFailure(err) || t('profile.uploadFailed')
: err?.message || t('profile.readFailed')
} finally {
avatarBusy.value = false
}
}
async function removeAvatar() {
avatarError.value = ''
avatarBusy.value = true
try {
await setAvatar('')
} catch (err: any) {
avatarError.value = describeFailure(err) || t('profile.removeFailed')
} finally {
avatarBusy.value = false
}
}
/* --- the account --- */
const username = ref('')
const savingName = ref(false)
const nameError = ref('')
const nameSaved = ref(false)
const currentPassword = ref('')
const newPassword = ref('')
const savingPassword = ref(false)
const passwordError = ref('')
const passwordSaved = ref(false)
/* A 401 means the session held is dead: drop it and hand the person the
sign-in popup over what just failed. Anything else is only an error line —
'' when the popup has already been swept away with the session. */
function describeFailure(err: any): string {
if (err?.statusCode === 401 || err?.response?.status === 401) {
signOut()
openSignIn({ notice: t('auth.expiredProfile') })
return ''
}
return err?.data?.message || t('common.apiUnreachable')
}
async function saveUsername() {
if (savingName.value) return
nameError.value = ''
nameSaved.value = false
const trimmed = username.value.trim().toLowerCase()
if (!trimmed) {
nameError.value = t('profile.nameRequired')
return
}
savingName.value = true
try {
await updateUsername(trimmed)
nameSaved.value = true
} catch (err: any) {
nameError.value = describeFailure(err) || t('profile.nameFailed')
} finally {
savingName.value = false
}
}
async function savePassword() {
if (savingPassword.value) return
passwordError.value = ''
passwordSaved.value = false
savingPassword.value = true
try {
await changePassword(currentPassword.value, newPassword.value)
currentPassword.value = ''
newPassword.value = ''
passwordSaved.value = true
} catch (err: any) {
passwordError.value = describeFailure(err) || t('profile.passwordFailed')
} finally {
savingPassword.value = false
}
}
function leave() {
signOut()
hide()
}
/* sync the form from the account whenever the popup opens */
watch(
open,
async (isOpen) => {
if (!isOpen) return
// opening this shouldn't leave another popup stacked behind it
hideSearch()
hideSettings()
hideSignIn()
username.value = user.value?.username ?? ''
currentPassword.value = ''
newPassword.value = ''
avatarBusy.value = false
avatarError.value = ''
nameError.value = ''
nameSaved.value = false
passwordError.value = ''
passwordSaved.value = false
await nextTick()
panelEl.value?.focus()
},
{ flush: 'post' }
)
/* --- esc closes, navigation closes --- */
function onGlobalKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && open.value) hide()
}
onMounted(() => window.addEventListener('keydown', onGlobalKeydown))
onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown))
watch(
() => route.fullPath,
() => {
if (open.value) hide()
}
)
</script>
<template>
<Teleport to="body">
<Transition name="profile-popup">
<div v-if="open && user" class="profile-overlay" @click.self="hide()">
<div
ref="panelEl"
class="profile-modal"
role="dialog"
aria-modal="true"
:aria-label="t('profile.title')"
tabindex="-1"
>
<div class="profile-modal-head">
<h2 class="profile-title">{{ t('profile.title') }}</h2>
<button
type="button"
class="panel-icon-btn"
:aria-label="t('profile.closeAria')"
:title="t('common.close')"
@click="hide()"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</div>
<div class="profile-modal-body">
<!-- the picture -->
<section class="profile-group" aria-labelledby="profile-picture">
<div class="profile-group-head">
<span id="profile-picture" class="profile-group-title">{{ t('profile.picture') }}</span>
<span class="profile-group-hint">{{ t('profile.pictureHint') }}</span>
</div>
<div class="profile-avatar-row">
<span class="profile-avatar" aria-hidden="true">
<img v-if="user.avatar" :src="user.avatar" alt="" />
<template v-else>{{ initials }}</template>
</span>
<div class="profile-avatar-actions">
<button
type="button"
class="btn btn-primary btn-sm"
:disabled="avatarBusy"
@click="fileInputEl?.click()"
>
{{ avatarBusy ? t('profile.uploading') : user.avatar ? t('profile.changePicture') : t('profile.choosePicture') }}
</button>
<button
v-if="user.avatar"
type="button"
class="btn btn-ghost btn-sm"
:disabled="avatarBusy"
@click="removeAvatar()"
>
{{ t('profile.remove') }}
</button>
<input
ref="fileInputEl"
type="file"
accept="image/png, image/jpeg, image/webp, image/gif"
class="profile-file-input"
aria-hidden="true"
tabindex="-1"
@change="onAvatarPicked($event)"
/>
</div>
</div>
<p v-if="avatarError" class="form-error">{{ avatarError }}</p>
</section>
<!-- the account itself -->
<section class="profile-group" aria-labelledby="profile-username">
<div class="profile-group-head">
<span id="profile-username" class="profile-group-title">{{ t('profile.username') }}</span>
<span class="profile-group-hint">{{ t('profile.usernameHint') }}</span>
</div>
<form class="profile-form" @submit.prevent="saveUsername">
<input
v-model="username"
type="text"
:aria-label="t('profile.username')"
autocomplete="username"
autocapitalize="none"
spellcheck="false"
minlength="3"
maxlength="30"
@input="nameSaved = false"
/>
<button
type="submit"
class="btn btn-primary btn-sm"
:disabled="savingName || !username.trim() || username.trim().toLowerCase() === user.username"
>
{{ savingName ? t('profile.saving') : t('profile.save') }}
</button>
</form>
<p v-if="nameError" class="form-error">{{ nameError }}</p>
<p v-else-if="nameSaved" class="profile-saved">{{ t('profile.nameSaved') }}</p>
</section>
<section class="profile-group" aria-labelledby="profile-password">
<div class="profile-group-head">
<span id="profile-password" class="profile-group-title">{{ t('profile.password') }}</span>
<span class="profile-group-hint">{{ t('profile.passwordHint') }}</span>
</div>
<form class="profile-form profile-form-password" @submit.prevent="savePassword">
<input
v-model="currentPassword"
type="password"
:aria-label="t('profile.currentPassword')"
autocomplete="current-password"
:placeholder="t('profile.currentPassword')"
required
/>
<input
v-model="newPassword"
type="password"
:aria-label="t('profile.newPassword')"
autocomplete="new-password"
:placeholder="t('profile.newPassword')"
minlength="8"
required
/>
<button type="submit" class="btn btn-primary btn-sm" :disabled="savingPassword">
{{ savingPassword ? t('profile.saving') : t('profile.changePassword') }}
</button>
</form>
<p v-if="passwordError" class="form-error">{{ passwordError }}</p>
<p v-else-if="passwordSaved" class="profile-saved">{{ t('profile.passwordChanged') }}</p>
</section>
<section class="profile-group">
<button type="button" class="btn btn-danger btn-sm profile-signout" @click="leave()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<path d="m16 17 5-5-5-5" />
<path d="M21 12H9" />
</svg>
{{ t('common.signOut') }}
</button>
</section>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>