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

@@ -0,0 +1,440 @@
<template>
<div class="camera-capture">
<div v-if="!hasPermission && !error" class="camera-placeholder">
<UIcon name="i-heroicons-camera" class="w-16 h-16 text-gray-400" />
<p class="text-gray-600 dark:text-gray-400 mt-4">
{{ permissionMessage }}
</p>
<UButton
v-if="!isRequestingPermission"
@click="requestCamera"
color="primary"
class="mt-4"
>
<UIcon name="i-heroicons-camera" class="w-5 h-5" />
Activar cámara
</UButton>
<div v-else class="mt-4">
<UIcon name="i-heroicons-arrow-path" class="w-6 h-6 animate-spin text-primary-500" />
<p class="text-sm text-gray-500 mt-2">Solicitando permisos...</p>
</div>
</div>
<div v-else-if="error" class="camera-error">
<UIcon name="i-heroicons-exclamation-triangle" class="w-16 h-16 text-error-500" />
<p class="text-error-600 dark:text-error-400 mt-4 font-semibold">
{{ error }}
</p>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-2">
{{ errorHelp }}
</p>
<UButton
@click="retry"
color="primary"
variant="soft"
class="mt-4"
>
<UIcon name="i-heroicons-arrow-path" class="w-5 h-5" />
Reintentar
</UButton>
</div>
<div v-else-if="!capturedImage" class="camera-active">
<div class="video-container">
<video
ref="videoElement"
autoplay
playsinline
muted
class="camera-video"
></video>
<!-- Overlay con guías de encuadre -->
<div class="camera-overlay">
<div class="camera-guide"></div>
</div>
</div>
<div class="camera-controls">
<!-- Selector de cámara (si hay múltiples) -->
<UButton
v-if="cameras.length > 1"
@click="switchCamera"
color="neutral"
variant="ghost"
:disabled="isSwitching"
>
<UIcon name="i-heroicons-arrow-path" class="w-5 h-5" />
Cambiar cámara
</UButton>
<!-- Botón de captura -->
<UButton
@click="capture"
color="primary"
size="xl"
class="capture-button"
>
<UIcon name="i-heroicons-camera" class="w-6 h-6" />
Capturar foto
</UButton>
<!-- Botón de cancelar -->
<UButton
@click="cancel"
color="neutral"
variant="ghost"
>
<UIcon name="i-heroicons-x-mark" class="w-5 h-5" />
Cancelar
</UButton>
</div>
</div>
<div v-else class="camera-preview">
<div class="preview-container">
<img :src="capturedImage" alt="Foto capturada" class="preview-image" />
</div>
<div class="preview-controls">
<UButton
@click="retake"
color="neutral"
variant="soft"
>
<UIcon name="i-heroicons-arrow-path" class="w-5 h-5" />
Tomar otra
</UButton>
<UButton
@click="confirm"
color="primary"
>
<UIcon name="i-heroicons-check" class="w-5 h-5" />
Usar esta foto
</UButton>
</div>
</div>
<canvas ref="canvasElement" style="display: none;"></canvas>
</div>
</template>
<script setup lang="ts">
interface CameraDevice {
deviceId: string
label: string
kind: string
}
const emit = defineEmits<{
(e: 'capture', image: Blob): void
(e: 'cancel'): void
}>()
const videoElement = ref<HTMLVideoElement | null>(null)
const canvasElement = ref<HTMLCanvasElement | null>(null)
const stream = ref<MediaStream | null>(null)
const capturedImage = ref<string | null>(null)
const hasPermission = ref(false)
const isRequestingPermission = ref(false)
const isSwitching = ref(false)
const error = ref<string | null>(null)
const errorHelp = ref<string>('')
const permissionMessage = ref('Necesitamos acceso a tu cámara para tomar una foto de perfil')
const cameras = ref<CameraDevice[]>([])
const currentCameraIndex = ref(0)
// Detectar capacidades del dispositivo
const getUserMedia = navigator.mediaDevices?.getUserMedia?.bind(navigator.mediaDevices)
const enumerateDevices = navigator.mediaDevices?.enumerateDevices?.bind(navigator.mediaDevices)
// Listar cámaras disponibles
const listCameras = async () => {
if (!enumerateDevices) return
try {
const devices = await enumerateDevices()
cameras.value = devices
.filter(device => device.kind === 'videoinput')
.map(device => ({
deviceId: device.deviceId,
label: device.label || `Cámara ${cameras.value.length + 1}`,
kind: device.kind
}))
} catch (err) {
console.error('Error listing cameras:', err)
}
}
// Solicitar acceso a la cámara
const requestCamera = async () => {
if (!getUserMedia) {
error.value = 'Tu navegador no soporta acceso a la cámara'
errorHelp.value = 'Por favor usa un navegador moderno como Chrome, Firefox o Safari'
return
}
isRequestingPermission.value = true
error.value = null
try {
// Primero solicitar permisos básicos
const testStream = await getUserMedia({ video: true })
testStream.getTracks().forEach(track => track.stop())
// Listar cámaras disponibles
await listCameras()
// Iniciar con la cámara preferida
await startCamera()
hasPermission.value = true
} catch (err: any) {
handleCameraError(err)
} finally {
isRequestingPermission.value = false
}
}
// Iniciar stream de cámara
const startCamera = async () => {
if (!getUserMedia) return
const constraints: MediaStreamConstraints = {
video: {
width: { ideal: 1280 },
height: { ideal: 720 },
facingMode: currentCameraIndex.value === 0 ? 'user' : 'environment'
},
audio: false
}
// Si tenemos un deviceId específico, úsalo
if (cameras.value.length > 0 && cameras.value[currentCameraIndex.value]) {
const camera = cameras.value[currentCameraIndex.value]
constraints.video = {
...constraints.video,
deviceId: { exact: camera.deviceId }
} as MediaTrackConstraints
}
try {
stream.value = await getUserMedia(constraints)
if (videoElement.value) {
videoElement.value.srcObject = stream.value
}
} catch (err: any) {
handleCameraError(err)
}
}
// Manejar errores de cámara
const handleCameraError = (err: any) => {
console.error('Camera error:', err)
if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') {
error.value = 'Permiso denegado'
errorHelp.value = 'Por favor permite el acceso a la cámara en la configuración de tu navegador'
} else if (err.name === 'NotFoundError' || err.name === 'DevicesNotFoundError') {
error.value = 'No se encontró ninguna cámara'
errorHelp.value = 'Asegúrate de que tu dispositivo tenga una cámara conectada'
} else if (err.name === 'NotReadableError' || err.name === 'TrackStartError') {
error.value = 'La cámara está en uso'
errorHelp.value = 'Cierra otras aplicaciones que puedan estar usando la cámara'
} else if (err.name === 'OverconstrainedError') {
error.value = 'Configuración no compatible'
errorHelp.value = 'Tu cámara no soporta la resolución solicitada'
} else {
error.value = 'Error al acceder a la cámara'
errorHelp.value = err.message || 'Intenta recargar la página o usar otro navegador'
}
}
// Cambiar entre cámaras
const switchCamera = async () => {
if (cameras.value.length <= 1) return
isSwitching.value = true
// Detener stream actual
stopCamera()
// Cambiar a siguiente cámara
currentCameraIndex.value = (currentCameraIndex.value + 1) % cameras.value.length
// Iniciar nueva cámara
await startCamera()
isSwitching.value = false
}
// Capturar foto
const capture = () => {
if (!videoElement.value || !canvasElement.value) return
const video = videoElement.value
const canvas = canvasElement.value
// Configurar canvas con el tamaño del video
canvas.width = video.videoWidth
canvas.height = video.videoHeight
// Dibujar frame actual del video
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
// Convertir a imagen
capturedImage.value = canvas.toDataURL('image/jpeg', 0.9)
// Detener cámara
stopCamera()
}
// Reintentar captura
const retake = () => {
capturedImage.value = null
error.value = null
requestCamera()
}
// Confirmar y enviar imagen
const confirm = () => {
if (!canvasElement.value || !capturedImage.value) return
canvasElement.value.toBlob(
(blob) => {
if (blob) {
emit('capture', blob)
}
},
'image/jpeg',
0.9
)
}
// Cancelar
const cancel = () => {
stopCamera()
emit('cancel')
}
// Reintentar
const retry = () => {
error.value = null
errorHelp.value = ''
hasPermission.value = false
requestCamera()
}
// Detener cámara
const stopCamera = () => {
if (stream.value) {
stream.value.getTracks().forEach(track => track.stop())
stream.value = null
}
}
// Cleanup al desmontar
onUnmounted(() => {
stopCamera()
})
</script>
<style scoped>
.camera-capture {
width: 100%;
max-width: 600px;
margin: 0 auto;
}
.camera-placeholder,
.camera-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem 2rem;
text-align: center;
}
.camera-active,
.camera-preview {
display: flex;
flex-direction: column;
gap: 1rem;
}
.video-container,
.preview-container {
position: relative;
width: 100%;
aspect-ratio: 4 / 3;
background: var(--color-gray-900);
border-radius: 1rem;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
.camera-video,
.preview-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.camera-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
}
.camera-guide {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 70%;
aspect-ratio: 1;
border: 3px solid rgba(255, 255, 255, 0.5);
border-radius: 50%;
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.3);
}
.camera-controls,
.preview-controls {
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.capture-button {
flex: 0 0 auto;
}
@media (max-width: 768px) {
.video-container,
.preview-container {
aspect-ratio: 3 / 4;
}
.camera-controls,
.preview-controls {
flex-direction: column;
width: 100%;
}
.camera-controls button,
.preview-controls button {
width: 100%;
}
}
</style>

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>