Spaces:
Sleeping
Sleeping
Merge pull request #33 from josesalazar2025/feature/header-user-logout
Browse files- frontend/index.html +26 -46
- frontend/src/api.js +20 -4
- frontend/src/app.js +150 -26
- frontend/src/style.css +177 -1
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
|
| 138 |
-
|
| 139 |
-
|
| 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 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
?
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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
|
| 609 |
try {
|
| 610 |
-
await api.getMe()
|
| 611 |
-
return
|
| 612 |
-
} catch
|
| 613 |
// Token invΓ‘lido o expirado β ya fue borrado por fetchJson
|
| 614 |
-
return
|
| 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
|
| 1508 |
-
if (
|
|
|
|
|
|
|
| 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:
|
| 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 |
+
}
|