Agregar carga de archivos y drag & drop para foto de perfil
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 57s

- Agregar zona de drag & drop con feedback visual
- Agregar botón para subir foto desde dispositivo
- Implementar validación de tipo de archivo (solo imágenes)
- Implementar validación de tamaño (máximo 5MB)
- Agregar estilos responsive y soporte para modo oscuro
This commit is contained in:
2025-10-17 17:56:58 -06:00
parent a7d44f185d
commit 9afe54d188

View File

@@ -33,7 +33,32 @@
<UIcon name="i-heroicons-camera" class="w-5 h-5" />
Foto de Perfil
</h3>
<div class="avatar-actions-simple">
<!-- 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>
<!-- 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"
@@ -41,7 +66,18 @@
:disabled="isUploading"
>
<UIcon name="i-heroicons-camera" class="w-4 h-4" />
{{ currentAvatar && currentAvatar.includes('/avatars/') ? 'Cambiar foto' : 'Tomar foto' }}
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
@@ -392,6 +428,10 @@ const isSubmitting = ref(false)
const isUploading = ref(false)
const showCamera = ref(false)
const showExitConfirm = ref(false)
const isDragging = ref(false)
// Ref para el input de archivo
const fileInput = ref<HTMLInputElement | null>(null)
// Cookie para persistir cambios del formulario
const formCookie = useCookie<Record<string, string>>('profile-form-draft', {
@@ -694,6 +734,74 @@ const removeAvatar = async () => {
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)
}
</script>
<style scoped>
@@ -825,7 +933,58 @@ const removeAvatar = async () => {
font-style: italic;
}
.avatar-actions-simple {
/* 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-actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
@@ -1054,6 +1213,33 @@ const removeAvatar = async () => {
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 .form-actions {
border-top-color: rgba(255, 255, 255, 0.1);
}