Implementar sistema completo de captura y gestión de avatares
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 57s
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 57s
- Agregar CameraCapture.vue con soporte multi-dispositivo * Soporte para múltiples cámaras (frontal/trasera) * Manejo robusto de permisos y errores * Preview y confirmación de foto * Detección automática de capacidades del dispositivo - Crear endpoint /api/avatar/upload para subir avatares * Validación de tipo y tamaño de archivo * Almacenamiento en /public/avatars/ * Actualización de atributos en Authentik * Limpieza automática de avatares antiguos - Actualizar UserProfileForm con gestión de avatar * Integración con CameraCapture en modal * Preview del avatar actual con MsnAvatar * Opciones para cambiar y eliminar avatar - Actualizar useAuthentik para avatares personalizados * Carga de atributos completos del usuario * Soporte para avatar_url desde Authentik * Fallback a UI Avatars si no hay custom avatar
This commit is contained in:
@@ -160,19 +160,51 @@
|
||||
<span class="field-help">Próximamente disponible</span>
|
||||
</div>
|
||||
|
||||
<!-- Avatar URL (deshabilitado) -->
|
||||
<div class="form-field">
|
||||
<!-- Avatar / Foto de perfil -->
|
||||
<div class="form-field full-width">
|
||||
<label class="field-label">
|
||||
<UIcon name="i-heroicons-photo" class="w-4 h-4" />
|
||||
URL del avatar
|
||||
<UIcon name="i-heroicons-camera" class="w-4 h-4" />
|
||||
Foto de perfil
|
||||
</label>
|
||||
<UInput
|
||||
v-model="formData.avatar"
|
||||
placeholder="https://ejemplo.com/avatar.jpg"
|
||||
disabled
|
||||
:ui="{ base: 'cursor-not-allowed opacity-50' }"
|
||||
/>
|
||||
<span class="field-help">Próximamente disponible</span>
|
||||
|
||||
<div class="avatar-section">
|
||||
<!-- Preview del avatar actual -->
|
||||
<div class="avatar-preview">
|
||||
<MsnAvatar
|
||||
:src="currentAvatar"
|
||||
:alt="user?.name || user?.username"
|
||||
:presence-status="'online'"
|
||||
:size="120"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Botones de acción -->
|
||||
<div class="avatar-actions">
|
||||
<UButton
|
||||
@click="showCamera = true"
|
||||
color="primary"
|
||||
:disabled="isUploading"
|
||||
>
|
||||
<UIcon name="i-heroicons-camera" class="w-5 h-5" />
|
||||
{{ currentAvatar ? 'Cambiar foto' : 'Tomar foto' }}
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
v-if="currentAvatar && currentAvatar.includes('/avatars/')"
|
||||
@click="removeAvatar"
|
||||
color="neutral"
|
||||
variant="soft"
|
||||
:disabled="isUploading"
|
||||
>
|
||||
<UIcon name="i-heroicons-trash" class="w-5 h-5" />
|
||||
Eliminar
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="field-help">
|
||||
Usa la cámara para tomar una foto de perfil personalizada
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -253,6 +285,25 @@
|
||||
</UButton>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Modal de cámara -->
|
||||
<UModal v-model="showCamera" :ui="{ width: 'max-w-3xl' }">
|
||||
<div class="camera-modal">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">
|
||||
<UIcon name="i-heroicons-camera" class="w-6 h-6" />
|
||||
Tomar foto de perfil
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="modal-content">
|
||||
<CameraCapture
|
||||
@capture="handleAvatarCapture"
|
||||
@cancel="showCamera = false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</UModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -265,6 +316,8 @@ defineEmits(['close'])
|
||||
|
||||
// Estado del formulario
|
||||
const isSubmitting = ref(false)
|
||||
const isUploading = ref(false)
|
||||
const showCamera = ref(false)
|
||||
|
||||
// Datos del formulario
|
||||
const formData = ref({
|
||||
@@ -279,6 +332,16 @@ const formData = ref({
|
||||
nucleoCode: ''
|
||||
})
|
||||
|
||||
// Avatar actual del usuario
|
||||
const currentAvatar = ref(user.value?.avatar || '')
|
||||
|
||||
// Actualizar avatar cuando cambie el usuario
|
||||
watch(() => user.value?.avatar, (newAvatar) => {
|
||||
if (newAvatar) {
|
||||
currentAvatar.value = newAvatar
|
||||
}
|
||||
})
|
||||
|
||||
// Enviar formulario
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.value.name || !formData.value.email) {
|
||||
@@ -324,6 +387,95 @@ const handleSubmit = async () => {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Manejar captura de avatar desde cámara
|
||||
const handleAvatarCapture = async (imageBlob: Blob) => {
|
||||
isUploading.value = true
|
||||
showCamera.value = false
|
||||
|
||||
try {
|
||||
// Crear FormData para subir el archivo
|
||||
const formData = new FormData()
|
||||
formData.append('avatar', imageBlob, 'avatar.jpg')
|
||||
|
||||
// Subir avatar
|
||||
const response = await $fetch<{ success: boolean; avatarUrl: string; message: string }>('/api/avatar/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
// Actualizar preview del avatar
|
||||
currentAvatar.value = response.avatarUrl
|
||||
|
||||
toast.add({
|
||||
title: 'Avatar actualizado',
|
||||
description: response.message,
|
||||
color: 'success',
|
||||
icon: 'i-heroicons-check-circle',
|
||||
actions: [{
|
||||
label: 'Recargar',
|
||||
onClick: () => window.location.reload()
|
||||
}]
|
||||
})
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error uploading avatar:', error)
|
||||
toast.add({
|
||||
title: 'Error',
|
||||
description: error.data?.message || 'No se pudo subir el avatar',
|
||||
color: 'error',
|
||||
icon: 'i-heroicons-x-circle'
|
||||
})
|
||||
} finally {
|
||||
isUploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Eliminar avatar personalizado
|
||||
const removeAvatar = async () => {
|
||||
if (!confirm('¿Estás seguro de que quieres eliminar tu foto de perfil?')) {
|
||||
return
|
||||
}
|
||||
|
||||
isUploading.value = true
|
||||
|
||||
try {
|
||||
// Actualizar Authentik para eliminar avatar_url
|
||||
await $fetch('/api/authentik/user', {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
name: formData.value.name,
|
||||
email: formData.value.email,
|
||||
removeAvatar: true
|
||||
}
|
||||
})
|
||||
|
||||
// Volver al avatar por defecto
|
||||
currentAvatar.value = user.value?.avatar || ''
|
||||
|
||||
toast.add({
|
||||
title: 'Avatar eliminado',
|
||||
description: 'Se restauró el avatar por defecto. Recarga para ver los cambios.',
|
||||
color: 'success',
|
||||
icon: 'i-heroicons-check-circle',
|
||||
actions: [{
|
||||
label: 'Recargar',
|
||||
onClick: () => window.location.reload()
|
||||
}]
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error removing avatar:', error)
|
||||
toast.add({
|
||||
title: 'Error',
|
||||
description: 'No se pudo eliminar el avatar',
|
||||
color: 'error',
|
||||
icon: 'i-heroicons-x-circle'
|
||||
})
|
||||
} finally {
|
||||
isUploading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -413,6 +565,50 @@ const handleSubmit = async () => {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.avatar-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
padding: 1rem;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 1rem;
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.avatar-preview {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.avatar-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.camera-modal {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-gray-900);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
@@ -447,6 +643,15 @@ const handleSubmit = async () => {
|
||||
.form-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.avatar-section {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.avatar-actions {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -480,4 +685,13 @@ const handleSubmit = async () => {
|
||||
.dark .form-actions {
|
||||
border-top-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.dark .avatar-section {
|
||||
background: rgba(255, 255, 255, 0.05) !important;
|
||||
border-color: rgba(255, 255, 255, 0.1) !important;
|
||||
}
|
||||
|
||||
.dark .modal-title {
|
||||
color: var(--color-gray-100) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user