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
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:
@@ -3,114 +3,47 @@
|
|||||||
<NuxtRouteAnnouncer />
|
<NuxtRouteAnnouncer />
|
||||||
<UNotifications />
|
<UNotifications />
|
||||||
|
|
||||||
<UContainer class="py-8">
|
<!-- Fondo animado -->
|
||||||
<div class="space-y-6">
|
<AnimatedBackground />
|
||||||
<!-- 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>
|
|
||||||
|
|
||||||
<!-- Componentes de autenticación -->
|
<!-- Contenido principal -->
|
||||||
|
<div class="main-content">
|
||||||
|
<UContainer class="py-8">
|
||||||
<div v-if="isAuthenticated" class="space-y-6">
|
<div v-if="isAuthenticated" class="space-y-6">
|
||||||
<!-- Lista de aplicaciones (ancho completo) -->
|
<!-- Header principal con info del usuario -->
|
||||||
|
<UserHeader />
|
||||||
|
|
||||||
|
<!-- Lista de aplicaciones -->
|
||||||
<AuthApplicationsList />
|
<AuthApplicationsList />
|
||||||
|
|
||||||
<!-- Grid de 2 columnas para el resto -->
|
<!-- Acciones rápidas en footer transparente -->
|
||||||
<div class="grid gap-6 lg:grid-cols-2">
|
<div class="quick-actions">
|
||||||
<!-- 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 />
|
|
||||||
<AuthSessionStatusButton />
|
<AuthSessionStatusButton />
|
||||||
<AuthProfileButton />
|
<AuthProfileButton />
|
||||||
<AuthLogoutButton />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mensaje si no está autenticado -->
|
<!-- Mensaje si no está autenticado -->
|
||||||
<UCard v-else class="text-center">
|
<div v-else class="auth-message">
|
||||||
<div class="py-8">
|
<UCard class="text-center">
|
||||||
<UIcon name="i-heroicons-shield-exclamation" class="w-16 h-16 mx-auto mb-4 text-gray-400" />
|
<div class="py-12">
|
||||||
<h2 class="text-2xl font-semibold mb-2">No autenticado</h2>
|
<UIcon name="i-heroicons-shield-exclamation" class="w-20 h-20 mx-auto mb-6 text-gray-400" />
|
||||||
<p class="text-gray-600 dark: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.
|
Authentik Proxy Outpost debería redirigirte automáticamente.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</UCard>
|
</UCard>
|
||||||
</div>
|
</div>
|
||||||
</UContainer>
|
</UContainer>
|
||||||
|
</div>
|
||||||
</UApp>
|
</UApp>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const { isAuthenticated } = useAuthentik()
|
const { isAuthenticated } = useAuthentik()
|
||||||
|
const { isNight } = useTheme()
|
||||||
|
|
||||||
// Configurar meta tags para PWA
|
// Configurar meta tags para PWA
|
||||||
useHead({
|
useHead({
|
||||||
@@ -126,6 +59,55 @@ useHead({
|
|||||||
{ name: 'apple-mobile-web-app-status-bar-style', content: 'default' }
|
{ name: 'apple-mobile-web-app-status-bar-style', content: 'default' }
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
// Build con user.ts unificado (GET y PATCH)
|
|
||||||
</script>
|
</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>
|
||||||
|
|||||||
352
nuxt4/app/components/AnimatedBackground.vue
Normal file
352
nuxt4/app/components/AnimatedBackground.vue
Normal 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>
|
||||||
375
nuxt4/app/components/UserHeader.vue
Normal file
375
nuxt4/app/components/UserHeader.vue
Normal 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>
|
||||||
@@ -1,19 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<UCard>
|
<div class="applications-container">
|
||||||
<template #header>
|
<div class="applications-header">
|
||||||
<div class="space-y-3">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<div class="flex items-center justify-between">
|
<h2 class="applications-title">
|
||||||
<h3 class="font-semibold text-lg flex items-center gap-2">
|
<UIcon name="i-heroicons-squares-2x2" class="w-6 h-6" />
|
||||||
<UIcon name="i-heroicons-squares-2x2" />
|
|
||||||
Mis Aplicaciones
|
Mis Aplicaciones
|
||||||
</h3>
|
</h2>
|
||||||
<UBadge v-if="filteredApplications.length > 0" color="primary" variant="soft">
|
<UBadge v-if="filteredApplications.length > 0" color="primary" variant="soft" size="lg">
|
||||||
{{ filteredApplications.length }}
|
{{ filteredApplications.length }}
|
||||||
</UBadge>
|
</UBadge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filtros por grupos -->
|
<!-- 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
|
<UButton
|
||||||
v-for="group in availableGroups"
|
v-for="group in availableGroups"
|
||||||
:key="group"
|
:key="group"
|
||||||
@@ -26,64 +25,63 @@
|
|||||||
</UButton>
|
</UButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
|
||||||
|
|
||||||
<div v-if="pending" class="flex justify-center py-8">
|
<div v-if="pending" class="empty-state">
|
||||||
<UIcon name="i-heroicons-arrow-path" class="w-8 h-8 animate-spin text-primary" />
|
<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>
|
||||||
|
|
||||||
<div v-else-if="error" class="text-center py-8">
|
<div v-else-if="error" class="empty-state">
|
||||||
<UIcon name="i-heroicons-exclamation-triangle" class="w-12 h-12 mx-auto mb-4 text-error" />
|
<UIcon name="i-heroicons-exclamation-triangle" class="w-16 h-16 text-error" />
|
||||||
<p class="text-error font-semibold">Error al cargar aplicaciones</p>
|
<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>
|
<p class="text-sm text-gray-500 mt-2">{{ error.message }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="applications.length === 0" class="text-center py-8">
|
<div v-else-if="applications.length === 0" class="empty-state">
|
||||||
<UIcon name="i-heroicons-inbox" class="w-12 h-12 mx-auto mb-4 text-gray-400" />
|
<UIcon name="i-heroicons-inbox" class="w-16 h-16 text-gray-400" />
|
||||||
<p class="text-gray-600 dark:text-gray-400">No tienes aplicaciones disponibles</p>
|
<p class="mt-4 text-gray-600 dark:text-gray-400 text-lg">No tienes aplicaciones disponibles</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="filteredApplications.length === 0" class="text-center py-8">
|
<div v-else-if="filteredApplications.length === 0" class="empty-state">
|
||||||
<UIcon name="i-heroicons-funnel" class="w-12 h-12 mx-auto mb-4 text-gray-400" />
|
<UIcon name="i-heroicons-funnel" class="w-16 h-16 text-gray-400" />
|
||||||
<p class="text-gray-600 dark:text-gray-400">No hay aplicaciones en los grupos seleccionados</p>
|
<p class="mt-4 text-gray-600 dark:text-gray-400 text-lg">No hay aplicaciones en los grupos seleccionados</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div v-else class="applications-grid">
|
||||||
<a
|
<a
|
||||||
v-for="app in filteredApplications"
|
v-for="app in filteredApplications"
|
||||||
:key="app.pk"
|
:key="app.pk"
|
||||||
:href="app.launchUrl"
|
:href="app.launchUrl"
|
||||||
:target="app.openInNewTab ? '_blank' : '_self'"
|
: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="app-card-content">
|
||||||
<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-icon">
|
||||||
<UIcon
|
<UIcon
|
||||||
v-if="app.icon"
|
v-if="app.icon"
|
||||||
:name="app.icon"
|
:name="app.icon"
|
||||||
class="w-6 h-6 text-primary"
|
class="w-7 h-7"
|
||||||
/>
|
/>
|
||||||
<UIcon
|
<UIcon
|
||||||
v-else
|
v-else
|
||||||
name="i-heroicons-cube"
|
name="i-heroicons-cube"
|
||||||
class="w-6 h-6 text-primary"
|
class="w-7 h-7"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 min-w-0">
|
<div class="app-info">
|
||||||
<div class="flex items-start justify-between gap-2">
|
<div class="app-header">
|
||||||
<h4 class="font-semibold text-sm group-hover:text-primary transition-colors">
|
<h4 class="app-name">
|
||||||
{{ app.name }}
|
{{ app.name }}
|
||||||
</h4>
|
</h4>
|
||||||
<UIcon
|
<UIcon
|
||||||
v-if="app.openInNewTab"
|
v-if="app.openInNewTab"
|
||||||
name="i-heroicons-arrow-top-right-on-square"
|
name="i-heroicons-arrow-top-right-on-square"
|
||||||
class="w-4 h-4 text-gray-400 flex-shrink-0"
|
class="external-icon"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Grupos como subtítulo con chips compactos -->
|
<div v-if="app.group" class="app-groups">
|
||||||
<div v-if="app.group" class="mt-1 flex flex-wrap gap-1">
|
|
||||||
<UBadge
|
<UBadge
|
||||||
v-for="group in app.group.split(',')"
|
v-for="group in app.group.split(',')"
|
||||||
:key="group"
|
:key="group"
|
||||||
@@ -95,14 +93,14 @@
|
|||||||
</UBadge>
|
</UBadge>
|
||||||
</div>
|
</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 }}
|
{{ app.description }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</UCard>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -175,3 +173,211 @@ onUnmounted(() => {
|
|||||||
clearInterval(refreshInterval)
|
clearInterval(refreshInterval)
|
||||||
})
|
})
|
||||||
</script>
|
</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>
|
||||||
|
|||||||
48
nuxt4/app/composables/useTheme.ts
Normal file
48
nuxt4/app/composables/useTheme.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user