feat(avatar) : add profile page with avatar upload and crop
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -237,6 +237,7 @@
|
|||||||
"new": "Nouveau ticket",
|
"new": "Nouveau ticket",
|
||||||
"created": "Ticket créé avec succès.",
|
"created": "Ticket créé avec succès.",
|
||||||
"deleted": "Ticket supprimé avec succès.",
|
"deleted": "Ticket supprimé avec succès.",
|
||||||
|
"updated": "Ticket mis à jour avec succès.",
|
||||||
"statusUpdated": "Statut du ticket mis à jour.",
|
"statusUpdated": "Statut du ticket mis à jour.",
|
||||||
"type": {
|
"type": {
|
||||||
"bug": "Bug",
|
"bug": "Bug",
|
||||||
@@ -290,6 +291,12 @@
|
|||||||
"days": "Il y a {n}j"
|
"days": "Il y a {n}j"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"profile": {
|
||||||
|
"title": "Mon profil",
|
||||||
|
"changeAvatar": "Changer l'avatar",
|
||||||
|
"removeAvatar": "Supprimer l'avatar",
|
||||||
|
"cropAvatar": "Recadrer l'avatar"
|
||||||
|
},
|
||||||
"bookstack": {
|
"bookstack": {
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Configuration BookStack",
|
"title": "Configuration BookStack",
|
||||||
|
|||||||
@@ -10,17 +10,16 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
|||||||
return navigateTo('/login')
|
return navigateTo('/login')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isClientOnly = auth.isAuthenticated
|
||||||
|
&& auth.user?.roles?.includes('ROLE_CLIENT')
|
||||||
|
&& !auth.user?.roles?.includes('ROLE_ADMIN')
|
||||||
|
|
||||||
if (isLogin && auth.isAuthenticated) {
|
if (isLogin && auth.isAuthenticated) {
|
||||||
const isClientOnly = auth.user?.roles?.includes('ROLE_CLIENT') && !auth.user?.roles?.includes('ROLE_ADMIN')
|
|
||||||
return navigateTo(isClientOnly ? '/portal' : '/')
|
return navigateTo(isClientOnly ? '/portal' : '/')
|
||||||
}
|
}
|
||||||
|
|
||||||
// ROLE_CLIENT without ROLE_ADMIN: redirect to /portal, block internal pages
|
const isProfileRoute = to.path === '/profile'
|
||||||
if (auth.isAuthenticated && auth.user?.roles?.includes('ROLE_CLIENT') && !auth.user?.roles?.includes('ROLE_ADMIN')) {
|
if (isClientOnly && !to.path.startsWith('/portal') && !isProfileRoute) {
|
||||||
const isPortalRoute = to.path.startsWith('/portal')
|
return navigateTo('/portal')
|
||||||
const isLoginRoute = to.path === '/login'
|
|
||||||
if (!isPortalRoute && !isLoginRoute) {
|
|
||||||
return navigateTo('/portal')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
85
frontend/pages/profile.vue
Normal file
85
frontend/pages/profile.vue
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
<template>
|
||||||
|
<div class="mx-auto max-w-lg px-4 py-10">
|
||||||
|
<h1 class="mb-8 text-2xl font-bold text-neutral-900">{{ $t('profile.title') }}</h1>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-center gap-6 rounded-xl border border-neutral-200 bg-white p-8 shadow-sm">
|
||||||
|
<!-- Current avatar -->
|
||||||
|
<UserAvatar
|
||||||
|
v-if="auth.user"
|
||||||
|
:user="auth.user"
|
||||||
|
size="lg"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p class="text-lg font-semibold text-neutral-800">{{ auth.user?.username }}</p>
|
||||||
|
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<label
|
||||||
|
class="cursor-pointer rounded-lg bg-primary-500 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-secondary-500"
|
||||||
|
>
|
||||||
|
{{ $t('profile.changeAvatar') }}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||||
|
class="hidden"
|
||||||
|
@change="onFileSelect"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="auth.user?.avatarUrl"
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg border border-red-300 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50"
|
||||||
|
:disabled="removing"
|
||||||
|
@click="onRemove"
|
||||||
|
>
|
||||||
|
{{ $t('profile.removeAvatar') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Crop modal -->
|
||||||
|
<AvatarCropper
|
||||||
|
v-if="selectedFile"
|
||||||
|
:image-file="selectedFile"
|
||||||
|
@crop="onCrop"
|
||||||
|
@cancel="selectedFile = null"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const { upload, remove } = useAvatarService()
|
||||||
|
|
||||||
|
const selectedFile = ref<File | null>(null)
|
||||||
|
const removing = ref(false)
|
||||||
|
|
||||||
|
function onFileSelect(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
const file = input.files?.[0]
|
||||||
|
if (file) {
|
||||||
|
selectedFile.value = file
|
||||||
|
}
|
||||||
|
input.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onCrop(blob: Blob) {
|
||||||
|
selectedFile.value = null
|
||||||
|
if (!auth.user) return
|
||||||
|
|
||||||
|
await upload(auth.user.id, blob)
|
||||||
|
await auth.refreshUser()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRemove() {
|
||||||
|
if (!auth.user) return
|
||||||
|
removing.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
await remove(auth.user.id)
|
||||||
|
await auth.refreshUser()
|
||||||
|
} finally {
|
||||||
|
removing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user