Files
mathew/frontend/app/components/ProfileModal.vue
2026-10-01 02:29:21 +02:00

404 lines
14 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, the
password, the interface language the account reads the wiki in, 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, updateLocale } = useAuth()
const { t, locale } = useI18n()
const { choose } = useAppLocale()
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()
}
/* --- the interface language ---
Turn the language Mathew speaks in. The device's own choice (the cookie
setLocale keeps) lands at once, and the account saves it too, so the next
device says the same hello. An account that won't take it — a session gone
quiet — doesn't undo the switch made here. */
const language = ref('en')
async function setLanguage(code: string) {
if (language.value === code) return
language.value = code
await choose(code)
try {
await updateLocale(code)
} catch {
/* the device-level switch stands on its own */
}
}
/* 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 = ''
language.value = locale.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>
<!-- the interface language -->
<section class="profile-group" aria-labelledby="profile-language">
<div class="profile-group-head">
<span id="profile-language" class="profile-group-title">{{ t('profile.language') }}</span>
<span class="profile-group-hint">{{ t('profile.languageHint') }}</span>
</div>
<div class="theme-switch" role="radiogroup" :aria-label="t('profile.languageAria')">
<button
v-for="l in APP_LANGUAGES"
:key="l.code"
type="button"
role="radio"
class="theme-option"
:class="{ active: language === l.code }"
:aria-checked="language === l.code"
:lang="l.code"
@click="setLanguage(l.code)"
>
<span>{{ l.name }}</span>
</button>
</div>
</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>