First commit
This commit is contained in:
358
frontend/app/components/ProfileModal.vue
Normal file
358
frontend/app/components/ProfileModal.vue
Normal file
@@ -0,0 +1,358 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user