359 lines
12 KiB
Vue
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>
|