Implementar avatar estilo Windows Live Messenger con sistema de presencia
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 55s

- Agregar componente MsnAvatar con marco SVG de grosor variable
- Implementar degradados radiales estilo Frutiger Aero
- Agregar composable usePresence con detección de inactividad
- Incluir selector de estados: Online, Away, Busy, Offline
- Actualizar UserHeader para usar el nuevo avatar
This commit is contained in:
2025-10-17 13:56:59 -06:00
parent fa19845b8e
commit 6b87902119
3 changed files with 478 additions and 10 deletions

View File

@@ -0,0 +1,262 @@
<template>
<div class="msn-avatar-wrapper" :class="`status-${presenceStatus}`">
<svg
class="msn-frame"
viewBox="0 0 140 140"
xmlns="http://www.w3.org/2000/svg"
>
<!-- Definiciones -->
<defs>
<!-- Degradado radial para el marco (simula iluminación) -->
<radialGradient
id="frameGradient"
cx="25%"
cy="25%"
r="100%"
fx="15%"
fy="15%"
>
<stop offset="0%" :stop-color="colors.light" />
<stop offset="50%" :stop-color="colors.medium" />
<stop offset="100%" :stop-color="colors.dark" />
</radialGradient>
<!-- Degradado para el highlight glossy -->
<linearGradient
id="glossHighlight"
x1="0%"
y1="0%"
x2="0%"
y2="100%"
>
<stop offset="0%" stop-color="rgba(255,255,255,0.6)" />
<stop offset="40%" stop-color="rgba(255,255,255,0.2)" />
<stop offset="60%" stop-color="rgba(255,255,255,0)" />
<stop offset="100%" stop-color="rgba(0,0,0,0.1)" />
</linearGradient>
<!-- Filtro para sombra exterior -->
<filter id="dropShadow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur in="SourceAlpha" stdDeviation="3" />
<feOffset dx="0" dy="2" result="offsetblur" />
<feComponentTransfer>
<feFuncA type="linear" slope="0.3" />
</feComponentTransfer>
<feMerge>
<feMergeNode />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<!-- Filtro para sombra interior -->
<filter id="innerShadow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur in="SourceAlpha" stdDeviation="2" result="blur" />
<feOffset in="blur" dx="1" dy="1" result="offsetBlur" />
<feFlood flood-color="rgba(0,0,0,0.4)" result="color" />
<feComposite in="color" in2="offsetBlur" operator="in" result="shadow" />
<feComposite in="shadow" in2="SourceAlpha" operator="in" result="innerShadow" />
</filter>
<!-- Clip path para el avatar (cuadrado con esquinas muy poco redondeadas) -->
<clipPath id="avatarClip">
<rect x="20" y="20" width="100" height="100" rx="3" ry="3" />
</clipPath>
<!-- Máscara para crear el marco con grosor variable -->
<mask id="frameMask">
<!-- Rectángulo exterior blanco -->
<rect x="0" y="0" width="140" height="140" fill="white" rx="10" ry="10" />
<!-- Rectángulo interior negro (el hueco del marco) -->
<!-- Usamos un path personalizado para crear el grosor variable -->
<path
d="M 20,15
Q 70,18 120,15
Q 122,70 125,120
Q 70,122 20,125
Q 18,70 15,20
Q 70,18 20,15 Z"
fill="black"
/>
</mask>
</defs>
<!-- Marco exterior con grosor variable -->
<g filter="url(#dropShadow)">
<!-- Base del marco con degradado radial -->
<rect
x="0"
y="0"
width="140"
height="140"
rx="10"
ry="10"
fill="url(#frameGradient)"
mask="url(#frameMask)"
/>
<!-- Capa glossy superior -->
<rect
x="0"
y="0"
width="140"
height="140"
rx="10"
ry="10"
fill="url(#glossHighlight)"
mask="url(#frameMask)"
opacity="0.8"
/>
<!-- Highlight especular en la esquina superior izquierda -->
<ellipse
cx="25"
cy="25"
rx="15"
ry="12"
fill="rgba(255,255,255,0.7)"
mask="url(#frameMask)"
opacity="0.9"
/>
</g>
</svg>
<!-- Avatar del usuario -->
<div class="avatar-container">
<img
v-if="src"
:src="src"
:alt="alt"
class="avatar-image"
@error="handleImageError"
/>
<div v-else class="avatar-placeholder">
{{ initials }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { PresenceStatus } from '~/composables/usePresence'
interface Props {
src?: string
alt?: string
presenceStatus?: PresenceStatus
size?: number
}
const props = withDefaults(defineProps<Props>(), {
presenceStatus: 'online',
size: 128,
alt: 'Avatar'
})
const imageError = ref(false)
// Calcular iniciales del nombre
const initials = computed(() => {
if (!props.alt) return '?'
const words = props.alt.split(' ').filter(w => w.length > 0)
if (words.length >= 2) {
return (words[0]![0]! + words[1]![0]!).toUpperCase()
}
return props.alt.slice(0, 2).toUpperCase()
})
const handleImageError = () => {
imageError.value = true
}
// Computed para colores basados en el status prop
const colors = computed(() => {
const { PRESENCE_COLORS } = usePresence()
return PRESENCE_COLORS[props.presenceStatus]
})
</script>
<style scoped>
.msn-avatar-wrapper {
position: relative;
display: inline-block;
width: var(--avatar-size, 128px);
height: var(--avatar-size, 128px);
}
.msn-frame {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 2;
pointer-events: none;
}
.avatar-container {
position: absolute;
top: 14.3%; /* Proporción del marco */
left: 14.3%;
width: 71.4%; /* 100% - 2*14.3% */
height: 71.4%;
border-radius: 2px;
overflow: hidden;
background: var(--color-gray-200);
z-index: 1;
}
.avatar-image {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.avatar-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
font-weight: 700;
color: var(--color-gray-600);
background: linear-gradient(135deg, var(--color-gray-100), var(--color-gray-300));
}
/* Variaciones de tamaño */
.msn-avatar-wrapper.size-sm {
--avatar-size: 64px;
}
.msn-avatar-wrapper.size-md {
--avatar-size: 96px;
}
.msn-avatar-wrapper.size-lg {
--avatar-size: 128px;
}
.msn-avatar-wrapper.size-xl {
--avatar-size: 160px;
}
/* Animación de hover */
.msn-avatar-wrapper {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.msn-avatar-wrapper:hover {
transform: scale(1.05);
}
/* Modo oscuro */
.dark .avatar-container {
background: var(--color-gray-800);
}
.dark .avatar-placeholder {
background: linear-gradient(135deg, var(--color-gray-700), var(--color-gray-900));
color: var(--color-gray-300);
}
</style>

View File

@@ -2,14 +2,14 @@
<div class="user-header">
<!-- Header principal -->
<div class="header-content">
<!-- Avatar -->
<!-- Avatar estilo MSN Messenger -->
<div class="avatar-section">
<UAvatar
<MsnAvatar
v-if="user"
:src="user.avatar"
:alt="user.name || user.username"
size="xl"
class="avatar-glow"
:presence-status="presenceStatus"
:size="140"
/>
</div>
@@ -22,6 +22,20 @@
</button>
</div>
<p class="user-email">{{ user?.email }}</p>
<!-- Selector de estado de presencia -->
<div class="presence-selector">
<button
v-for="option in presenceOptions"
:key="option.value"
:class="['presence-option', { active: presenceStatus === option.value }]"
@click="setPresenceStatus(option.value as any)"
:title="option.label"
>
<UIcon :name="option.icon" class="w-3.5 h-3.5" />
<span class="presence-label">{{ option.label }}</span>
</button>
</div>
<div class="user-badges">
<span
v-for="group in user?.groups.slice(0, 3)"
@@ -53,9 +67,23 @@
<script setup lang="ts">
const { user } = useAuthentik()
const { isNight, toggleTheme } = useTheme()
const { status: presenceStatus, setStatus: setPresenceStatus, initActivityListeners } = usePresence()
// Emits
defineEmits(['edit-profile'])
// Inicializar listeners de actividad
onMounted(() => {
initActivityListeners()
})
// Estados disponibles para el selector
const presenceOptions = [
{ value: 'online', label: 'Disponible', icon: 'i-heroicons-check-circle' },
{ value: 'away', label: 'Ausente', icon: 'i-heroicons-clock' },
{ value: 'busy', label: 'Ocupado', icon: 'i-heroicons-minus-circle' },
{ value: 'offline', label: 'Desconectado', icon: 'i-heroicons-x-circle' }
]
</script>
<style scoped>
@@ -83,11 +111,6 @@ defineEmits(['edit-profile'])
flex-shrink: 0;
}
.avatar-glow {
box-shadow: 0 0 20px rgba(var(--color-primary-500), 0.4);
transition: box-shadow 0.3s ease;
}
.user-info {
flex: 1;
min-width: 0;
@@ -110,7 +133,48 @@ defineEmits(['edit-profile'])
.user-email {
font-size: 0.875rem;
color: var(--color-gray-600);
margin: 0.25rem 0 0.75rem 0;
margin: 0.25rem 0 0.5rem 0;
}
.presence-selector {
display: flex;
gap: 0.375rem;
margin-bottom: 0.75rem;
flex-wrap: wrap;
}
.presence-option {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.625rem;
font-size: 0.75rem;
font-weight: 500;
border-radius: 0.5rem;
background: rgba(255, 255, 255, 0.3);
backdrop-filter: blur(10px);
border: 1px solid rgba(0, 0, 0, 0.08);
color: var(--color-gray-600);
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.presence-option:hover {
background: rgba(255, 255, 255, 0.5);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.presence-option.active {
background: rgba(var(--color-primary-500), 0.2);
border-color: rgba(var(--color-primary-500), 0.4);
color: rgb(var(--color-primary-600));
font-weight: 600;
box-shadow: 0 2px 8px rgba(var(--color-primary-500), 0.2);
}
.presence-label {
white-space: nowrap;
}
.user-badges {
@@ -309,4 +373,22 @@ defineEmits(['edit-profile'])
0 2px 6px 0 rgba(0, 0, 0, 0.3),
inset 0 1px 1px 0 rgba(255, 255, 255, 0.05) !important;
}
.dark .presence-option {
background: rgba(255, 255, 255, 0.05) !important;
border-color: rgba(255, 255, 255, 0.1) !important;
color: var(--color-gray-400) !important;
}
.dark .presence-option:hover {
background: rgba(255, 255, 255, 0.1) !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3) !important;
}
.dark .presence-option.active {
background: rgba(var(--color-primary-500), 0.3) !important;
border-color: rgba(var(--color-primary-500), 0.5) !important;
color: rgb(var(--color-primary-400)) !important;
box-shadow: 0 2px 8px rgba(var(--color-primary-500), 0.4) !important;
}
</style>

View File

@@ -0,0 +1,124 @@
/**
* Composable para manejar el estado de presencia del usuario
* Inspirado en Windows Live Messenger
*/
export type PresenceStatus = 'online' | 'away' | 'busy' | 'offline'
interface PresenceConfig {
light: string // Color para el highlight (top-left)
medium: string // Color medio
dark: string // Color para la sombra (bottom-right)
}
export const PRESENCE_COLORS: Record<PresenceStatus, PresenceConfig> = {
online: {
light: '#CCFF99',
medium: '#66CC33',
dark: '#339900'
},
away: {
light: '#FFE699',
medium: '#FFCC33',
dark: '#FF9900'
},
busy: {
light: '#FF9999',
medium: '#FF3333',
dark: '#CC0000'
},
offline: {
light: '#CCCCCC',
medium: '#999999',
dark: '#666666'
}
}
export const usePresence = () => {
const status = useState<PresenceStatus>('userPresenceStatus', () => 'online')
const lastActivity = useState<number>('lastActivity', () => Date.now())
// Configuración de tiempos (en milisegundos)
const AWAY_TIMEOUT = 5 * 60 * 1000 // 5 minutos de inactividad = Away
let inactivityTimer: NodeJS.Timeout | null = null
// Actualizar última actividad
const updateActivity = () => {
lastActivity.value = Date.now()
// Si estaba away y el usuario vuelve a estar activo, ponerlo online
if (status.value === 'away') {
status.value = 'online'
}
resetInactivityTimer()
}
// Resetear el timer de inactividad
const resetInactivityTimer = () => {
if (inactivityTimer) {
clearTimeout(inactivityTimer)
}
// Solo en el cliente
if (import.meta.client) {
inactivityTimer = setTimeout(() => {
// Solo cambiar a away si está online
if (status.value === 'online') {
status.value = 'away'
}
}, AWAY_TIMEOUT)
}
}
// Cambiar estado manualmente
const setStatus = (newStatus: PresenceStatus) => {
status.value = newStatus
// Si se cambia manualmente, resetear el timer
if (newStatus === 'online') {
resetInactivityTimer()
} else if (inactivityTimer) {
// Si se pone busy o offline manualmente, cancelar el timer de away
clearTimeout(inactivityTimer)
inactivityTimer = null
}
}
// Obtener los colores del estado actual
const colors = computed(() => PRESENCE_COLORS[status.value])
// Inicializar listeners de actividad (solo en cliente)
const initActivityListeners = () => {
if (import.meta.client) {
const events = ['mousedown', 'mousemove', 'keypress', 'scroll', 'touchstart', 'click']
events.forEach(event => {
document.addEventListener(event, updateActivity, { passive: true })
})
// Iniciar el timer
resetInactivityTimer()
// Cleanup cuando se desmonte
onUnmounted(() => {
events.forEach(event => {
document.removeEventListener(event, updateActivity)
})
if (inactivityTimer) {
clearTimeout(inactivityTimer)
}
})
}
}
return {
status: readonly(status),
colors,
setStatus,
updateActivity,
initActivityListeners,
PRESENCE_COLORS
}
}