Feature: Rediseño completo con tema día/noche y fondos animados
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 54s

- Implementar sistema de tema día/noche con persistencia en localStorage
- Crear componente AnimatedBackground con paisajes SVG animados
- Generar todos los assets SVG desde cero (sol, luna, estrellas, nubes, montañas)
- Añadir animaciones suaves para nubes, estrellas y elementos del paisaje
- Rediseñar UserHeader como componente principal clickeable
- Integrar modal de edición de perfil en el header
- Reorganizar layout principal mostrando solo aplicaciones
- Mejorar diseño de ApplicationsList con glassmorphism
- Implementar efectos hover y transiciones elegantes
- Diseño responsive mobile-first
- Diferencias visuales notorias entre modo día y noche
This commit is contained in:
2025-10-16 21:46:22 -06:00
parent 9ebc97c784
commit 01139f4415
5 changed files with 1112 additions and 149 deletions

View File

@@ -3,114 +3,47 @@
<NuxtRouteAnnouncer />
<UNotifications />
<UContainer class="py-8">
<div class="space-y-6">
<!-- Header -->
<div class="text-center mb-8">
<h1 class="text-4xl font-bold mb-2">Plantilla Nuxt + Authentik</h1>
<p class="text-gray-600 dark:text-gray-400">
Ejemplo de integración con Authentik Proxy Outpost revisando cambios
</p>
</div>
<!-- Fondo animado -->
<AnimatedBackground />
<!-- Componentes de autenticación -->
<!-- Contenido principal -->
<div class="main-content">
<UContainer class="py-8">
<div v-if="isAuthenticated" class="space-y-6">
<!-- Lista de aplicaciones (ancho completo) -->
<!-- Header principal con info del usuario -->
<UserHeader />
<!-- Lista de aplicaciones -->
<AuthApplicationsList />
<!-- Grid de 2 columnas para el resto -->
<div class="grid gap-6 lg:grid-cols-2">
<!-- Columna izquierda -->
<div class="space-y-6">
<!-- Avatar y datos básicos -->
<AuthUserAvatar />
<!-- Botones de acción individuales -->
<UCard class="w-full">
<template #header>
<h3 class="text-lg font-semibold">Acciones de Sesión</h3>
</template>
<div class="flex flex-wrap gap-3">
<AuthEditProfileButton />
<!-- Acciones rápidas en footer transparente -->
<div class="quick-actions">
<AuthSessionStatusButton />
<AuthProfileButton />
<AuthLogoutButton />
<AuthLoginButton />
</div>
</UCard>
<!-- Verificaciones Frontend/Backend -->
<UCard class="w-full">
<template #header>
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-cpu-chip" class="w-5 h-5" />
<h3 class="text-lg font-semibold">Verificación de Sistema</h3>
</div>
</template>
<div class="flex flex-wrap gap-3">
<AuthFrontendVerificationButton />
<AuthBackendVerificationButton />
</div>
</UCard>
</div>
<!-- Columna derecha -->
<div class="space-y-6">
<!-- Metadatos completos -->
<AuthUserMetadata />
<!-- Verificaciones de Grupos Frontend -->
<UCard class="w-full">
<template #header>
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-user-group" class="w-5 h-5 text-purple-500" />
<h3 class="text-lg font-semibold">Grupos (Frontend)</h3>
</div>
</template>
<div class="grid grid-cols-2 gap-3">
<AuthCheckAuthentikAdminsButton />
<AuthCheckGrupoPruebaButton />
<AuthCheckLvl0Button />
<AuthCheckPublicAccessButton />
</div>
</UCard>
<!-- Verificaciones de Grupos Backend -->
<UCard class="w-full">
<template #header>
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-server-stack" class="w-5 h-5 text-orange-500" />
<h3 class="text-lg font-semibold">Grupos (Backend)</h3>
</div>
</template>
<div class="grid grid-cols-2 gap-3">
<AuthCheckAuthentikAdminsButton :verify-backend="true" />
<AuthCheckGrupoPruebaButton :verify-backend="true" />
<AuthCheckLvl0Button :verify-backend="true" />
<AuthCheckPublicAccessButton :verify-backend="true" />
</div>
</UCard>
</div>
</div>
</div>
<!-- Mensaje si no está autenticado -->
<UCard v-else class="text-center">
<div class="py-8">
<UIcon name="i-heroicons-shield-exclamation" class="w-16 h-16 mx-auto mb-4 text-gray-400" />
<h2 class="text-2xl font-semibold mb-2">No autenticado</h2>
<p class="text-gray-600 dark:text-gray-400">
<div v-else class="auth-message">
<UCard class="text-center">
<div class="py-12">
<UIcon name="i-heroicons-shield-exclamation" class="w-20 h-20 mx-auto mb-6 text-gray-400" />
<h2 class="text-3xl font-bold mb-3">No autenticado</h2>
<p class="text-gray-600 dark:text-gray-400 text-lg">
Authentik Proxy Outpost debería redirigirte automáticamente.
</p>
</div>
</UCard>
</div>
</UContainer>
</div>
</UApp>
</template>
<script setup lang="ts">
const { isAuthenticated } = useAuthentik()
const { isNight } = useTheme()
// Configurar meta tags para PWA
useHead({
@@ -126,6 +59,55 @@ useHead({
{ name: 'apple-mobile-web-app-status-bar-style', content: 'default' }
]
})
// Build con user.ts unificado (GET y PATCH)
</script>
<style scoped>
.main-content {
position: relative;
z-index: 1;
min-height: 100vh;
padding-top: 2rem;
}
.quick-actions {
display: flex;
justify-content: center;
gap: 1rem;
flex-wrap: wrap;
padding: 1.5rem;
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(10px);
border-radius: 1rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
}
:global(.dark) .quick-actions {
background: rgba(30, 30, 40, 0.7);
}
.auth-message {
display: flex;
align-items: center;
justify-content: center;
min-height: 60vh;
}
/* Mejorar estilos de las cards en el tema oscuro */
:global(.dark) :global(.card) {
background: rgba(30, 30, 40, 0.8);
backdrop-filter: blur(10px);
}
/* Responsive */
@media (max-width: 768px) {
.main-content {
padding-top: 1rem;
}
.quick-actions {
flex-direction: column;
align-items: stretch;
}
}
</style>

View File

@@ -0,0 +1,352 @@
<template>
<div class="animated-background">
<!-- Fondo día -->
<div class="landscape day-landscape" :class="{ active: !isNight }">
<!-- Cielo diurno con gradiente -->
<svg class="sky" viewBox="0 0 1920 1080" preserveAspectRatio="xMidYMid slice">
<defs>
<linearGradient id="daySkyGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#87CEEB;stop-opacity:1" />
<stop offset="100%" style="stop-color:#E0F6FF;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="1920" height="1080" fill="url(#daySkyGradient)" />
</svg>
<!-- Sol -->
<svg class="sun" viewBox="0 0 200 200" width="120" height="120">
<defs>
<radialGradient id="sunGradient">
<stop offset="0%" style="stop-color:#FFF176;stop-opacity:1" />
<stop offset="100%" style="stop-color:#FFD54F;stop-opacity:0.8" />
</radialGradient>
</defs>
<circle cx="100" cy="100" r="50" fill="url(#sunGradient)" />
<g class="sun-rays">
<line x1="100" y1="20" x2="100" y2="0" stroke="#FFD54F" stroke-width="3" stroke-linecap="round" />
<line x1="150" y1="35" x2="165" y2="20" stroke="#FFD54F" stroke-width="3" stroke-linecap="round" />
<line x1="180" y1="100" x2="200" y2="100" stroke="#FFD54F" stroke-width="3" stroke-linecap="round" />
<line x1="150" y1="165" x2="165" y2="180" stroke="#FFD54F" stroke-width="3" stroke-linecap="round" />
<line x1="100" y1="180" x2="100" y2="200" stroke="#FFD54F" stroke-width="3" stroke-linecap="round" />
<line x1="50" y1="165" x2="35" y2="180" stroke="#FFD54F" stroke-width="3" stroke-linecap="round" />
<line x1="20" y1="100" x2="0" y2="100" stroke="#FFD54F" stroke-width="3" stroke-linecap="round" />
<line x1="50" y1="35" x2="35" y2="20" stroke="#FFD54F" stroke-width="3" stroke-linecap="round" />
</g>
</svg>
<!-- Nubes diurnas -->
<svg class="cloud cloud-1" viewBox="0 0 200 100" width="180" height="90">
<path d="M 30,60 Q 30,40 50,40 Q 50,20 70,20 Q 90,20 90,40 Q 110,40 110,60 Q 110,80 90,80 L 50,80 Q 30,80 30,60 Z"
fill="#FFFFFF" opacity="0.9" />
</svg>
<svg class="cloud cloud-2" viewBox="0 0 200 100" width="150" height="75">
<path d="M 30,60 Q 30,40 50,40 Q 50,20 70,20 Q 90,20 90,40 Q 110,40 110,60 Q 110,80 90,80 L 50,80 Q 30,80 30,60 Z"
fill="#FFFFFF" opacity="0.8" />
</svg>
<svg class="cloud cloud-3" viewBox="0 0 200 100" width="200" height="100">
<path d="M 30,60 Q 30,40 50,40 Q 50,20 70,20 Q 90,20 90,40 Q 110,40 110,60 Q 110,80 90,80 L 50,80 Q 30,80 30,60 Z"
fill="#FFFFFF" opacity="0.7" />
</svg>
<!-- Montañas lejanas (más claras) -->
<svg class="mountains mountains-far" viewBox="0 0 1920 600" preserveAspectRatio="xMidYMid slice">
<path d="M 0,400 L 400,150 L 600,250 L 900,100 L 1200,200 L 1500,120 L 1920,300 L 1920,600 L 0,600 Z"
fill="#A7C7E7" opacity="0.6" />
</svg>
<!-- Montañas cercanas -->
<svg class="mountains mountains-near" viewBox="0 0 1920 700" preserveAspectRatio="xMidYMid slice">
<path d="M 0,500 L 300,250 L 500,350 L 800,200 L 1100,320 L 1400,180 L 1700,350 L 1920,250 L 1920,700 L 0,700 Z"
fill="#4A7C94" opacity="0.8" />
</svg>
<!-- Colinas con césped -->
<svg class="hills" viewBox="0 0 1920 400" preserveAspectRatio="xMidYMid slice">
<path d="M 0,250 Q 240,150 480,200 T 960,220 T 1440,180 T 1920,240 L 1920,400 L 0,400 Z"
fill="#7CB342" />
</svg>
</div>
<!-- Fondo noche -->
<div class="landscape night-landscape" :class="{ active: isNight }">
<!-- Cielo nocturno con gradiente -->
<svg class="sky" viewBox="0 0 1920 1080" preserveAspectRatio="xMidYMid slice">
<defs>
<linearGradient id="nightSkyGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#0B1026;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1A2150;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="1920" height="1080" fill="url(#nightSkyGradient)" />
</svg>
<!-- Estrellas -->
<svg class="stars" viewBox="0 0 1920 1080" preserveAspectRatio="xMidYMid slice">
<circle cx="200" cy="100" r="2" fill="#FFFFFF" class="star star-1" />
<circle cx="400" cy="150" r="1.5" fill="#FFFFFF" class="star star-2" />
<circle cx="600" cy="80" r="2" fill="#FFFFFF" class="star star-3" />
<circle cx="800" cy="200" r="1" fill="#FFFFFF" class="star star-4" />
<circle cx="1000" cy="120" r="2" fill="#FFFFFF" class="star star-1" />
<circle cx="1200" cy="180" r="1.5" fill="#FFFFFF" class="star star-2" />
<circle cx="1400" cy="90" r="1" fill="#FFFFFF" class="star star-3" />
<circle cx="1600" cy="160" r="2" fill="#FFFFFF" class="star star-4" />
<circle cx="1800" cy="110" r="1.5" fill="#FFFFFF" class="star star-1" />
<circle cx="300" cy="300" r="1" fill="#FFFFFF" class="star star-2" />
<circle cx="500" cy="350" r="2" fill="#FFFFFF" class="star star-3" />
<circle cx="700" cy="280" r="1.5" fill="#FFFFFF" class="star star-4" />
<circle cx="900" cy="320" r="1" fill="#FFFFFF" class="star star-1" />
<circle cx="1100" cy="290" r="2" fill="#FFFFFF" class="star star-2" />
<circle cx="1300" cy="340" r="1" fill="#FFFFFF" class="star star-3" />
<circle cx="1500" cy="310" r="1.5" fill="#FFFFFF" class="star star-4" />
<circle cx="1700" cy="330" r="2" fill="#FFFFFF" class="star star-1" />
<circle cx="150" cy="450" r="1.5" fill="#FFFFFF" class="star star-3" />
<circle cx="350" cy="480" r="1" fill="#FFFFFF" class="star star-4" />
<circle cx="550" cy="420" r="2" fill="#FFFFFF" class="star star-1" />
<circle cx="750" cy="460" r="1" fill="#FFFFFF" class="star star-2" />
<circle cx="950" cy="440" r="1.5" fill="#FFFFFF" class="star star-3" />
<circle cx="1150" cy="470" r="2" fill="#FFFFFF" class="star star-4" />
<circle cx="1350" cy="430" r="1" fill="#FFFFFF" class="star star-1" />
<circle cx="1550" cy="450" r="1.5" fill="#FFFFFF" class="star star-2" />
</svg>
<!-- Luna -->
<svg class="moon" viewBox="0 0 200 200" width="100" height="100">
<defs>
<radialGradient id="moonGradient">
<stop offset="0%" style="stop-color:#F5F5DC;stop-opacity:1" />
<stop offset="100%" style="stop-color:#E6E6D0;stop-opacity:0.9" />
</radialGradient>
</defs>
<circle cx="100" cy="100" r="45" fill="url(#moonGradient)" />
<!-- Cráteres -->
<circle cx="85" cy="90" r="8" fill="#D0D0C0" opacity="0.3" />
<circle cx="110" cy="105" r="6" fill="#D0D0C0" opacity="0.3" />
<circle cx="95" cy="115" r="5" fill="#D0D0C0" opacity="0.3" />
<circle cx="115" cy="85" r="4" fill="#D0D0C0" opacity="0.3" />
</svg>
<!-- Montañas lejanas nocturnas -->
<svg class="mountains mountains-far" viewBox="0 0 1920 600" preserveAspectRatio="xMidYMid slice">
<path d="M 0,400 L 400,150 L 600,250 L 900,100 L 1200,200 L 1500,120 L 1920,300 L 1920,600 L 0,600 Z"
fill="#1A2340" opacity="0.7" />
</svg>
<!-- Montañas cercanas nocturnas -->
<svg class="mountains mountains-near" viewBox="0 0 1920 700" preserveAspectRatio="xMidYMid slice">
<path d="M 0,500 L 300,250 L 500,350 L 800,200 L 1100,320 L 1400,180 L 1700,350 L 1920,250 L 1920,700 L 0,700 Z"
fill="#0D1428" opacity="0.9" />
</svg>
<!-- Colinas nocturnas -->
<svg class="hills" viewBox="0 0 1920 400" preserveAspectRatio="xMidYMid slice">
<path d="M 0,250 Q 240,150 480,200 T 960,220 T 1440,180 T 1920,240 L 1920,400 L 0,400 Z"
fill="#1B3A28" />
</svg>
</div>
</div>
</template>
<script setup lang="ts">
const { isNight } = useTheme()
</script>
<style scoped>
.animated-background {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: -1;
overflow: hidden;
}
.landscape {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
transition: opacity 1.5s ease-in-out;
pointer-events: none;
}
.landscape.active {
opacity: 1;
}
.sky {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
/* Sol */
.sun {
position: absolute;
top: 10%;
right: 15%;
animation: sunFloat 20s ease-in-out infinite;
}
.sun-rays {
transform-origin: center;
animation: sunRotate 30s linear infinite;
}
@keyframes sunFloat {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-20px); }
}
@keyframes sunRotate {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Luna */
.moon {
position: absolute;
top: 8%;
right: 12%;
animation: moonFloat 25s ease-in-out infinite;
}
@keyframes moonFloat {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-15px); }
}
/* Estrellas */
.stars {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 60%;
}
.star {
animation-duration: 3s;
animation-iteration-count: infinite;
animation-timing-function: ease-in-out;
}
.star-1 { animation-name: twinkle1; }
.star-2 { animation-name: twinkle2; animation-delay: 0.5s; }
.star-3 { animation-name: twinkle3; animation-delay: 1s; }
.star-4 { animation-name: twinkle4; animation-delay: 1.5s; }
@keyframes twinkle1 {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
@keyframes twinkle2 {
0%, 100% { opacity: 0.8; }
50% { opacity: 0.2; }
}
@keyframes twinkle3 {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
@keyframes twinkle4 {
0%, 100% { opacity: 0.6; }
50% { opacity: 0.1; }
}
/* Nubes */
.cloud {
position: absolute;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.cloud-1 {
top: 15%;
left: -10%;
animation: cloudFloat1 60s linear infinite;
}
.cloud-2 {
top: 25%;
left: -10%;
animation: cloudFloat2 80s linear infinite;
}
.cloud-3 {
top: 35%;
left: -10%;
animation: cloudFloat3 100s linear infinite;
}
@keyframes cloudFloat1 {
from { transform: translateX(0); }
to { transform: translateX(calc(100vw + 200px)); }
}
@keyframes cloudFloat2 {
from { transform: translateX(0); }
to { transform: translateX(calc(100vw + 200px)); }
}
@keyframes cloudFloat3 {
from { transform: translateX(0); }
to { transform: translateX(calc(100vw + 200px)); }
}
/* Montañas y colinas */
.mountains, .hills {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
pointer-events: none;
}
.mountains-far {
bottom: 15%;
animation: mountainFloat 40s ease-in-out infinite;
}
.mountains-near {
bottom: 8%;
animation: mountainFloat 35s ease-in-out infinite reverse;
}
.hills {
bottom: 0;
animation: hillFloat 30s ease-in-out infinite;
}
@keyframes mountainFloat {
0%, 100% { transform: translateX(0); }
50% { transform: translateX(-20px); }
}
@keyframes hillFloat {
0%, 100% { transform: translateX(0); }
50% { transform: translateX(15px); }
}
/* Responsive */
@media (max-width: 768px) {
.sun, .moon {
width: 80px;
height: 80px;
top: 8%;
right: 10%;
}
.cloud {
transform: scale(0.7);
}
}
</style>

View File

@@ -0,0 +1,375 @@
<template>
<div class="user-header">
<!-- Header principal clickable -->
<div class="header-content" @click="openEditProfile">
<!-- Avatar -->
<div class="avatar-section">
<UAvatar
v-if="user"
:src="user.avatar"
:alt="user.name || user.username"
size="xl"
class="avatar-glow"
/>
</div>
<!-- Info del usuario -->
<div class="user-info">
<h1 class="user-name">{{ user?.name || user?.username }}</h1>
<p class="user-email">{{ user?.email }}</p>
<div class="user-badges">
<UBadge
v-for="group in user?.groups.slice(0, 3)"
:key="group"
size="sm"
color="primary"
variant="soft"
>
{{ group }}
</UBadge>
<UBadge
v-if="user && user.groups.length > 3"
size="sm"
color="neutral"
variant="soft"
>
+{{ user.groups.length - 3 }}
</UBadge>
</div>
</div>
<!-- Ícono de edición -->
<div class="edit-hint">
<UIcon name="i-heroicons-pencil-square" class="w-5 h-5" />
<span class="edit-text">Editar perfil</span>
</div>
</div>
<!-- Botón de tema -->
<button class="theme-toggle" @click.stop="toggleTheme" :title="isNight ? 'Cambiar a modo día' : 'Cambiar a modo noche'">
<transition name="theme-icon" mode="out-in">
<UIcon v-if="isNight" key="moon" name="i-heroicons-moon" class="w-6 h-6" />
<UIcon v-else key="sun" name="i-heroicons-sun" class="w-6 h-6" />
</transition>
</button>
<!-- Modal de edición de perfil -->
<UModal v-model="isEditModalOpen">
<div class="modal-content">
<h3 class="text-xl font-semibold mb-4">Editar Perfil</h3>
<form @submit.prevent="handleSubmit" class="space-y-4">
<UFormGroup label="Nombre de usuario" help="No se puede modificar">
<UInput
:model-value="user?.username"
disabled
icon="i-heroicons-user"
/>
</UFormGroup>
<UFormGroup label="Nombre completo" required>
<UInput
v-model="formData.name"
placeholder="Tu nombre completo"
icon="i-heroicons-user-circle"
:disabled="isSubmitting"
/>
</UFormGroup>
<UFormGroup label="Email" required>
<UInput
v-model="formData.email"
type="email"
placeholder="tu@email.com"
icon="i-heroicons-envelope"
:disabled="isSubmitting"
/>
</UFormGroup>
<div class="flex justify-end gap-3 mt-6">
<UButton
color="neutral"
variant="ghost"
@click="isEditModalOpen = false"
:disabled="isSubmitting"
>
Cancelar
</UButton>
<UButton
type="submit"
color="primary"
:loading="isSubmitting"
>
Guardar cambios
</UButton>
</div>
</form>
</div>
</UModal>
</div>
</template>
<script setup lang="ts">
const { user } = useAuthentik()
const { isNight, toggleTheme } = useTheme()
const toast = useToast()
// Estado del modal
const isEditModalOpen = ref(false)
const isSubmitting = ref(false)
// Datos del formulario
const formData = ref({
name: user.value?.name || '',
email: user.value?.email || ''
})
// Abrir modal de edición
const openEditProfile = () => {
if (user.value) {
formData.value = {
name: user.value.name || '',
email: user.value.email || ''
}
isEditModalOpen.value = true
}
}
// Enviar formulario
const handleSubmit = async () => {
if (!formData.value.name || !formData.value.email) {
toast.add({
title: 'Error',
description: 'Por favor completa todos los campos',
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
}
})
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()
}]
})
isEditModalOpen.value = false
} 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
}
}
</script>
<style scoped>
.user-header {
position: relative;
margin-bottom: 2rem;
}
.header-content {
display: flex;
align-items: center;
gap: 1.5rem;
padding: 1.5rem 2rem;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(10px);
border-radius: 1rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
cursor: pointer;
transition: all 0.3s ease;
border: 2px solid transparent;
}
.header-content:hover {
transform: translateY(-2px);
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
border-color: rgba(var(--color-primary-500), 0.3);
}
:global(.dark) .header-content {
background: rgba(30, 30, 40, 0.9);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -1px rgba(0, 0, 0, 0.2);
}
:global(.dark) .header-content:hover {
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.4), 0 4px 6px -2px rgba(0, 0, 0, 0.3);
}
.avatar-section {
flex-shrink: 0;
}
.avatar-glow {
box-shadow: 0 0 20px rgba(var(--color-primary-500), 0.4);
transition: box-shadow 0.3s ease;
}
.header-content:hover .avatar-glow {
box-shadow: 0 0 30px rgba(var(--color-primary-500), 0.6);
}
.user-info {
flex: 1;
min-width: 0;
}
.user-name {
font-size: 1.5rem;
font-weight: 700;
margin: 0;
color: var(--color-gray-900);
transition: color 0.3s ease;
}
:global(.dark) .user-name {
color: var(--color-gray-100);
}
.user-email {
font-size: 0.875rem;
color: var(--color-gray-600);
margin: 0.25rem 0 0.75rem 0;
}
:global(.dark) .user-email {
color: var(--color-gray-400);
}
.user-badges {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.modal-content {
padding: 1.5rem;
max-width: 28rem;
margin: 0 auto;
}
.edit-hint {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
background: rgba(var(--color-primary-500), 0.1);
color: rgb(var(--color-primary-500));
opacity: 0;
transform: translateX(-10px);
transition: all 0.3s ease;
}
.header-content:hover .edit-hint {
opacity: 1;
transform: translateX(0);
}
.edit-text {
font-size: 0.875rem;
font-weight: 500;
white-space: nowrap;
}
.theme-toggle {
position: absolute;
top: 1rem;
right: 1rem;
width: 3rem;
height: 3rem;
border-radius: 50%;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(10px);
border: 2px solid rgba(var(--color-primary-500), 0.2);
color: rgb(var(--color-primary-500));
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
z-index: 10;
}
:global(.dark) .theme-toggle {
background: rgba(30, 30, 40, 0.9);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.theme-toggle:hover {
transform: scale(1.1) rotate(20deg);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
.theme-icon-enter-active,
.theme-icon-leave-active {
transition: all 0.3s ease;
}
.theme-icon-enter-from {
opacity: 0;
transform: rotate(-180deg) scale(0);
}
.theme-icon-leave-to {
opacity: 0;
transform: rotate(180deg) scale(0);
}
/* Responsive */
@media (max-width: 768px) {
.header-content {
flex-direction: column;
text-align: center;
padding: 1.5rem 1rem;
}
.edit-hint {
opacity: 1;
transform: translateX(0);
}
.edit-text {
display: none;
}
.theme-toggle {
top: 0.5rem;
right: 0.5rem;
width: 2.5rem;
height: 2.5rem;
}
.user-name {
font-size: 1.25rem;
}
.user-badges {
justify-content: center;
}
}
</style>

View File

@@ -1,19 +1,18 @@
<template>
<UCard>
<template #header>
<div class="space-y-3">
<div class="flex items-center justify-between">
<h3 class="font-semibold text-lg flex items-center gap-2">
<UIcon name="i-heroicons-squares-2x2" />
<div class="applications-container">
<div class="applications-header">
<div class="flex items-center justify-between mb-4">
<h2 class="applications-title">
<UIcon name="i-heroicons-squares-2x2" class="w-6 h-6" />
Mis Aplicaciones
</h3>
<UBadge v-if="filteredApplications.length > 0" color="primary" variant="soft">
</h2>
<UBadge v-if="filteredApplications.length > 0" color="primary" variant="soft" size="lg">
{{ filteredApplications.length }}
</UBadge>
</div>
<!-- Filtros por grupos -->
<div v-if="availableGroups.length > 0" class="flex flex-wrap gap-2">
<div v-if="availableGroups.length > 0" class="filter-section">
<UButton
v-for="group in availableGroups"
:key="group"
@@ -26,64 +25,63 @@
</UButton>
</div>
</div>
</template>
<div v-if="pending" class="flex justify-center py-8">
<UIcon name="i-heroicons-arrow-path" class="w-8 h-8 animate-spin text-primary" />
<div v-if="pending" class="empty-state">
<UIcon name="i-heroicons-arrow-path" class="w-12 h-12 animate-spin text-primary" />
<p class="mt-4 text-gray-600 dark:text-gray-400">Cargando aplicaciones...</p>
</div>
<div v-else-if="error" class="text-center py-8">
<UIcon name="i-heroicons-exclamation-triangle" class="w-12 h-12 mx-auto mb-4 text-error" />
<p class="text-error font-semibold">Error al cargar aplicaciones</p>
<div v-else-if="error" class="empty-state">
<UIcon name="i-heroicons-exclamation-triangle" class="w-16 h-16 text-error" />
<p class="mt-4 text-error font-semibold text-lg">Error al cargar aplicaciones</p>
<p class="text-sm text-gray-500 mt-2">{{ error.message }}</p>
</div>
<div v-else-if="applications.length === 0" class="text-center py-8">
<UIcon name="i-heroicons-inbox" class="w-12 h-12 mx-auto mb-4 text-gray-400" />
<p class="text-gray-600 dark:text-gray-400">No tienes aplicaciones disponibles</p>
<div v-else-if="applications.length === 0" class="empty-state">
<UIcon name="i-heroicons-inbox" class="w-16 h-16 text-gray-400" />
<p class="mt-4 text-gray-600 dark:text-gray-400 text-lg">No tienes aplicaciones disponibles</p>
</div>
<div v-else-if="filteredApplications.length === 0" class="text-center py-8">
<UIcon name="i-heroicons-funnel" class="w-12 h-12 mx-auto mb-4 text-gray-400" />
<p class="text-gray-600 dark:text-gray-400">No hay aplicaciones en los grupos seleccionados</p>
<div v-else-if="filteredApplications.length === 0" class="empty-state">
<UIcon name="i-heroicons-funnel" class="w-16 h-16 text-gray-400" />
<p class="mt-4 text-gray-600 dark:text-gray-400 text-lg">No hay aplicaciones en los grupos seleccionados</p>
</div>
<div v-else class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<div v-else class="applications-grid">
<a
v-for="app in filteredApplications"
:key="app.pk"
:href="app.launchUrl"
:target="app.openInNewTab ? '_blank' : '_self'"
class="group block p-4 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-primary hover:bg-primary/5 transition-all duration-200"
class="app-card"
>
<div class="flex items-start gap-3">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center group-hover:bg-primary/20 transition-colors">
<div class="app-card-content">
<div class="app-icon">
<UIcon
v-if="app.icon"
:name="app.icon"
class="w-6 h-6 text-primary"
class="w-7 h-7"
/>
<UIcon
v-else
name="i-heroicons-cube"
class="w-6 h-6 text-primary"
class="w-7 h-7"
/>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-start justify-between gap-2">
<h4 class="font-semibold text-sm group-hover:text-primary transition-colors">
<div class="app-info">
<div class="app-header">
<h4 class="app-name">
{{ app.name }}
</h4>
<UIcon
v-if="app.openInNewTab"
name="i-heroicons-arrow-top-right-on-square"
class="w-4 h-4 text-gray-400 flex-shrink-0"
class="external-icon"
/>
</div>
<!-- Grupos como subtítulo con chips compactos -->
<div v-if="app.group" class="mt-1 flex flex-wrap gap-1">
<div v-if="app.group" class="app-groups">
<UBadge
v-for="group in app.group.split(',')"
:key="group"
@@ -95,14 +93,14 @@
</UBadge>
</div>
<p v-if="app.description" class="text-xs text-gray-500 dark:text-gray-400 mt-2 line-clamp-2">
<p v-if="app.description" class="app-description">
{{ app.description }}
</p>
</div>
</div>
</a>
</div>
</UCard>
</div>
</template>
<script setup lang="ts">
@@ -175,3 +173,211 @@ onUnmounted(() => {
clearInterval(refreshInterval)
})
</script>
<style scoped>
.applications-container {
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(15px);
border-radius: 1.5rem;
padding: 2rem;
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
}
:global(.dark) .applications-container {
background: rgba(30, 30, 40, 0.85);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3);
}
.applications-header {
margin-bottom: 2rem;
}
.applications-title {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 1.75rem;
font-weight: 700;
color: var(--color-gray-900);
margin: 0;
}
:global(.dark) .applications-title {
color: var(--color-gray-100);
}
.filter-section {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem 2rem;
text-align: center;
}
.applications-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
}
@media (min-width: 640px) {
.applications-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 1024px) {
.applications-grid {
grid-template-columns: repeat(3, 1fr);
}
}
.app-card {
display: block;
padding: 1.25rem;
border-radius: 1rem;
background: rgba(255, 255, 255, 0.9);
border: 2px solid rgba(var(--color-gray-200), 0.5);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
transition: all 0.3s ease;
cursor: pointer;
text-decoration: none;
}
:global(.dark) .app-card {
background: rgba(40, 40, 50, 0.9);
border-color: rgba(var(--color-gray-700), 0.5);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.app-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(var(--color-primary-500), 0.2);
border-color: rgb(var(--color-primary-500));
}
:global(.dark) .app-card:hover {
box-shadow: 0 8px 16px rgba(var(--color-primary-500), 0.3);
}
.app-card-content {
display: flex;
gap: 1rem;
align-items: start;
}
.app-icon {
flex-shrink: 0;
width: 3rem;
height: 3rem;
border-radius: 0.75rem;
background: rgba(var(--color-primary-500), 0.1);
display: flex;
align-items: center;
justify-content: center;
color: rgb(var(--color-primary-500));
transition: all 0.3s ease;
}
.app-card:hover .app-icon {
background: rgba(var(--color-primary-500), 0.2);
transform: scale(1.1);
}
.app-info {
flex: 1;
min-width: 0;
}
.app-header {
display: flex;
align-items: start;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.app-name {
font-size: 1rem;
font-weight: 600;
color: var(--color-gray-900);
margin: 0;
transition: color 0.3s ease;
line-height: 1.3;
}
:global(.dark) .app-name {
color: var(--color-gray-100);
}
.app-card:hover .app-name {
color: rgb(var(--color-primary-500));
}
.external-icon {
width: 1rem;
height: 1rem;
color: var(--color-gray-400);
flex-shrink: 0;
margin-top: 0.125rem;
}
.app-groups {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
margin-bottom: 0.5rem;
}
.app-description {
font-size: 0.8125rem;
color: var(--color-gray-600);
line-height: 1.4;
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
:global(.dark) .app-description {
color: var(--color-gray-400);
}
/* Responsive */
@media (max-width: 640px) {
.applications-container {
padding: 1.5rem;
}
.applications-title {
font-size: 1.5rem;
}
.applications-grid {
gap: 0.75rem;
grid-template-columns: 1fr;
}
.app-card {
padding: 1rem;
}
.app-card-content {
gap: 0.75rem;
}
.app-icon {
width: 2.5rem;
height: 2.5rem;
}
}
</style>

View File

@@ -0,0 +1,48 @@
/**
* Composable para manejar el tema día/noche
* Persiste la preferencia del usuario en localStorage
*/
export type Theme = 'day' | 'night'
export const useTheme = () => {
// Estado del tema con persistencia
const currentTheme = useState<Theme>('theme', () => {
if (import.meta.client && typeof window !== 'undefined') {
const saved = localStorage.getItem('theme')
if (saved === 'day' || saved === 'night') {
return saved
}
// Detectar preferencia del sistema
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
return prefersDark ? 'night' : 'day'
}
return 'day'
})
// Computed para saber si es modo noche
const isNight = computed(() => currentTheme.value === 'night')
// Alternar tema
const toggleTheme = () => {
currentTheme.value = currentTheme.value === 'day' ? 'night' : 'day'
if (import.meta.client && typeof window !== 'undefined') {
localStorage.setItem('theme', currentTheme.value)
}
}
// Establecer tema específico
const setTheme = (theme: Theme) => {
currentTheme.value = theme
if (import.meta.client && typeof window !== 'undefined') {
localStorage.setItem('theme', theme)
}
}
return {
currentTheme: readonly(currentTheme),
isNight,
toggleTheme,
setTheme
}
}