Files
perfil/nuxt4/app/components/UserProfileForm.vue
josedario87 00596bd6df
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 55s
Agregar opción para cargar avatar desde URL en edición de perfil
Se añade campo de entrada para pegar URLs de imágenes directamente,
permitiendo cargar avatares desde enlaces externos. Incluye validación
de URLs, descarga de imágenes con manejo de errores CORS, validación
de tipo y tamaño (máx 5MB), con feedback visual de errores específicos.
2025-10-18 00:20:39 -06:00

1478 lines
38 KiB
Vue

<template>
<div class="profile-form-container">
<div class="form-header">
<div class="flex items-center justify-between mb-4">
<h2 class="form-title">
<UIcon name="i-heroicons-user-circle" class="w-6 h-6" />
Editar Perfil
</h2>
<UButton
color="neutral"
variant="ghost"
icon="i-heroicons-x-mark"
@click="handleClose"
>
Cancelar
</UButton>
</div>
<!-- Banner informativo sobre guardado local -->
<div class="info-banner">
<UIcon name="i-heroicons-information-circle" class="w-5 h-5 flex-shrink-0" />
<p class="info-banner-text">
Los cambios que realices se guardan <strong>automáticamente en tu navegador</strong>.
Para que sean permanentes y se sincronicen con el servidor, debes presionar <strong>"Guardar cambios"</strong>.
</p>
</div>
</div>
<form @submit.prevent="handleSubmit" class="form-content">
<!-- Sección: Foto de Perfil -->
<div class="form-section">
<h3 class="section-title">
<UIcon name="i-heroicons-camera" class="w-5 h-5" />
Foto de Perfil
</h3>
<!-- Zona de drag & drop -->
<div
class="avatar-dropzone"
:class="{ 'dropzone-active': isDragging }"
@drop.prevent="handleDrop"
@dragover.prevent="handleDragOver"
@dragleave="handleDragLeave"
>
<UIcon name="i-heroicons-photo" class="w-12 h-12 dropzone-icon" />
<p class="dropzone-text">
Arrastra una imagen aquí
</p>
<span class="dropzone-hint">o usa los botones de abajo</span>
</div>
<!-- Campo para URL de imagen -->
<div class="avatar-url-section">
<div class="flex items-center gap-2 mb-2">
<UIcon name="i-heroicons-link" class="w-4 h-4 text-gray-600 dark:text-gray-400" />
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">O pega una URL de imagen</span>
</div>
<div class="flex gap-2">
<UInput
v-model="avatarUrl"
placeholder="https://ejemplo.com/imagen.jpg"
:disabled="isUploading"
class="flex-1"
/>
<UButton
@click="loadAvatarFromUrl"
color="primary"
variant="outline"
:disabled="isUploading || !avatarUrl"
:loading="isLoadingUrl"
>
<UIcon name="i-heroicons-arrow-down-tray" class="w-4 h-4" />
Cargar
</UButton>
</div>
<span v-if="urlError" class="text-xs text-red-500 mt-1">{{ urlError }}</span>
</div>
<!-- Botones de acción -->
<div class="avatar-actions">
<input
ref="fileInput"
type="file"
accept="image/*"
@change="handleFileSelect"
style="display: none"
/>
<UButton
@click="showCamera = true"
color="primary"
size="sm"
:disabled="isUploading"
>
<UIcon name="i-heroicons-camera" class="w-4 h-4" />
Tomar foto
</UButton>
<UButton
@click="triggerFileInput"
color="primary"
variant="soft"
size="sm"
:disabled="isUploading"
>
<UIcon name="i-heroicons-arrow-up-tray" class="w-4 h-4" />
Subir desde dispositivo
</UButton>
<UButton
v-if="currentAvatar && currentAvatar.includes('/avatars/')"
@click="removeAvatar"
color="neutral"
variant="soft"
size="sm"
:disabled="isUploading"
>
<UIcon name="i-heroicons-trash" class="w-4 h-4" />
Eliminar
</UButton>
</div>
</div>
<!-- Sección: Información Básica -->
<div class="form-section">
<h3 class="section-title">
<UIcon name="i-heroicons-identification" class="w-5 h-5" />
Información Básica
</h3>
<div class="form-grid">
<!-- Nombre de usuario (readonly) -->
<div class="form-field">
<label class="field-label">
<UIcon name="i-heroicons-at-symbol" class="w-4 h-4" />
Nombre de usuario
</label>
<UInput
:model-value="user?.username"
disabled
:ui="{ base: 'cursor-not-allowed opacity-50' }"
/>
<span class="field-help">No se puede cambiar</span>
</div>
<!-- UID de Authentik (readonly) -->
<div class="form-field">
<label class="field-label">
<UIcon name="i-heroicons-finger-print" class="w-4 h-4" />
UID de Authentik
</label>
<UInput
:model-value="user?.uid || 'No disponible'"
disabled
:ui="{ base: 'cursor-not-allowed opacity-50' }"
/>
<span class="field-help">Identificador único del sistema</span>
</div>
<!-- Nombre completo -->
<div class="form-field">
<label class="field-label" :class="{ 'field-modified': modifiedFields.name }">
<UIcon name="i-heroicons-user" class="w-4 h-4" />
Nombre completo
<span class="required">*</span>
<span v-if="modifiedFields.name" class="modified-indicator">
<UIcon name="i-heroicons-pencil-square" class="w-3 h-3" />
Modificado
</span>
</label>
<UInput
v-model="formData.name"
placeholder="Tu nombre completo"
:disabled="isSubmitting"
/>
</div>
<!-- Email -->
<div class="form-field">
<label class="field-label" :class="{ 'field-modified': modifiedFields.email }">
<UIcon name="i-heroicons-envelope" class="w-4 h-4" />
Correo electrónico
<span class="required">*</span>
<span v-if="modifiedFields.email" class="modified-indicator">
<UIcon name="i-heroicons-pencil-square" class="w-3 h-3" />
Modificado
</span>
</label>
<UInput
v-model="formData.email"
type="email"
placeholder="tu@email.com"
:disabled="isSubmitting"
/>
</div>
</div>
</div>
<!-- Sección: Conectividad -->
<div class="form-section">
<h3 class="section-title">
<UIcon name="i-heroicons-wifi" class="w-5 h-5" />
Conectividad
</h3>
<div class="form-grid">
<!-- Contraseña WiFi (readonly) -->
<div class="form-field full-width">
<label class="field-label">
<UIcon name="i-heroicons-key" class="w-4 h-4" />
Contraseña WiFi Nucleo
</label>
<UInput
value="Nucleo2024WiFi!"
disabled
type="text"
:ui="{ base: 'cursor-not-allowed opacity-50' }"
/>
<span class="field-help">Contraseña de la red WiFi de Nucleo</span>
</div>
</div>
</div>
<!-- Sección: Información Personal -->
<div class="form-section">
<h3 class="section-title">
<UIcon name="i-heroicons-user-circle" class="w-5 h-5" />
Información Personal
</h3>
<div class="form-grid">
<!-- Teléfono (deshabilitado) -->
<div class="form-field">
<label class="field-label">
<UIcon name="i-heroicons-phone" class="w-4 h-4" />
Teléfono
</label>
<UInput
v-model="formData.phone"
placeholder="+506 1234-5678"
disabled
:ui="{ base: 'cursor-not-allowed opacity-50' }"
/>
<span class="field-help">Próximamente disponible</span>
</div>
<!-- Cédula (deshabilitado) -->
<div class="form-field">
<label class="field-label">
<UIcon name="i-heroicons-identification" class="w-4 h-4" />
Cédula
</label>
<UInput
v-model="formData.cedula"
placeholder="1-2345-6789"
disabled
:ui="{ base: 'cursor-not-allowed opacity-50' }"
/>
<span class="field-help">Próximamente disponible</span>
</div>
<!-- Fecha de nacimiento (deshabilitado) -->
<div class="form-field">
<label class="field-label">
<UIcon name="i-heroicons-cake" class="w-4 h-4" />
Fecha de nacimiento
</label>
<UInput
v-model="formData.birthdate"
type="date"
disabled
:ui="{ base: 'cursor-not-allowed opacity-50' }"
/>
<span class="field-help">Próximamente disponible</span>
</div>
</div>
</div>
<!-- Sección: Seguridad y Acceso -->
<div class="form-section">
<h3 class="section-title">
<UIcon name="i-heroicons-shield-check" class="w-5 h-5" />
Seguridad y Acceso
</h3>
<div class="form-grid">
<!-- NFC vinculada (deshabilitado) -->
<div class="form-field">
<label class="field-label">
<UIcon name="i-heroicons-credit-card" class="w-4 h-4" />
NFC vinculada
</label>
<UInput
v-model="formData.nfc"
placeholder="ID de tarjeta NFC"
disabled
:ui="{ base: 'cursor-not-allowed opacity-50' }"
/>
<span class="field-help">Próximamente disponible</span>
</div>
<!-- PIN numérico (deshabilitado) -->
<div class="form-field">
<label class="field-label">
<UIcon name="i-heroicons-lock-closed" class="w-4 h-4" />
PIN numérico
</label>
<UInput
v-model="formData.pin"
type="password"
placeholder="••••"
disabled
:ui="{ base: 'cursor-not-allowed opacity-50' }"
/>
<span class="field-help">Próximamente disponible</span>
</div>
<!-- Código Nucleo V2 (deshabilitado) -->
<div class="form-field">
<label class="field-label">
<UIcon name="i-heroicons-qr-code" class="w-4 h-4" />
Código Nucleo V2
</label>
<UInput
v-model="formData.nucleoCode"
placeholder="NUCLEO-XXXX-XXXX"
disabled
:ui="{ base: 'cursor-not-allowed opacity-50' }"
/>
<span class="field-help">Próximamente disponible</span>
</div>
</div>
</div>
<!-- Botones de acción -->
<div class="form-actions">
<UButton
color="neutral"
variant="ghost"
size="lg"
@click="handleClose"
:disabled="isSubmitting"
>
Cancelar
</UButton>
<UButton
v-if="hasChanges"
color="warning"
variant="soft"
size="lg"
@click="resetForm"
:disabled="isSubmitting"
>
<UIcon name="i-heroicons-arrow-path" class="w-5 h-5" />
Reiniciar
</UButton>
<UButton
color="primary"
size="lg"
type="submit"
:loading="isSubmitting"
>
Guardar cambios
</UButton>
</div>
</form>
<!-- Modal de cámara -->
<UModal v-model:open="showCamera">
<template #content>
<div class="camera-modal max-w-3xl mx-auto">
<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>
</template>
</UModal>
<!-- Modal de confirmación de salida -->
<UModal v-model:open="showExitConfirm">
<template #content>
<div class="exit-confirm-modal">
<div class="exit-modal-header">
<UIcon name="i-heroicons-exclamation-triangle" class="w-12 h-12 text-warning-500" />
<h3 class="exit-modal-title">¿Estás seguro de salir?</h3>
</div>
<div class="exit-modal-body">
<p class="exit-modal-message">
Tienes cambios pendientes en:
</p>
<ul class="exit-modal-fields">
<li v-for="field in modifiedFieldsList" :key="field" class="exit-field-item">
<UIcon name="i-heroicons-pencil-square" class="w-4 h-4 text-warning-500" />
{{ field }}
</li>
</ul>
<div class="exit-modal-notice">
<UIcon name="i-heroicons-information-circle" class="w-5 h-5" />
<p>
Estos cambios quedarán guardados <strong>localmente en tu navegador</strong>,
pero <strong class="text-error-600 dark:text-error-400">NO han sido enviados al servidor</strong>.
</p>
</div>
<div class="exit-modal-help">
<UIcon name="i-heroicons-light-bulb" class="w-5 h-5 text-primary-500" />
<p>
Para guardar definitivamente, haz clic en <strong>"Guardar cambios"</strong>.
</p>
</div>
</div>
<div class="exit-modal-actions">
<UButton
color="neutral"
variant="soft"
size="lg"
@click="showExitConfirm = false"
>
<UIcon name="i-heroicons-arrow-left" class="w-5 h-5" />
Continuar editando
</UButton>
<UButton
color="error"
size="lg"
@click="confirmExit"
>
<UIcon name="i-heroicons-arrow-right-on-rectangle" class="w-5 h-5" />
Salir sin guardar
</UButton>
</div>
</div>
</template>
</UModal>
</div>
</template>
<script setup lang="ts">
const { user } = useAuthentik()
const toast = useToast()
// Props
const props = defineProps<{
sharedImageUrl?: string | null
}>()
// Emits
const emit = defineEmits(['close'])
// Estado del formulario
const isSubmitting = ref(false)
const isUploading = ref(false)
const showCamera = ref(false)
const showExitConfirm = ref(false)
const isDragging = ref(false)
const isLoadingUrl = ref(false)
// Ref para el input de archivo
const fileInput = ref<HTMLInputElement | null>(null)
// URL del avatar
const avatarUrl = ref('')
const urlError = ref('')
// Cookie para persistir cambios del formulario
const formCookie = useCookie<Record<string, string>>('profile-form-draft', {
maxAge: 60 * 60 * 24 * 7, // 7 días
sameSite: 'lax',
default: () => ({})
})
// Datos originales del usuario (para comparar)
const originalData = ref({
name: user.value?.name || '',
email: user.value?.email || '',
avatar: '',
phone: '',
cedula: '',
birthdate: '',
nfc: '',
pin: '',
nucleoCode: ''
})
// Datos del formulario (restaurar desde cookie si existe)
const formData = ref({
name: formCookie.value.name || user.value?.name || '',
email: formCookie.value.email || user.value?.email || '',
avatar: formCookie.value.avatar || '',
phone: formCookie.value.phone || '',
cedula: formCookie.value.cedula || '',
birthdate: formCookie.value.birthdate || '',
nfc: formCookie.value.nfc || '',
pin: formCookie.value.pin || '',
nucleoCode: formCookie.value.nucleoCode || ''
})
// Avatar actual del usuario
const currentAvatar = ref(user.value?.avatar || '')
// Detectar qué campos han sido modificados
const modifiedFields = computed(() => {
const modified: Record<string, boolean> = {}
const keys = Object.keys(formData.value) as Array<keyof typeof formData.value>
keys.forEach(key => {
modified[key] = formData.value[key] !== originalData.value[key]
})
return modified
})
// Verificar si hay cambios pendientes
const hasChanges = computed(() => {
return Object.values(modifiedFields.value).some(modified => modified)
})
// Lista de campos modificados (para mostrar en alerta)
const modifiedFieldsList = computed(() => {
const fieldNames: Record<string, string> = {
name: 'Nombre completo',
email: 'Correo electrónico',
phone: 'Teléfono',
cedula: 'Cédula',
birthdate: 'Fecha de nacimiento',
nfc: 'NFC vinculada',
pin: 'PIN numérico',
nucleoCode: 'Código Nucleo V2'
}
return Object.entries(modifiedFields.value)
.filter(([_, modified]) => modified)
.map(([field, _]) => fieldNames[field] || field)
})
// Guardar en cookie cuando cambian los datos
watch(formData, (newData) => {
formCookie.value = { ...newData }
}, { deep: true })
// Actualizar avatar cuando cambie el usuario
watch(() => user.value?.avatar, (newAvatar) => {
if (newAvatar) {
currentAvatar.value = newAvatar
}
})
// Alertar al usuario antes de salir si hay cambios
if (import.meta.client) {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (hasChanges.value) {
const message = `¿Estás seguro de salir?\n\nTienes cambios pendientes en: ${modifiedFieldsList.value.join(', ')}\n\nEstos cambios quedarán guardados localmente en tu navegador, pero NO han sido enviados al servidor.\n\nPara guardar definitivamente, debes hacer clic en "Guardar cambios".`
e.preventDefault()
e.returnValue = message
return message
}
}
onMounted(() => {
window.addEventListener('beforeunload', handleBeforeUnload)
// Mostrar notificación si hay datos restaurados
if (Object.keys(formCookie.value).length > 0 && hasChanges.value) {
toast.add({
title: 'Borrador restaurado',
description: `Se restauraron cambios pendientes en: ${modifiedFieldsList.value.join(', ')}`,
color: 'info',
icon: 'i-heroicons-document-text'
})
}
})
onUnmounted(() => {
window.removeEventListener('beforeunload', handleBeforeUnload)
})
// Detectar y procesar imagen compartida automáticamente
watch(() => props.sharedImageUrl, async (newUrl) => {
if (newUrl && import.meta.client) {
await processSharedImage(newUrl)
}
}, { immediate: true })
}
// Enviar formulario
const handleSubmit = async () => {
if (!formData.value.name || !formData.value.email) {
toast.add({
title: 'Error',
description: 'Por favor completa todos los campos requeridos',
color: 'error',
icon: 'i-heroicons-exclamation-triangle'
})
return
}
isSubmitting.value = true
try {
await $fetch('/api/authentik/user', {
method: 'PATCH',
body: {
name: formData.value.name,
email: formData.value.email
}
})
// Limpiar cookie de borrador al guardar exitosamente
formCookie.value = {}
// Actualizar datos originales
originalData.value.name = formData.value.name
originalData.value.email = formData.value.email
toast.add({
title: 'Perfil actualizado',
description: 'Tus cambios se guardaron correctamente. Recarga la página para verlos.',
color: 'success',
icon: 'i-heroicons-check-circle',
actions: [{
label: 'Recargar',
onClick: () => window.location.reload()
}]
})
} catch (error) {
console.error('Error updating profile:', error)
toast.add({
title: 'Error',
description: 'No se pudo actualizar el perfil',
color: 'error',
icon: 'i-heroicons-x-circle'
})
} finally {
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
}
}
// Manejar intento de cerrar formulario
const handleClose = () => {
if (hasChanges.value) {
// Si hay cambios, mostrar modal de confirmación
showExitConfirm.value = true
} else {
// Si no hay cambios, cerrar directamente
emit('close')
}
}
// Confirmar salida sin guardar
const confirmExit = () => {
showExitConfirm.value = false
emit('close')
}
// Reiniciar formulario a valores originales
const resetForm = () => {
if (!confirm('¿Estás seguro de descartar todos los cambios?\n\nSe restaurarán los valores originales y se eliminará el borrador guardado.')) {
return
}
// Restaurar valores originales
formData.value = {
name: originalData.value.name,
email: originalData.value.email,
avatar: originalData.value.avatar,
phone: originalData.value.phone,
cedula: originalData.value.cedula,
birthdate: originalData.value.birthdate,
nfc: originalData.value.nfc,
pin: originalData.value.pin,
nucleoCode: originalData.value.nucleoCode
}
// Limpiar cookie de borrador
formCookie.value = {}
toast.add({
title: 'Formulario reiniciado',
description: 'Se restauraron los valores originales y se eliminó el borrador',
color: 'info',
icon: 'i-heroicons-arrow-path'
})
}
// 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
}
}
// Abrir selector de archivos
const triggerFileInput = () => {
fileInput.value?.click()
}
// Manejar selección de archivo desde input
const handleFileSelect = (event: Event) => {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (file) {
processImageFile(file)
}
// Limpiar input para permitir seleccionar el mismo archivo de nuevo
input.value = ''
}
// Manejar drag over
const handleDragOver = (event: DragEvent) => {
event.preventDefault()
isDragging.value = true
}
// Manejar drag leave
const handleDragLeave = () => {
isDragging.value = false
}
// Manejar drop
const handleDrop = (event: DragEvent) => {
event.preventDefault()
isDragging.value = false
const file = event.dataTransfer?.files[0]
if (file) {
processImageFile(file)
}
}
// Procesar archivo de imagen
const processImageFile = async (file: File) => {
// Validar que sea una imagen
if (!file.type.startsWith('image/')) {
toast.add({
title: 'Archivo inválido',
description: 'Por favor selecciona un archivo de imagen',
color: 'error',
icon: 'i-heroicons-exclamation-triangle'
})
return
}
// Validar tamaño (máximo 5MB)
const maxSize = 5 * 1024 * 1024 // 5MB
if (file.size > maxSize) {
toast.add({
title: 'Archivo muy grande',
description: 'La imagen debe ser menor a 5MB',
color: 'error',
icon: 'i-heroicons-exclamation-triangle'
})
return
}
// Convertir a Blob y subir
const blob = new Blob([file], { type: file.type })
await handleAvatarCapture(blob)
}
// Cargar avatar desde URL
const loadAvatarFromUrl = async () => {
urlError.value = ''
// Validar que la URL no esté vacía
if (!avatarUrl.value.trim()) {
urlError.value = 'Por favor ingresa una URL'
return
}
// Validar formato de URL
try {
new URL(avatarUrl.value)
} catch (error) {
urlError.value = 'URL inválida. Por favor ingresa una URL válida (ej: https://ejemplo.com/imagen.jpg)'
return
}
// Validar que la URL sea https
if (!avatarUrl.value.startsWith('https://') && !avatarUrl.value.startsWith('http://')) {
urlError.value = 'La URL debe comenzar con http:// o https://'
return
}
isLoadingUrl.value = true
isUploading.value = true
try {
// Descargar la imagen
const response = await fetch(avatarUrl.value, {
mode: 'cors'
})
if (!response.ok) {
throw new Error(`Error al descargar la imagen: ${response.statusText}`)
}
const blob = await response.blob()
// Validar que sea una imagen
if (!blob.type.startsWith('image/')) {
throw new Error('El archivo en la URL no es una imagen válida')
}
// Validar tamaño (máximo 5MB)
const maxSize = 5 * 1024 * 1024 // 5MB
if (blob.size > maxSize) {
throw new Error('La imagen debe ser menor a 5MB')
}
// Procesar la imagen
await handleAvatarCapture(blob)
// Limpiar el campo de URL y errores
avatarUrl.value = ''
urlError.value = ''
toast.add({
title: 'Imagen cargada',
description: 'La imagen se descargó correctamente desde la URL',
color: 'success',
icon: 'i-heroicons-check-circle'
})
} catch (error: any) {
console.error('Error loading image from URL:', error)
// Manejar errores de CORS
if (error.message.includes('CORS') || error.message.includes('NetworkError')) {
urlError.value = 'No se pudo acceder a la imagen. Puede que el servidor no permita descargas externas (CORS). Intenta descargar la imagen manualmente y subirla desde tu dispositivo.'
} else {
urlError.value = error.message || 'No se pudo cargar la imagen desde la URL'
}
toast.add({
title: 'Error',
description: error.message || 'No se pudo cargar la imagen desde la URL',
color: 'error',
icon: 'i-heroicons-exclamation-triangle'
})
} finally {
isLoadingUrl.value = false
isUploading.value = false
}
}
// Procesar imagen compartida desde Web Share Target
const processSharedImage = async (imageUrl: string) => {
try {
// Mostrar notificación
toast.add({
title: 'Imagen compartida recibida',
description: 'Procesando imagen para tu avatar...',
color: 'info',
icon: 'i-heroicons-photo'
})
// Descargar la imagen del servidor
const response = await fetch(imageUrl)
if (!response.ok) {
throw new Error('No se pudo cargar la imagen compartida')
}
const blob = await response.blob()
// Validar que sea una imagen
if (!blob.type.startsWith('image/')) {
throw new Error('El archivo compartido no es una imagen válida')
}
// Procesar y subir la imagen
await handleAvatarCapture(blob)
// Limpiar archivo temporal del servidor
try {
await $fetch('/api/share-target/cleanup', {
method: 'POST',
body: { imageUrl }
})
} catch (cleanupError) {
console.error('Error limpiando archivo temporal:', cleanupError)
// No es crítico si falla la limpieza
}
} catch (error: any) {
console.error('Error procesando imagen compartida:', error)
toast.add({
title: 'Error',
description: error.message || 'No se pudo procesar la imagen compartida',
color: 'error',
icon: 'i-heroicons-exclamation-triangle'
})
}
}
</script>
<style scoped>
.profile-form-container {
background: rgba(255, 255, 255, 0.35);
backdrop-filter: blur(20px) saturate(180%);
border-radius: 1.5rem;
padding: 2rem;
box-shadow:
0 8px 32px 0 rgba(31, 38, 135, 0.15),
inset 0 1px 1px 0 rgba(255, 255, 255, 0.3);
border: 1px solid rgba(255, 255, 255, 0.18);
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.form-header {
margin-bottom: 1.5rem;
}
.info-banner {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.875rem 1.125rem;
background: rgba(0, 150, 255, 0.08);
border-left: 3px solid rgb(var(--color-primary-500));
border-radius: 0.625rem;
margin-top: 1rem;
}
.info-banner-text {
font-size: 0.875rem;
line-height: 1.5;
color: var(--color-gray-700);
margin: 0;
}
.info-banner-text strong {
color: var(--color-gray-900);
font-weight: 600;
}
.form-title {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 1.75rem;
font-weight: 700;
color: var(--color-gray-900);
margin: 0;
}
.form-content {
display: flex;
flex-direction: column;
gap: 2rem;
}
.form-section {
display: flex;
flex-direction: column;
gap: 1rem;
}
.section-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.125rem;
font-weight: 600;
color: var(--color-gray-800);
margin: 0 0 0.5rem 0;
padding-bottom: 0.75rem;
border-bottom: 2px solid rgba(var(--color-primary-500), 0.2);
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1.5rem;
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-field.full-width {
grid-column: 1 / -1;
}
.field-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--color-gray-700);
transition: color 0.3s ease;
}
.field-label.field-modified {
color: rgb(var(--color-warning-600));
}
.field-label .required {
color: rgb(var(--color-error-500));
margin-left: 0.125rem;
}
.modified-indicator {
display: inline-flex;
align-items: center;
gap: 0.25rem;
margin-left: auto;
padding: 0.125rem 0.5rem;
font-size: 0.6875rem;
font-weight: 600;
border-radius: 0.375rem;
background: rgba(var(--color-warning-500), 0.15);
color: rgb(var(--color-warning-600));
border: 1px solid rgba(var(--color-warning-500), 0.3);
}
.field-help {
font-size: 0.75rem;
color: var(--color-gray-500);
font-style: italic;
}
/* Zona de drag & drop para avatar */
.avatar-dropzone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 2rem;
border: 2px dashed rgba(var(--color-primary-500), 0.3);
border-radius: 1rem;
background: rgba(var(--color-primary-500), 0.05);
transition: all 0.3s ease;
cursor: pointer;
}
.avatar-dropzone:hover {
border-color: rgba(var(--color-primary-500), 0.5);
background: rgba(var(--color-primary-500), 0.1);
}
.avatar-dropzone.dropzone-active {
border-color: rgb(var(--color-primary-500));
background: rgba(var(--color-primary-500), 0.15);
border-style: solid;
transform: scale(1.02);
}
.dropzone-icon {
color: rgb(var(--color-primary-500));
opacity: 0.6;
transition: opacity 0.3s ease;
}
.avatar-dropzone:hover .dropzone-icon,
.avatar-dropzone.dropzone-active .dropzone-icon {
opacity: 1;
}
.dropzone-text {
font-size: 1rem;
font-weight: 600;
color: var(--color-gray-700);
margin: 0;
}
.dropzone-hint {
font-size: 0.875rem;
color: var(--color-gray-500);
font-style: italic;
}
.avatar-url-section {
margin-top: 1rem;
padding: 1rem;
background: rgba(var(--color-gray-100), 0.5);
border-radius: 0.75rem;
border: 1px solid rgba(var(--color-gray-300), 0.5);
}
.avatar-actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
margin-top: 1rem;
}
.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;
}
/* Modal de confirmación de salida */
.exit-confirm-modal {
padding: 2rem;
max-width: 600px;
margin: 0 auto;
}
.exit-modal-header {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
margin-bottom: 1.5rem;
text-align: center;
}
.exit-modal-title {
font-size: 1.5rem;
font-weight: 700;
color: var(--color-gray-900);
margin: 0;
}
.exit-modal-body {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 2rem;
}
.exit-modal-message {
font-size: 1rem;
font-weight: 600;
color: var(--color-gray-800);
margin: 0;
}
.exit-modal-fields {
list-style: none;
padding: 1rem;
margin: 0;
background: rgba(var(--color-warning-500), 0.1);
border-radius: 0.75rem;
border: 1px solid rgba(var(--color-warning-500), 0.3);
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.exit-field-item {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--color-gray-700);
}
.exit-modal-notice {
display: flex;
gap: 0.75rem;
padding: 1rem;
background: rgba(var(--color-error-500), 0.1);
border-radius: 0.75rem;
border: 1px solid rgba(var(--color-error-500), 0.3);
}
.exit-modal-notice p {
margin: 0;
font-size: 0.875rem;
line-height: 1.5;
color: var(--color-gray-700);
}
.exit-modal-help {
display: flex;
gap: 0.75rem;
padding: 1rem;
background: rgba(var(--color-primary-500), 0.1);
border-radius: 0.75rem;
border: 1px solid rgba(var(--color-primary-500), 0.3);
}
.exit-modal-help p {
margin: 0;
font-size: 0.875rem;
line-height: 1.5;
color: var(--color-gray-700);
}
.exit-modal-actions {
display: flex;
gap: 1rem;
justify-content: center;
}
@media (max-width: 640px) {
.exit-confirm-modal {
padding: 1.5rem;
}
.exit-modal-title {
font-size: 1.25rem;
}
.exit-modal-actions {
flex-direction: column-reverse;
}
.exit-modal-actions button {
width: 100%;
}
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 1rem;
padding-top: 1rem;
border-top: 1px solid rgba(0, 0, 0, 0.1);
}
/* Responsive */
@media (max-width: 768px) {
.profile-form-container {
padding: 1.5rem;
}
.form-title {
font-size: 1.5rem;
}
.section-title {
font-size: 1rem;
}
.form-grid {
grid-template-columns: 1fr;
gap: 1rem;
}
.form-field.full-width {
grid-column: 1;
}
.form-actions {
flex-direction: column;
}
}
</style>
<style>
/* Estilos de modo oscuro (sin scoped para que .dark funcione correctamente) */
.dark .profile-form-container {
background: rgba(0, 0, 0, 0.15) !important;
box-shadow:
0 8px 32px 0 rgba(0, 0, 0, 0.5),
inset 0 1px 1px 0 rgba(255, 255, 255, 0.05) !important;
border: 1px solid rgba(255, 255, 255, 0.08) !important;
}
.dark .form-title {
color: var(--color-gray-100) !important;
}
.dark .info-banner {
background: rgba(0, 150, 255, 0.12) !important;
border-left-color: rgb(var(--color-primary-400)) !important;
}
.dark .info-banner-text {
color: var(--color-gray-300) !important;
}
.dark .info-banner-text strong {
color: var(--color-gray-100) !important;
}
.dark .section-title {
color: var(--color-gray-100) !important;
border-bottom-color: rgba(var(--color-primary-500), 0.3) !important;
}
.dark .field-label {
color: var(--color-gray-300) !important;
}
.dark .field-label.field-modified {
color: rgb(var(--color-warning-400)) !important;
}
.dark .modified-indicator {
background: rgba(var(--color-warning-500), 0.2) !important;
color: rgb(var(--color-warning-400)) !important;
border-color: rgba(var(--color-warning-500), 0.4) !important;
}
.dark .field-help {
color: var(--color-gray-400) !important;
}
.dark .avatar-dropzone {
border-color: rgba(var(--color-primary-400), 0.3) !important;
background: rgba(var(--color-primary-500), 0.08) !important;
}
.dark .avatar-dropzone:hover {
border-color: rgba(var(--color-primary-400), 0.5) !important;
background: rgba(var(--color-primary-500), 0.12) !important;
}
.dark .avatar-dropzone.dropzone-active {
border-color: rgb(var(--color-primary-400)) !important;
background: rgba(var(--color-primary-500), 0.18) !important;
}
.dark .dropzone-icon {
color: rgb(var(--color-primary-400)) !important;
}
.dark .dropzone-text {
color: var(--color-gray-200) !important;
}
.dark .dropzone-hint {
color: var(--color-gray-400) !important;
}
.dark .avatar-url-section {
background: rgba(255, 255, 255, 0.03) !important;
border-color: rgba(255, 255, 255, 0.1) !important;
}
.dark .form-actions {
border-top-color: rgba(255, 255, 255, 0.1);
}
.dark .modal-title {
color: var(--color-gray-100) !important;
}
.dark .exit-modal-title {
color: var(--color-gray-100) !important;
}
.dark .exit-modal-message {
color: var(--color-gray-200) !important;
}
.dark .exit-field-item {
color: var(--color-gray-300) !important;
}
.dark .exit-modal-fields {
background: rgba(var(--color-warning-500), 0.15) !important;
border-color: rgba(var(--color-warning-500), 0.4) !important;
}
.dark .exit-modal-notice {
background: rgba(var(--color-error-500), 0.15) !important;
border-color: rgba(var(--color-error-500), 0.4) !important;
}
.dark .exit-modal-notice p {
color: var(--color-gray-300) !important;
}
.dark .exit-modal-help {
background: rgba(var(--color-primary-500), 0.15) !important;
border-color: rgba(var(--color-primary-500), 0.4) !important;
}
.dark .exit-modal-help p {
color: var(--color-gray-300) !important;
}
</style>