Implementar avatar estilo Windows Live Messenger con sistema de presencia
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 55s
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:
262
nuxt4/app/components/MsnAvatar.vue
Normal file
262
nuxt4/app/components/MsnAvatar.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
124
nuxt4/app/composables/usePresence.ts
Normal file
124
nuxt4/app/composables/usePresence.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user