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:
440
nuxt4/app/components/CameraCapture.vue
Normal file
440
nuxt4/app/components/CameraCapture.vue
Normal 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>
|
||||
Reference in New Issue
Block a user