germansango01 commited on
Commit Β·
c677cde
1
Parent(s): fbdd07a
Cambios en login y logout flow and add loading helper
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">
|
|
@@ -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
|
| 101 |
-
|
| 102 |
-
|
| 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 |
-
|
| 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 |
|
|
@@ -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
|
| 1500 |
-
if (
|
|
|
|
|
|
|
| 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:
|
| 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 |
+
}
|