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>