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 />
|
||||
<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 />
|
||||
<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>
|
||||
<!-- Acciones rápidas en footer transparente -->
|
||||
<div class="quick-actions">
|
||||
<AuthSessionStatusButton />
|
||||
<AuthProfileButton />
|
||||
<AuthLogoutButton />
|
||||
</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">
|
||||
Authentik Proxy Outpost debería redirigirte automáticamente.
|
||||
</p>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</UContainer>
|
||||
<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>
|
||||
|
||||
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,89 +1,87 @@
|
||||
<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" />
|
||||
Mis Aplicaciones
|
||||
</h3>
|
||||
<UBadge v-if="filteredApplications.length > 0" color="primary" variant="soft">
|
||||
{{ filteredApplications.length }}
|
||||
</UBadge>
|
||||
</div>
|
||||
|
||||
<!-- Filtros por grupos -->
|
||||
<div v-if="availableGroups.length > 0" class="flex flex-wrap gap-2">
|
||||
<UButton
|
||||
v-for="group in availableGroups"
|
||||
:key="group"
|
||||
size="sm"
|
||||
:color="selectedGroups.includes(group) ? 'primary' : 'neutral'"
|
||||
:variant="selectedGroups.includes(group) ? 'soft' : 'ghost'"
|
||||
@click="toggleGroup(group)"
|
||||
>
|
||||
{{ group }}
|
||||
</UButton>
|
||||
</div>
|
||||
<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
|
||||
</h2>
|
||||
<UBadge v-if="filteredApplications.length > 0" color="primary" variant="soft" size="lg">
|
||||
{{ filteredApplications.length }}
|
||||
</UBadge>
|
||||
</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" />
|
||||
<!-- Filtros por grupos -->
|
||||
<div v-if="availableGroups.length > 0" class="filter-section">
|
||||
<UButton
|
||||
v-for="group in availableGroups"
|
||||
:key="group"
|
||||
size="sm"
|
||||
:color="selectedGroups.includes(group) ? 'primary' : 'neutral'"
|
||||
:variant="selectedGroups.includes(group) ? 'soft' : 'ghost'"
|
||||
@click="toggleGroup(group)"
|
||||
>
|
||||
{{ group }}
|
||||
</UButton>
|
||||
</div>
|
||||
</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-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="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>
|
||||
|
||||
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