germansango01 commited on
Commit
c677cde
Β·
1 Parent(s): fbdd07a

Cambios en login y logout flow and add loading helper

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">
@@ -177,7 +183,27 @@
177
  <button class="icon-btn mobile-only" id="btn-watchlist-mobile" title="Seguimiento">β˜†</button>
178
  <button class="icon-btn mobile-only" id="btn-alerts-mobile" title="Alertas">⚑</button>
179
  <button class="icon-btn mobile-only" id="btn-prefs" title="Modelo IA">βš™</button>
 
180
  <button class="btn-ghost desktop-only" id="btn-auth">Entrar</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
  <button class="icon-btn auth-indicator mobile-only" id="btn-auth-mobile" title="Entrar"></button>
182
  </div>
183
  </header>
@@ -470,52 +496,6 @@
470
  </div>
471
  </div>
472
 
473
- <!-- Auth Modal -->
474
- <div class="modal-overlay hidden" id="auth-modal">
475
- <div class="modal">
476
- <div class="modal-header">
477
- <div class="modal-tabs">
478
- <button class="modal-tab active" data-tab="login">Iniciar sesiΓ³n</button>
479
- <button class="modal-tab" data-tab="register">Registrarse</button>
480
- </div>
481
- <button class="modal-close" id="modal-close" title="Cerrar">βœ•</button>
482
- </div>
483
- <div class="modal-body">
484
- <!-- Login Form -->
485
- <form class="modal-form active" id="form-login">
486
- <div class="form-group">
487
- <label for="login-email">Correo electrΓ³nico</label>
488
- <input type="email" id="login-email" placeholder="usuario@ejemplo.com" required />
489
- </div>
490
- <div class="form-group">
491
- <label for="login-password">ContraseΓ±a</label>
492
- <input type="password" id="login-password" placeholder="β€’β€’β€’β€’β€’β€’β€’β€’" required />
493
- </div>
494
- <div class="form-error" id="login-error"></div>
495
- <button type="submit" class="modal-submit">Entrar</button>
496
- </form>
497
-
498
- <!-- Register Form -->
499
- <form class="modal-form" id="form-register">
500
- <div class="form-group">
501
- <label for="register-email">Correo electrΓ³nico</label>
502
- <input type="email" id="register-email" placeholder="usuario@ejemplo.com" required />
503
- </div>
504
- <div class="form-group">
505
- <label for="register-password">ContraseΓ±a</label>
506
- <input type="password" id="register-password" placeholder="MΓ­nimo 8 caracteres" required minlength="8" />
507
- </div>
508
- <div class="form-group">
509
- <label for="register-password-confirm">Confirmar contraseΓ±a</label>
510
- <input type="password" id="register-password-confirm" placeholder="Repite la contraseΓ±a" required />
511
- </div>
512
- <div class="form-error" id="register-error"></div>
513
- <button type="submit" class="modal-submit">Crear cuenta</button>
514
- </form>
515
- </div>
516
- </div>
517
- </div>
518
-
519
 
520
  <script type="module" src="/src/main.js"></script>
521
  </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">
 
183
  <button class="icon-btn mobile-only" id="btn-watchlist-mobile" title="Seguimiento">β˜†</button>
184
  <button class="icon-btn mobile-only" id="btn-alerts-mobile" title="Alertas">⚑</button>
185
  <button class="icon-btn mobile-only" id="btn-prefs" title="Modelo IA">βš™</button>
186
+ <!-- Desktop: unauthenticated -->
187
  <button class="btn-ghost desktop-only" id="btn-auth">Entrar</button>
188
+ <!-- Desktop: authenticated dropdown -->
189
+ <div class="user-menu" id="user-menu" hidden aria-haspopup="true">
190
+ <button class="user-menu-trigger" id="user-menu-trigger" aria-expanded="false" aria-controls="user-menu-panel">
191
+ <span class="user-avatar" id="user-avatar" aria-hidden="true">?</span>
192
+ <span class="user-email-short" id="user-email-short"></span>
193
+ <span class="user-chevron" aria-hidden="true">β–Ύ</span>
194
+ </button>
195
+ <div class="user-menu-panel" id="user-menu-panel" role="menu" hidden>
196
+ <div class="user-menu-info">
197
+ <span class="user-menu-label">SesiΓ³n iniciada como</span>
198
+ <span class="user-menu-email" id="user-menu-email"></span>
199
+ </div>
200
+ <hr class="user-menu-divider" />
201
+ <button class="user-menu-item" id="btn-logout" role="menuitem">
202
+ Cerrar sesiΓ³n
203
+ </button>
204
+ </div>
205
+ </div>
206
+ <!-- Mobile: auth indicator (comportamiento sin cambios) -->
207
  <button class="icon-btn auth-indicator mobile-only" id="btn-auth-mobile" title="Entrar"></button>
208
  </div>
209
  </header>
 
496
  </div>
497
  </div>
498
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
499
 
500
  <script type="module" src="/src/main.js"></script>
501
  </body>
frontend/src/api.js CHANGED
@@ -86,6 +86,8 @@ export async function getMe() {
86
  return fetchJson(`${BASE}/auth/me`)
87
  }
88
 
 
 
89
  /* ─── Core fetch ─── */
90
  async function fetchJson(url, opts = {}) {
91
  const headers = { 'Content-Type': 'application/json', ...opts.headers }
@@ -97,10 +99,24 @@ async function fetchJson(url, opts = {}) {
97
  }
98
  }
99
 
100
- const res = await fetch(url, {
101
- headers,
102
- ...opts,
103
- })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
 
105
  if (!res.ok) {
106
  if (res.status === 401) clearToken()
 
86
  return fetchJson(`${BASE}/auth/me`)
87
  }
88
 
89
+ const FETCH_TIMEOUT_MS = 15_000
90
+
91
  /* ─── Core fetch ─── */
92
  async function fetchJson(url, opts = {}) {
93
  const headers = { 'Content-Type': 'application/json', ...opts.headers }
 
99
  }
100
  }
101
 
102
+ const controller = new AbortController()
103
+ const timerId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
104
+
105
+ let res
106
+ try {
107
+ res = await fetch(url, {
108
+ headers,
109
+ ...opts,
110
+ signal: controller.signal,
111
+ })
112
+ } catch (err) {
113
+ if (err.name === 'AbortError') {
114
+ throw new Error('El servidor tarda en responder. IntΓ©ntalo de nuevo.')
115
+ }
116
+ throw err
117
+ } finally {
118
+ clearTimeout(timerId)
119
+ }
120
 
121
  if (!res.ok) {
122
  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
 
@@ -1483,6 +1601,9 @@ export async function init() {
1483
  document.getElementById('form-telegram')?.addEventListener('submit', handleTelegramSave)
1484
  document.getElementById('btn-test-telegram')?.addEventListener('click', handleTelegramTest)
1485
 
 
 
 
1486
  // Auth page events
1487
  document.getElementById('btn-auth')?.addEventListener('click', showAuthView)
1488
  document.querySelectorAll('#view-auth .modal-tab').forEach((tab) => {
@@ -1493,15 +1614,18 @@ export async function init() {
1493
  document.getElementById('form-register')?.addEventListener('submit', handleRegister)
1494
  attachRegisterInputListeners()
1495
 
1496
- updateAuthButton()
1497
-
1498
  // Si hay token, carga datos; si no, muestra la pΓ‘gina de auth
1499
- const authed = await ensureAuth()
1500
- if (authed) {
 
 
1501
  showDashboardView()
 
1502
  await initAppData()
1503
  initFilters()
1504
  } else {
 
 
1505
  showAuthView()
1506
  }
1507
 
 
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
 
 
1601
  document.getElementById('form-telegram')?.addEventListener('submit', handleTelegramSave)
1602
  document.getElementById('btn-test-telegram')?.addEventListener('click', handleTelegramTest)
1603
 
1604
+ // User menu dropdown
1605
+ initUserMenu()
1606
+
1607
  // Auth page events
1608
  document.getElementById('btn-auth')?.addEventListener('click', showAuthView)
1609
  document.querySelectorAll('#view-auth .modal-tab').forEach((tab) => {
 
1614
  document.getElementById('form-register')?.addEventListener('submit', handleRegister)
1615
  attachRegisterInputListeners()
1616
 
 
 
1617
  // Si hay token, carga datos; si no, muestra la pΓ‘gina de auth
1618
+ const user = await ensureAuth()
1619
+ if (user) {
1620
+ _currentUser = user
1621
+ updateAuthButton()
1622
  showDashboardView()
1623
+ hideLoader()
1624
  await initAppData()
1625
  initFilters()
1626
  } else {
1627
+ updateAuthButton()
1628
+ hideLoader()
1629
  showAuthView()
1630
  }
1631
 
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;
@@ -2025,6 +2143,25 @@ td {
2025
  gap: 10px;
2026
  }
2027
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2028
  /* Stats row with infinite scroll */
2029
  .topbar-stats {
2030
  order: 1;
@@ -2244,3 +2381,42 @@ td {
2244
  flex-direction: column;
2245
  gap: 16px;
2246
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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;
 
2143
  gap: 10px;
2144
  }
2145
 
2146
+ /* En mobile se muestra versiΓ³n compacta del user-menu (solo avatar) */
2147
+ .user-menu {
2148
+ display: flex !important;
2149
+ }
2150
+
2151
+ .user-email-short {
2152
+ display: none;
2153
+ }
2154
+
2155
+ .user-menu-trigger {
2156
+ padding: 4px 8px;
2157
+ gap: 4px;
2158
+ }
2159
+
2160
+ .user-menu-panel {
2161
+ right: 0;
2162
+ min-width: 180px;
2163
+ }
2164
+
2165
  /* Stats row with infinite scroll */
2166
  .topbar-stats {
2167
  order: 1;
 
2381
  flex-direction: column;
2382
  gap: 16px;
2383
  }
2384
+
2385
+ /* ─── Global Loader ─── */
2386
+ .global-loader {
2387
+ position: fixed;
2388
+ inset: 0;
2389
+ z-index: 9999;
2390
+ background: var(--bg);
2391
+ display: flex;
2392
+ flex-direction: column;
2393
+ align-items: center;
2394
+ justify-content: center;
2395
+ gap: 16px;
2396
+ transition: opacity 0.25s ease;
2397
+ }
2398
+
2399
+ .global-loader.hidden {
2400
+ opacity: 0;
2401
+ pointer-events: none;
2402
+ }
2403
+
2404
+ .global-loader__spinner {
2405
+ width: 36px;
2406
+ height: 36px;
2407
+ border: 3px solid var(--border2);
2408
+ border-top-color: var(--accent);
2409
+ border-radius: 50%;
2410
+ animation: loader-spin 0.7s linear infinite;
2411
+ }
2412
+
2413
+ @keyframes loader-spin {
2414
+ to { transform: rotate(360deg); }
2415
+ }
2416
+
2417
+ .global-loader__label {
2418
+ font-family: var(--font-mono);
2419
+ font-size: 0.8rem;
2420
+ color: var(--text2);
2421
+ letter-spacing: 0.04em;
2422
+ }