Some checks failed
build-and-deploy / build-and-deploy (push) Has been cancelled
- Agregar share_target al manifest de la PWA - Crear endpoint /api/share-target para recibir archivos compartidos - Guardar archivos temporalmente en /public/temp-shared - Modificar UserProfileForm para aceptar imágenes externas - Detectar automáticamente imágenes compartidas y procesarlas - Crear endpoint /api/share-target/cleanup para limpiar temporales - Mostrar toast informativo al recibir imagen compartida - Redirigir automáticamente al formulario de perfil - Soportar compartir desde galería, otras apps, etc.
1347 lines
34 KiB
Vue
1347 lines
34 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>
|
|
|
|
<!-- 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)
|
|
|
|
// 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', {
|
|
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)
|
|
}
|
|
|
|
// 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-actions {
|
|
display: flex;
|
|
gap: 0.75rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.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 .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>
|