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
441 lines
10 KiB
Vue
441 lines
10 KiB
Vue
<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>
|