Implementar sistema completo de captura y gestión de avatares
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:
2025-10-17 16:35:59 -06:00
parent 66be233e3a
commit 8109f7e1d0
5 changed files with 860 additions and 13 deletions

View File

@@ -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>