Jose Salazar commited on
Commit
de370c8
Β·
unverified Β·
2 Parent(s): 71925dec677cde

Merge pull request #33 from josesalazar2025/feature/header-user-logout

Browse files
frontend/index.html CHANGED
@@ -7,6 +7,12 @@
7
  <link rel="icon" type="image/svg+xml" href="/vite.svg" />
8
  </head>
9
  <body>
 
 
 
 
 
 
10
  <!-- Auth Page (shown when unauthenticated) -->
11
  <div id="view-auth" class="auth-view hidden">
12
  <div class="auth-card">
@@ -176,7 +182,27 @@
176
  <button class="icon-btn mobile-only" id="btn-watchlist-mobile" title="Seguimiento">β˜†</button>
177
  <button class="icon-btn mobile-only" id="btn-alerts-mobile" title="Alertas">⚑</button>
178
  <button class="icon-btn mobile-only" id="btn-prefs" title="Modelo IA">βš™</button>
 
179
  <button class="btn-ghost desktop-only" id="btn-auth">Entrar</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
  <button class="icon-btn auth-indicator mobile-only" id="btn-auth-mobile" title="Entrar"></button>
181
  </div>
182
  </header>
@@ -482,52 +508,6 @@
482
  </div>
483
  </div>
484
 
485
- <!-- Auth Modal -->
486
- <div class="modal-overlay hidden" id="auth-modal">
487
- <div class="modal">
488
- <div class="modal-header">
489
- <div class="modal-tabs">
490
- <button class="modal-tab active" data-tab="login">Iniciar sesiΓ³n</button>
491
- <button class="modal-tab" data-tab="register">Registrarse</button>
492
- </div>
493
- <button class="modal-close" id="modal-close" title="Cerrar">βœ•</button>
494
- </div>
495
- <div class="modal-body">
496
- <!-- Login Form -->
497
- <form class="modal-form active" id="form-login">
498
- <div class="form-group">
499
- <label for="login-email">Correo electrΓ³nico</label>
500
- <input type="email" id="login-email" placeholder="usuario@ejemplo.com" required />
501
- </div>
502
- <div class="form-group">
503
- <label for="login-password">ContraseΓ±a</label>
504
- <input type="password" id="login-password" placeholder="β€’β€’β€’β€’β€’β€’β€’β€’" required />
505
- </div>
506
- <div class="form-error" id="login-error"></div>
507
- <button type="submit" class="modal-submit">Entrar</button>
508
- </form>
509
-
510
- <!-- Register Form -->
511
- <form class="modal-form" id="form-register">
512
- <div class="form-group">
513
- <label for="register-email">Correo electrΓ³nico</label>
514
- <input type="email" id="register-email" placeholder="usuario@ejemplo.com" required />
515
- </div>
516
- <div class="form-group">
517
- <label for="register-password">ContraseΓ±a</label>
518
- <input type="password" id="register-password" placeholder="MΓ­nimo 8 caracteres" required minlength="8" />
519
- </div>
520
- <div class="form-group">
521
- <label for="register-password-confirm">Confirmar contraseΓ±a</label>
522
- <input type="password" id="register-password-confirm" placeholder="Repite la contraseΓ±a" required />
523
- </div>
524
- <div class="form-error" id="register-error"></div>
525
- <button type="submit" class="modal-submit">Crear cuenta</button>
526
- </form>
527
- </div>
528
- </div>
529
- </div>
530
-
531
 
532
  <script type="module" src="/src/main.js"></script>
533
  </body>
 
7
  <link rel="icon" type="image/svg+xml" href="/vite.svg" />
8
  </head>
9
  <body>
10
+ <!-- Global loader: visible por defecto (sin JS) hasta que bootstrap lo oculte -->
11
+ <div id="global-loader" class="global-loader" role="status" aria-live="polite" aria-busy="true">
12
+ <div class="global-loader__spinner"></div>
13
+ <span class="global-loader__label" id="global-loader-label">Cargando…</span>
14
+ </div>
15
+
16
  <!-- Auth Page (shown when unauthenticated) -->
17
  <div id="view-auth" class="auth-view hidden">
18
  <div class="auth-card">
 
182
  <button class="icon-btn mobile-only" id="btn-watchlist-mobile" title="Seguimiento">β˜†</button>
183
  <button class="icon-btn mobile-only" id="btn-alerts-mobile" title="Alertas">⚑</button>
184
  <button class="icon-btn mobile-only" id="btn-prefs" title="Modelo IA">βš™</button>
185
+ <!-- Desktop: unauthenticated -->
186
  <button class="btn-ghost desktop-only" id="btn-auth">Entrar</button>
187
+ <!-- Desktop: authenticated dropdown -->
188
+ <div class="user-menu" id="user-menu" hidden aria-haspopup="true">
189
+ <button class="user-menu-trigger" id="user-menu-trigger" aria-expanded="false" aria-controls="user-menu-panel">
190
+ <span class="user-avatar" id="user-avatar" aria-hidden="true">?</span>
191
+ <span class="user-email-short" id="user-email-short"></span>
192
+ <span class="user-chevron" aria-hidden="true">β–Ύ</span>
193
+ </button>
194
+ <div class="user-menu-panel" id="user-menu-panel" role="menu" hidden>
195
+ <div class="user-menu-info">
196
+ <span class="user-menu-label">SesiΓ³n iniciada como</span>
197
+ <span class="user-menu-email" id="user-menu-email"></span>
198
+ </div>
199
+ <hr class="user-menu-divider" />
200
+ <button class="user-menu-item" id="btn-logout" role="menuitem">
201
+ Cerrar sesiΓ³n
202
+ </button>
203
+ </div>
204
+ </div>
205
+ <!-- Mobile: auth indicator (comportamiento sin cambios) -->
206
  <button class="icon-btn auth-indicator mobile-only" id="btn-auth-mobile" title="Entrar"></button>
207
  </div>
208
  </header>
 
508
  </div>
509
  </div>
510
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
511
 
512
  <script type="module" src="/src/main.js"></script>
513
  </body>
frontend/src/api.js CHANGED
@@ -123,6 +123,8 @@ export async function updateTelegramConfig({ botToken, chatId, enabled }) {
123
  return body
124
  }
125
 
 
 
126
  /* ─── Core fetch ─── */
127
  async function fetchJson(url, opts = {}) {
128
  const headers = { 'Content-Type': 'application/json', ...opts.headers }
@@ -134,10 +136,24 @@ async function fetchJson(url, opts = {}) {
134
  }
135
  }
136
 
137
- const res = await fetch(url, {
138
- headers,
139
- ...opts,
140
- })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
 
142
  if (!res.ok) {
143
  if (res.status === 401) clearToken()
 
123
  return body
124
  }
125
 
126
+ const FETCH_TIMEOUT_MS = 15_000
127
+
128
  /* ─── Core fetch ─── */
129
  async function fetchJson(url, opts = {}) {
130
  const headers = { 'Content-Type': 'application/json', ...opts.headers }
 
136
  }
137
  }
138
 
139
+ const controller = new AbortController()
140
+ const timerId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
141
+
142
+ let res
143
+ try {
144
+ res = await fetch(url, {
145
+ headers,
146
+ ...opts,
147
+ signal: controller.signal,
148
+ })
149
+ } catch (err) {
150
+ if (err.name === 'AbortError') {
151
+ throw new Error('El servidor tarda en responder. IntΓ©ntalo de nuevo.')
152
+ }
153
+ throw err
154
+ } finally {
155
+ clearTimeout(timerId)
156
+ }
157
 
158
  if (!res.ok) {
159
  if (res.status === 401) clearToken()
frontend/src/app.js CHANGED
@@ -62,6 +62,37 @@ function focusFirstInvalid(form) {
62
  if (invalid) invalid.focus()
63
  }
64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  /* ─── Estado global ─── */
66
  let state = {
67
  view: 'dashboard',
@@ -469,22 +500,95 @@ function handleTelegramTest() {
469
  })
470
  }
471
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
472
  function updateAuthButton() {
473
  const btn = document.getElementById('btn-auth')
 
474
  const indicator = document.getElementById('btn-auth-mobile')
475
  const authed = api.isAuthenticated()
476
 
477
  if (btn) {
478
- if (authed) {
479
- btn.textContent = 'Salir'
480
- btn.onclick = async () => {
481
- await api.logout()
482
- updateAuthButton()
483
- showAuthView()
484
- }
485
- } else {
486
- btn.textContent = 'Entrar'
487
- btn.onclick = showAuthView
 
 
 
 
 
 
 
 
488
  }
489
  }
490
 
@@ -492,11 +596,7 @@ function updateAuthButton() {
492
  indicator.classList.toggle('logged-in', authed)
493
  indicator.title = authed ? 'Salir' : 'Entrar'
494
  indicator.onclick = authed
495
- ? async () => {
496
- await api.logout()
497
- updateAuthButton()
498
- showAuthView()
499
- }
500
  : showAuthView
501
  }
502
  }
@@ -528,13 +628,22 @@ async function handleLogin(e) {
528
  return
529
  }
530
 
 
 
 
 
531
  try {
532
- await api.login(email, password)
 
 
 
533
  showDashboardView()
534
  updateAuthButton()
535
  await initAppData()
536
  } catch (err) {
537
  errorEl.textContent = 'Credenciales incorrectas. IntΓ©ntalo de nuevo.'
 
 
538
  }
539
  }
540
 
@@ -582,8 +691,15 @@ async function handleRegister(e) {
582
  return
583
  }
584
 
 
 
 
 
585
  try {
586
- await api.register(email, password)
 
 
 
587
  showDashboardView()
588
  updateAuthButton()
589
  await initAppData()
@@ -595,6 +711,8 @@ async function handleRegister(e) {
595
  } else {
596
  errorEl.textContent = 'Error al registrar. IntΓ©ntalo de nuevo.'
597
  }
 
 
598
  }
599
  }
600
 
@@ -605,13 +723,13 @@ function attachRegisterInputListeners() {
605
  }
606
 
607
  async function ensureAuth() {
608
- if (!api.isAuthenticated()) return false
609
  try {
610
- await api.getMe()
611
- return true
612
- } catch (e) {
613
  // Token invΓ‘lido o expirado β€” ya fue borrado por fetchJson
614
- return false
615
  }
616
  }
617
 
@@ -1491,6 +1609,9 @@ export async function init() {
1491
  document.getElementById('form-telegram')?.addEventListener('submit', handleTelegramSave)
1492
  document.getElementById('btn-test-telegram')?.addEventListener('click', handleTelegramTest)
1493
 
 
 
 
1494
  // Auth page events
1495
  document.getElementById('btn-auth')?.addEventListener('click', showAuthView)
1496
  document.querySelectorAll('#view-auth .modal-tab').forEach((tab) => {
@@ -1501,15 +1622,18 @@ export async function init() {
1501
  document.getElementById('form-register')?.addEventListener('submit', handleRegister)
1502
  attachRegisterInputListeners()
1503
 
1504
- updateAuthButton()
1505
-
1506
  // Si hay token, carga datos; si no, muestra la pΓ‘gina de auth
1507
- const authed = await ensureAuth()
1508
- if (authed) {
 
 
1509
  showDashboardView()
 
1510
  await initAppData()
1511
  initFilters()
1512
  } else {
 
 
1513
  showAuthView()
1514
  }
1515
 
 
62
  if (invalid) invalid.focus()
63
  }
64
 
65
+ /* ─── Loader global ─── */
66
+ let _pendingRequests = 0
67
+
68
+ function showLoader(label = 'Cargando…') {
69
+ _pendingRequests++
70
+ const el = document.getElementById('global-loader')
71
+ const labelEl = document.getElementById('global-loader-label')
72
+ if (!el) return
73
+ if (labelEl) labelEl.textContent = label
74
+ el.setAttribute('aria-busy', 'true')
75
+ el.classList.remove('hidden')
76
+ }
77
+
78
+ function hideLoader() {
79
+ _pendingRequests = Math.max(0, _pendingRequests - 1)
80
+ if (_pendingRequests > 0) return
81
+ const el = document.getElementById('global-loader')
82
+ if (!el) return
83
+ el.setAttribute('aria-busy', 'false')
84
+ el.classList.add('hidden')
85
+ }
86
+
87
+ async function withLoader(asyncFn, label = 'Cargando…') {
88
+ showLoader(label)
89
+ try {
90
+ return await asyncFn()
91
+ } finally {
92
+ hideLoader()
93
+ }
94
+ }
95
+
96
  /* ─── Estado global ─── */
97
  let state = {
98
  view: 'dashboard',
 
500
  })
501
  }
502
 
503
+ let _currentUser = null
504
+
505
+ async function performLogout(triggerEl) {
506
+ if (triggerEl) triggerEl.disabled = true
507
+ try {
508
+ await withLoader(() => api.logout(), 'Cerrando sesiΓ³n…')
509
+ } finally {
510
+ _currentUser = null
511
+ if (triggerEl) triggerEl.disabled = false
512
+ updateAuthButton()
513
+ showAuthView()
514
+ }
515
+ }
516
+
517
+ function initUserMenu() {
518
+ const trigger = document.getElementById('user-menu-trigger')
519
+ const panel = document.getElementById('user-menu-panel')
520
+ const logoutBtn = document.getElementById('btn-logout')
521
+
522
+ if (!trigger || !panel) return
523
+
524
+ function openMenu() {
525
+ panel.hidden = false
526
+ trigger.setAttribute('aria-expanded', 'true')
527
+ logoutBtn?.focus()
528
+ }
529
+
530
+ function closeMenu() {
531
+ panel.hidden = true
532
+ trigger.setAttribute('aria-expanded', 'false')
533
+ }
534
+
535
+ trigger.addEventListener('click', (e) => {
536
+ e.stopPropagation()
537
+ panel.hidden ? openMenu() : closeMenu()
538
+ })
539
+
540
+ document.addEventListener('click', (e) => {
541
+ if (!document.getElementById('user-menu')?.contains(e.target)) closeMenu()
542
+ })
543
+
544
+ document.addEventListener('keydown', (e) => {
545
+ if (e.key === 'Escape' && !panel.hidden) {
546
+ closeMenu()
547
+ trigger.focus()
548
+ }
549
+ })
550
+
551
+ logoutBtn?.addEventListener('click', () => {
552
+ closeMenu()
553
+ performLogout(logoutBtn)
554
+ })
555
+ }
556
+
557
+ async function loadCurrentUser() {
558
+ if (!api.isAuthenticated()) { _currentUser = null; return }
559
+ try {
560
+ const data = await api.getMe()
561
+ _currentUser = data.user ?? data
562
+ } catch {
563
+ _currentUser = null
564
+ }
565
+ }
566
+
567
  function updateAuthButton() {
568
  const btn = document.getElementById('btn-auth')
569
+ const userMenu = document.getElementById('user-menu')
570
  const indicator = document.getElementById('btn-auth-mobile')
571
  const authed = api.isAuthenticated()
572
 
573
  if (btn) {
574
+ btn.style.display = authed ? 'none' : ''
575
+ btn.onclick = showAuthView
576
+ }
577
+
578
+ if (userMenu) {
579
+ userMenu.hidden = !authed
580
+ if (authed && _currentUser) {
581
+ const email = _currentUser.email ?? ''
582
+ const initial = email.charAt(0).toUpperCase() || '?'
583
+ const shortEmail = email.length > 18 ? email.slice(0, 16) + '…' : email
584
+
585
+ const avatarEl = document.getElementById('user-avatar')
586
+ const shortEl = document.getElementById('user-email-short')
587
+ const fullEl = document.getElementById('user-menu-email')
588
+
589
+ if (avatarEl) avatarEl.textContent = initial
590
+ if (shortEl) shortEl.textContent = shortEmail
591
+ if (fullEl) fullEl.textContent = email
592
  }
593
  }
594
 
 
596
  indicator.classList.toggle('logged-in', authed)
597
  indicator.title = authed ? 'Salir' : 'Entrar'
598
  indicator.onclick = authed
599
+ ? () => performLogout(indicator)
 
 
 
 
600
  : showAuthView
601
  }
602
  }
 
628
  return
629
  }
630
 
631
+ const submitBtn = e.target.querySelector('button[type="submit"]')
632
+ const originalText = submitBtn?.textContent
633
+ if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = 'Entrando…' }
634
+
635
  try {
636
+ await withLoader(async () => {
637
+ await api.login(email, password)
638
+ await loadCurrentUser()
639
+ }, 'Iniciando sesiΓ³n…')
640
  showDashboardView()
641
  updateAuthButton()
642
  await initAppData()
643
  } catch (err) {
644
  errorEl.textContent = 'Credenciales incorrectas. IntΓ©ntalo de nuevo.'
645
+ } finally {
646
+ if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = originalText }
647
  }
648
  }
649
 
 
691
  return
692
  }
693
 
694
+ const submitBtn = e.target.querySelector('button[type="submit"]')
695
+ const originalText = submitBtn?.textContent
696
+ if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = 'Creando cuenta…' }
697
+
698
  try {
699
+ await withLoader(async () => {
700
+ await api.register(email, password)
701
+ await loadCurrentUser()
702
+ }, 'Creando cuenta…')
703
  showDashboardView()
704
  updateAuthButton()
705
  await initAppData()
 
711
  } else {
712
  errorEl.textContent = 'Error al registrar. IntΓ©ntalo de nuevo.'
713
  }
714
+ } finally {
715
+ if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = originalText }
716
  }
717
  }
718
 
 
723
  }
724
 
725
  async function ensureAuth() {
726
+ if (!api.isAuthenticated()) return null
727
  try {
728
+ const data = await api.getMe()
729
+ return data.user ?? data
730
+ } catch {
731
  // Token invΓ‘lido o expirado β€” ya fue borrado por fetchJson
732
+ return null
733
  }
734
  }
735
 
 
1609
  document.getElementById('form-telegram')?.addEventListener('submit', handleTelegramSave)
1610
  document.getElementById('btn-test-telegram')?.addEventListener('click', handleTelegramTest)
1611
 
1612
+ // User menu dropdown
1613
+ initUserMenu()
1614
+
1615
  // Auth page events
1616
  document.getElementById('btn-auth')?.addEventListener('click', showAuthView)
1617
  document.querySelectorAll('#view-auth .modal-tab').forEach((tab) => {
 
1622
  document.getElementById('form-register')?.addEventListener('submit', handleRegister)
1623
  attachRegisterInputListeners()
1624
 
 
 
1625
  // Si hay token, carga datos; si no, muestra la pΓ‘gina de auth
1626
+ const user = await ensureAuth()
1627
+ if (user) {
1628
+ _currentUser = user
1629
+ updateAuthButton()
1630
  showDashboardView()
1631
+ hideLoader()
1632
  await initAppData()
1633
  initFilters()
1634
  } else {
1635
+ updateAuthButton()
1636
+ hideLoader()
1637
  showAuthView()
1638
  }
1639
 
frontend/src/style.css CHANGED
@@ -238,8 +238,9 @@ h6 { font-size: var(--fs-h6); line-height: 1.4; font-weight: 500; }
238
  padding: 0 16px;
239
  padding-left: calc(var(--sidebar-width) + var(--panel-gap));
240
  gap: 16px;
241
- overflow: hidden;
242
  position: relative;
 
243
  }
244
 
245
  .layout.collapsed .topbar {
@@ -393,6 +394,123 @@ h6 { font-size: var(--fs-h6); line-height: 1.4; font-weight: 500; }
393
  border-color: var(--border2);
394
  }
395
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
396
  /* ─── Main content ─── */
397
  .main {
398
  grid-area: main;
@@ -2096,6 +2214,25 @@ td {
2096
  gap: 10px;
2097
  }
2098
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2099
  /* Stats row with infinite scroll */
2100
  .topbar-stats {
2101
  order: 1;
@@ -2362,3 +2499,42 @@ td {
2362
  flex-direction: column;
2363
  gap: 16px;
2364
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
238
  padding: 0 16px;
239
  padding-left: calc(var(--sidebar-width) + var(--panel-gap));
240
  gap: 16px;
241
+ overflow: visible;
242
  position: relative;
243
+ z-index: 100;
244
  }
245
 
246
  .layout.collapsed .topbar {
 
394
  border-color: var(--border2);
395
  }
396
 
397
+ /* ─── User menu dropdown (desktop) ─── */
398
+ .user-menu {
399
+ position: relative;
400
+ }
401
+
402
+ .user-menu-trigger {
403
+ display: flex;
404
+ align-items: center;
405
+ gap: 8px;
406
+ background: var(--bg3);
407
+ border: 0.5px solid var(--border2);
408
+ border-radius: var(--radius-sm);
409
+ padding: 6px 10px;
410
+ cursor: pointer;
411
+ font-family: var(--font-sans);
412
+ font-size: 0.875rem;
413
+ color: var(--text2);
414
+ transition: color 0.15s, border-color 0.15s;
415
+ }
416
+
417
+ .user-menu-trigger:hover {
418
+ color: var(--text);
419
+ border-color: var(--blue2);
420
+ }
421
+
422
+ .user-avatar {
423
+ width: 22px;
424
+ height: 22px;
425
+ border-radius: 50%;
426
+ background: var(--blue3);
427
+ border: 0.5px solid var(--blue2);
428
+ color: var(--blue);
429
+ font-size: 0.75rem;
430
+ font-weight: 600;
431
+ display: inline-flex;
432
+ align-items: center;
433
+ justify-content: center;
434
+ flex-shrink: 0;
435
+ text-transform: uppercase;
436
+ }
437
+
438
+ .user-email-short {
439
+ max-width: 120px;
440
+ overflow: hidden;
441
+ text-overflow: ellipsis;
442
+ white-space: nowrap;
443
+ }
444
+
445
+ .user-chevron {
446
+ font-size: 0.7rem;
447
+ color: var(--text3);
448
+ transition: transform 0.15s;
449
+ }
450
+
451
+ .user-menu-trigger[aria-expanded="true"] .user-chevron {
452
+ transform: rotate(180deg);
453
+ }
454
+
455
+ .user-menu-panel {
456
+ position: absolute;
457
+ top: calc(100% + 6px);
458
+ right: 0;
459
+ min-width: 200px;
460
+ background: var(--bg2);
461
+ border: 0.5px solid var(--border2);
462
+ border-radius: var(--radius-sm);
463
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
464
+ z-index: 200;
465
+ padding: 6px 0;
466
+ }
467
+
468
+ .user-menu-info {
469
+ display: flex;
470
+ flex-direction: column;
471
+ gap: 2px;
472
+ padding: 10px 14px;
473
+ }
474
+
475
+ .user-menu-label {
476
+ font-size: 0.7rem;
477
+ color: var(--text3);
478
+ text-transform: uppercase;
479
+ letter-spacing: 0.04em;
480
+ }
481
+
482
+ .user-menu-email {
483
+ font-size: 0.8125rem;
484
+ color: var(--text);
485
+ font-weight: 500;
486
+ word-break: break-all;
487
+ }
488
+
489
+ .user-menu-divider {
490
+ border: none;
491
+ border-top: 0.5px solid var(--border2);
492
+ margin: 4px 0;
493
+ }
494
+
495
+ .user-menu-item {
496
+ display: block;
497
+ width: 100%;
498
+ text-align: left;
499
+ padding: 8px 14px;
500
+ background: none;
501
+ border: none;
502
+ font-family: var(--font-sans);
503
+ font-size: 0.875rem;
504
+ color: var(--text2);
505
+ cursor: pointer;
506
+ transition: background 0.1s, color 0.1s;
507
+ }
508
+
509
+ .user-menu-item:hover {
510
+ background: var(--bg3);
511
+ color: var(--text);
512
+ }
513
+
514
  /* ─── Main content ─── */
515
  .main {
516
  grid-area: main;
 
2214
  gap: 10px;
2215
  }
2216
 
2217
+ /* En mobile se muestra versiΓ³n compacta del user-menu (solo avatar) */
2218
+ .user-menu {
2219
+ display: flex !important;
2220
+ }
2221
+
2222
+ .user-email-short {
2223
+ display: none;
2224
+ }
2225
+
2226
+ .user-menu-trigger {
2227
+ padding: 4px 8px;
2228
+ gap: 4px;
2229
+ }
2230
+
2231
+ .user-menu-panel {
2232
+ right: 0;
2233
+ min-width: 180px;
2234
+ }
2235
+
2236
  /* Stats row with infinite scroll */
2237
  .topbar-stats {
2238
  order: 1;
 
2499
  flex-direction: column;
2500
  gap: 16px;
2501
  }
2502
+
2503
+ /* ─── Global Loader ─── */
2504
+ .global-loader {
2505
+ position: fixed;
2506
+ inset: 0;
2507
+ z-index: 9999;
2508
+ background: var(--bg);
2509
+ display: flex;
2510
+ flex-direction: column;
2511
+ align-items: center;
2512
+ justify-content: center;
2513
+ gap: 16px;
2514
+ transition: opacity 0.25s ease;
2515
+ }
2516
+
2517
+ .global-loader.hidden {
2518
+ opacity: 0;
2519
+ pointer-events: none;
2520
+ }
2521
+
2522
+ .global-loader__spinner {
2523
+ width: 36px;
2524
+ height: 36px;
2525
+ border: 3px solid var(--border2);
2526
+ border-top-color: var(--accent);
2527
+ border-radius: 50%;
2528
+ animation: loader-spin 0.7s linear infinite;
2529
+ }
2530
+
2531
+ @keyframes loader-spin {
2532
+ to { transform: rotate(360deg); }
2533
+ }
2534
+
2535
+ .global-loader__label {
2536
+ font-family: var(--font-mono);
2537
+ font-size: 0.8rem;
2538
+ color: var(--text2);
2539
+ letter-spacing: 0.04em;
2540
+ }