Agregar opción para cargar avatar desde URL en edición de perfil
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 55s

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.
This commit is contained in:
2025-10-18 00:20:39 -06:00
parent 470823e1a5
commit 00596bd6df

View File

@@ -49,6 +49,33 @@
<span class="dropzone-hint">o usa los botones de abajo</span> <span class="dropzone-hint">o usa los botones de abajo</span>
</div> </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 --> <!-- Botones de acción -->
<div class="avatar-actions"> <div class="avatar-actions">
<input <input
@@ -434,10 +461,15 @@ const isUploading = ref(false)
const showCamera = ref(false) const showCamera = ref(false)
const showExitConfirm = ref(false) const showExitConfirm = ref(false)
const isDragging = ref(false) const isDragging = ref(false)
const isLoadingUrl = ref(false)
// Ref para el input de archivo // Ref para el input de archivo
const fileInput = ref<HTMLInputElement | null>(null) const fileInput = ref<HTMLInputElement | null>(null)
// URL del avatar
const avatarUrl = ref('')
const urlError = ref('')
// Cookie para persistir cambios del formulario // Cookie para persistir cambios del formulario
const formCookie = useCookie<Record<string, string>>('profile-form-draft', { const formCookie = useCookie<Record<string, string>>('profile-form-draft', {
maxAge: 60 * 60 * 24 * 7, // 7 días maxAge: 60 * 60 * 24 * 7, // 7 días
@@ -815,6 +847,91 @@ const processImageFile = async (file: File) => {
await handleAvatarCapture(blob) 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 // Procesar imagen compartida desde Web Share Target
const processSharedImage = async (imageUrl: string) => { const processSharedImage = async (imageUrl: string) => {
try { try {
@@ -1045,10 +1162,19 @@ const processSharedImage = async (imageUrl: string) => {
font-style: italic; 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 { .avatar-actions {
display: flex; display: flex;
gap: 0.75rem; gap: 0.75rem;
flex-wrap: wrap; flex-wrap: wrap;
margin-top: 1rem;
} }
.camera-modal { .camera-modal {
@@ -1301,6 +1427,11 @@ const processSharedImage = async (imageUrl: string) => {
color: var(--color-gray-400) !important; 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 { .dark .form-actions {
border-top-color: rgba(255, 255, 255, 0.1); border-top-color: rgba(255, 255, 255, 0.1);
} }