| <!DOCTYPE html>
|
| <html lang="en">
|
| <head>
|
| <meta charset="UTF-8">
|
| <meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-visual">
|
| <title>Odysseus — Login</title>
|
| <script nonce="{{CSP_NONCE}}">
|
| (function(){
|
|
|
|
|
|
|
| var THEME_DEFAULT_PATTERN = {
|
| dark:'none', light:'dots', midnight:'rain', paper:'dots',
|
| cyberpunk:'synapse', retrowave:'embers', forest:'petals',
|
| ocean:'constellations', terminal:'perlin-flow', organs:'rain',
|
| ume:'petals', cute:'sparkles'
|
| };
|
| var THEME_DEFAULT_EFFECT_COLOR = {
|
| midnight:'#ffffff', organs:'#451616', cute:'#ff8cb8', ume:'#f5a0c0'
|
| };
|
| var THEME_DEFAULT_INTENSITY = { midnight:0.5, terminal:0.8, organs:0.65 };
|
| try {
|
| var t = JSON.parse(localStorage.getItem('odysseus-theme'));
|
| var s = document.documentElement.style;
|
| if (t && t.colors) {
|
| var c = t.colors;
|
|
|
| s.setProperty('--bg', c.bg);
|
| s.setProperty('--fg', c.fg);
|
| s.setProperty('--panel', c.panel);
|
| s.setProperty('--border', c.border);
|
| if (c.red) s.setProperty('--red', c.red);
|
| if (c.green) s.setProperty('--green', c.green);
|
|
|
|
|
|
|
|
|
| var a = c.advanced || {};
|
| var ADV = {
|
| userBubbleBg: '--user-bubble-bg',
|
| aiBubbleBg: '--ai-bubble-bg',
|
| bubbleBorder: '--bubble-border',
|
| sidebarBg: '--sidebar-bg',
|
| brandColor: '--brand-color',
|
| hamburgerColor: '--hamburger-color',
|
| inputBg: '--input-bg',
|
| inputBorder: '--input-border',
|
| sendBtnBg: '--send-btn-bg',
|
| sendBtnHover: '--send-btn-hover',
|
| codeBg: '--code-bg',
|
| codeFg: '--code-fg',
|
| toggleActive: '--toggle-active',
|
| };
|
| for (var k in ADV) { if (a[k]) s.setProperty(ADV[k], a[k]); }
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
| var name = (t && t.name) || '';
|
|
|
| var PATTERNS = ['dots','synapse','rain','constellations','petals','sparkles','embers'];
|
| var pattern = PATTERNS[Math.floor(Math.random() * PATTERNS.length)];
|
| var effColor = (t && t.bgEffectColor) || THEME_DEFAULT_EFFECT_COLOR[name] || '';
|
| var effInt = (t && t.bgEffectIntensity !== undefined)
|
| ? t.bgEffectIntensity
|
| : (THEME_DEFAULT_INTENSITY[name] !== undefined ? THEME_DEFAULT_INTENSITY[name] : 1);
|
| var effSize = (t && t.bgEffectSize !== undefined) ? t.bgEffectSize : 1;
|
| if (effColor) s.setProperty('--bg-effect-color', effColor);
|
| s.setProperty('--bg-effect-intensity', String(effInt));
|
| s.setProperty('--bg-effect-size', String(effSize));
|
|
|
| window.__loginBgPattern = pattern;
|
|
|
|
|
| var apply = function() { document.body.classList.add('bg-pattern-' + pattern); };
|
| if (document.body) apply();
|
| else document.addEventListener('DOMContentLoaded', apply, { once: true });
|
| } catch(e){}
|
| })();
|
| </script>
|
| <style>
|
| @font-face { font-family: 'Fira Code'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-Regular.woff2') format('woff2'); }
|
| @font-face { font-family: 'Fira Code'; font-weight: 600; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-SemiBold.woff2') format('woff2'); }
|
| |
| |
| |
|
|
| :root {
|
| --bg: #282c34; --fg: #9cdef2; --panel: #111; --border: #355a66;
|
| --red: #e06c75; --green: #50fa7b;
|
| --bg-effect-intensity: 1;
|
| }
|
| |
| |
| |
| |
|
|
| #synapse-canvas, #rain-canvas, #constellations-canvas,
|
| #perlin-flow-canvas, #petals-canvas, #sparkles-canvas,
|
| #embers-canvas { opacity: var(--bg-effect-intensity, 1); }
|
| body.bg-pattern-dots {
|
| background-image: radial-gradient(color-mix(in srgb, var(--bg-effect-color, var(--fg)) calc(5% * var(--bg-effect-intensity, 1)), transparent) 1px, transparent 1px);
|
| background-size: 20px 20px; background-attachment: fixed;
|
| }
|
| body.bg-pattern-synapse {
|
| background-image: linear-gradient(color-mix(in srgb, var(--bg-effect-color, var(--fg)) calc(3.5% * var(--bg-effect-intensity, 1)), transparent) 1px, transparent 1px),
|
| linear-gradient(90deg, color-mix(in srgb, var(--bg-effect-color, var(--fg)) calc(3.5% * var(--bg-effect-intensity, 1)), transparent) 1px, transparent 1px);
|
| background-size: 24px 24px; background-attachment: fixed;
|
| }
|
| * { box-sizing: border-box; margin: 0; padding: 0; }
|
| body {
|
| font-family: 'Fira Code', monospace;
|
| background: var(--bg); color: var(--fg);
|
| display: flex; justify-content: center; align-items: flex-start;
|
| min-height: 100vh; min-height: 100dvh;
|
| padding-top: 15vh;
|
| transition: padding-top 0.2s ease;
|
| }
|
| .card {
|
| background: var(--panel); border: 1px solid var(--border); border-radius: 12px;
|
| padding: 2rem; width: 360px; max-width: 90vw;
|
| |
| |
|
|
| position: relative; z-index: 1;
|
| }
|
| .logo {
|
| text-align: center; margin-bottom: 1.5rem;
|
| }
|
| .logo span {
|
| font-family: 'Fira Code', monospace;
|
| font-size: 2rem; font-weight: 600;
|
| letter-spacing: 0.04em;
|
| |
| |
|
|
| background: linear-gradient(135deg, var(--brand-color, var(--red)), color-mix(in srgb, var(--brand-color, var(--red)) 60%, var(--fg)));
|
| -webkit-background-clip: text;
|
| -webkit-text-fill-color: transparent;
|
| background-clip: text;
|
| }
|
| .setup-note { color: color-mix(in srgb, var(--fg) 60%, transparent); font-size: 0.8rem; margin-bottom: 1rem; text-align: center; }
|
| label { display: block; font-size: 0.85rem; margin-bottom: 0.3rem; color: color-mix(in srgb, var(--fg) 65%, transparent); }
|
| input:not(.remember-check) {
|
| width: 100%; padding: 0.6rem 0.8rem; margin-bottom: 1rem;
|
| background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
|
| color: var(--fg); font-size: 0.95rem; font-family: 'Fira Code', monospace;
|
| }
|
| input:focus { outline: none; border-color: var(--red); }
|
| |
| |
| |
| |
|
|
| @media (hover: none) and (pointer: coarse) {
|
| input:not(.remember-check) { font-size: 16px !important; }
|
| }
|
|
|
| input:focus-visible, a:focus-visible, button:focus-visible {
|
| outline: 2px solid var(--red);
|
| outline-offset: 2px;
|
| }
|
| button {
|
| width: 100%;
|
| |
|
|
| padding: calc(0.7rem + 1px) 0.7rem calc(0.7rem - 1px);
|
| border: none; border-radius: 6px;
|
| |
|
|
| background: color-mix(in srgb, var(--red) 78%, #000); color: #fff; font-size: 1rem; cursor: pointer;
|
| font-weight: 600; font-family: 'Fira Code', monospace;
|
| }
|
| button:hover { background: color-mix(in srgb, var(--red) 66%, black); }
|
| button:disabled { opacity: 0.5; cursor: not-allowed; }
|
| .error { color: #e55; font-size: 0.85rem; margin-bottom: 0.75rem; display: none; }
|
| .toggle { text-align: center; margin-top: calc(1rem + 4px); font-size: 0.85rem; color: color-mix(in srgb, var(--fg) 50%, transparent); }
|
| .toggle a { color: var(--red); cursor: pointer; text-decoration: none; }
|
| .toggle a:hover { text-decoration: underline; }
|
| .pw-wrapper { position: relative; margin-bottom: 1rem; }
|
| .pw-wrapper input:not(.remember-check) { padding-right: 2.5rem; margin-bottom: 0; }
|
| .pw-toggle {
|
| position: absolute; right: 8px; top: 50%;
|
| |
|
|
| transform: translateY(calc(-50% + 1px));
|
| background: none; border: none; padding: 4px; cursor: pointer;
|
| color: color-mix(in srgb, var(--fg) 40%, transparent); width: auto;
|
| display: flex; align-items: center; justify-content: center;
|
| }
|
| .pw-toggle:hover { color: var(--fg); background: none; }
|
| .pw-toggle svg { display: block; }
|
| .remember-toggle {
|
| position: absolute !important; right: 13px; top: 50%; transform: translateY(-50%);
|
| background: none; border: none; padding: 2px; cursor: pointer;
|
| width: 14px !important; height: 14px !important; display: flex !important;
|
| align-items: center; justify-content: center;
|
| font-size: 0; margin: 0; color: transparent;
|
| }
|
| |
| |
|
|
| .remember-toggle .remember-check {
|
| position: absolute; top: 0; left: 0;
|
| width: 100%; height: 100%; margin: 0;
|
| opacity: 0; cursor: pointer;
|
| }
|
| .remember-toggle .remember-check:focus-visible + .remember-dot {
|
| outline: 2px solid var(--red); outline-offset: 2px;
|
| }
|
| .remember-toggle .remember-dot {
|
| display: block; width: 10px; height: 10px; min-width: 10px; min-height: 10px;
|
| border-radius: 50%;
|
| border: 2px solid color-mix(in srgb, var(--fg) 25%, transparent);
|
| background: transparent;
|
| transition: all 0.15s;
|
| }
|
| .remember-toggle .remember-check:checked + .remember-dot {
|
| border-color: transparent;
|
| background: color-mix(in srgb, var(--fg) 40%, transparent);
|
| }
|
| .remember-toggle:hover .remember-dot {
|
| border-color: color-mix(in srgb, var(--fg) 40%, transparent);
|
| }
|
| .logo-boat { width: 1.6rem; height: 1.6rem; margin-right: 0.4rem; vertical-align: -0.15em; color: var(--red); }
|
| .version-label {
|
| position: fixed; bottom: 12px; right: 16px;
|
| font-size: 0.7rem; opacity: 0.25;
|
| font-family: 'Fira Code', monospace;
|
| pointer-events: none; user-select: none;
|
| }
|
| |
| |
|
|
| .login-spinner {
|
| display: inline-block;
|
| width: 16px; height: 16px;
|
| border: 2px solid color-mix(in srgb, var(--fg, #fff) 22%, transparent);
|
| border-top-color: var(--accent, var(--red, #e8a830));
|
| border-radius: 50%;
|
| animation: login-spin 0.7s linear infinite;
|
| vertical-align: -3px;
|
| }
|
| @keyframes login-spin { to { transform: rotate(360deg); } }
|
| </style>
|
| </head>
|
| <body>
|
| <main class="card">
|
| <h1 class="logo">
|
| <svg class="logo-boat" viewBox="0 0 32 32" aria-hidden="true" focusable="false"><path d="M16 4L16 22L6 22Z" fill="currentColor"/><path d="M16 8L16 22L24 22Z" fill="currentColor" opacity="0.6"/><path d="M4 24Q10 20 16 24Q22 28 28 24" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round"/></svg><span>Odysseus</span>
|
| </h1>
|
| <p class="setup-note" id="setupNote" style="display:none"></p>
|
|
|
| <div class="error" id="error" role="alert" aria-live="assertive"></div>
|
|
|
| <form id="authForm" autocomplete="on">
|
| <label for="username">Username</label>
|
| <div class="pw-wrapper">
|
| <input id="username" name="username" type="text" required autofocus autocomplete="username">
|
| <label class="remember-toggle" id="rememberToggle" title="Remember me">
|
| <input type="checkbox" class="remember-check" id="remember" checked aria-label="Remember me">
|
| <span class="remember-dot" aria-hidden="true"></span>
|
| </label>
|
| </div>
|
|
|
| <label for="password">Password</label>
|
| <div class="pw-wrapper">
|
| <input id="password" name="password" type="password" required autocomplete="current-password">
|
| <button type="button" class="pw-toggle" id="pwToggle" tabindex="-1" aria-label="Show password">
|
| <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><line x1="8" y1="16" x2="16" y2="8"/><line x1="8" y1="8" x2="16" y2="16"/></svg>
|
| </button>
|
| </div>
|
|
|
| <div id="confirmGroup" style="display:none">
|
| <label for="confirmPassword">Confirm Password</label>
|
| <div class="pw-wrapper">
|
| <input id="confirmPassword" name="confirmPassword" type="password" autocomplete="new-password">
|
| <button type="button" class="pw-toggle" id="pwToggleConfirm" tabindex="-1" aria-label="Show password">
|
| <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><line x1="8" y1="16" x2="16" y2="8"/><line x1="8" y1="8" x2="16" y2="16"/></svg>
|
| </button>
|
| </div>
|
| </div>
|
|
|
| <button type="submit" id="submitBtn">Sign In</button>
|
| </form>
|
|
|
| <div class="toggle" id="toggleArea" style="display:none">
|
| <span id="toggleText">Don't have an account? </span>
|
| <a id="toggleLink" href="#">Sign up</a>
|
| </div>
|
| </main>
|
|
|
| <footer class="version-label" id="version-label"></footer>
|
|
|
| <script nonce="{{CSP_NONCE}}">
|
| (async () => {
|
|
|
| try {
|
| const vr = await fetch('/api/version');
|
| if (vr.ok) {
|
| const vd = await vr.json();
|
| document.getElementById('version-label').textContent = 'v' + vd.version;
|
| }
|
| } catch(e) {}
|
|
|
|
|
| const usernameInput = document.getElementById('username');
|
| const savedUser = localStorage.getItem('odysseus-last-user');
|
| if (savedUser && usernameInput) {
|
| usernameInput.value = savedUser;
|
| document.getElementById('password').focus();
|
| }
|
|
|
| const form = document.getElementById('authForm');
|
| const errEl = document.getElementById('error');
|
| const setupNote = document.getElementById('setupNote');
|
| const confirmGroup = document.getElementById('confirmGroup');
|
| const submitBtn = document.getElementById('submitBtn');
|
| const toggleArea = document.getElementById('toggleArea');
|
| const toggleLink = document.getElementById('toggleLink');
|
| const toggleText = document.getElementById('toggleText');
|
|
|
| let mode = 'login';
|
| let signupAllowed = false;
|
|
|
| const rememberToggle = document.getElementById('rememberToggle');
|
|
|
| function setMode(m) {
|
| mode = m;
|
| errEl.style.display = 'none';
|
| if (m === 'setup') {
|
| setupNote.textContent = 'First-time setup — create your admin account';
|
| setupNote.style.display = 'block';
|
| confirmGroup.style.display = 'block';
|
| submitBtn.textContent = 'Create Admin Account';
|
| toggleArea.style.display = 'none';
|
| rememberToggle.style.display = 'none';
|
| } else if (m === 'signup') {
|
| setupNote.style.display = 'none';
|
| confirmGroup.style.display = 'block';
|
| submitBtn.innerHTML = '<span style="position:relative;top:1px;">Create Account</span>';
|
| toggleArea.style.display = 'block';
|
| toggleText.textContent = 'Already have an account? ';
|
| toggleLink.textContent = 'Sign in';
|
| rememberToggle.style.display = 'none';
|
| } else {
|
| setupNote.style.display = 'none';
|
| confirmGroup.style.display = 'none';
|
| submitBtn.textContent = 'Sign In';
|
| toggleArea.style.display = signupAllowed ? 'block' : 'none';
|
| toggleText.textContent = "Don't have an account? ";
|
| toggleLink.textContent = 'Sign up';
|
| rememberToggle.style.display = '';
|
| }
|
| }
|
|
|
|
|
| try {
|
| const res = await fetch('/api/auth/status', { credentials: 'same-origin' });
|
| const data = await res.json();
|
| if (data.authenticated) {
|
| window.location.replace('/');
|
| return;
|
| }
|
| signupAllowed = !!data.signup_enabled;
|
| if (!data.configured) {
|
| setMode('setup');
|
| } else {
|
| setMode('login');
|
| }
|
| } catch (e) {
|
| setMode('login');
|
| }
|
|
|
| toggleLink.addEventListener('click', (e) => {
|
| e.preventDefault();
|
| setMode(mode === 'login' ? 'signup' : 'login');
|
| });
|
|
|
| form.addEventListener('submit', async (e) => {
|
| e.preventDefault();
|
| errEl.style.display = 'none';
|
| submitBtn.disabled = true;
|
|
|
| const username = document.getElementById('username').value.trim();
|
| const password = document.getElementById('password').value;
|
|
|
|
|
| if (form._totpMode) {
|
| const totpInput = document.getElementById('totp-input');
|
| const code = totpInput ? totpInput.value.trim() : '';
|
| if (!code) { totpInput.focus(); submitBtn.disabled = false; return; }
|
| const remember = document.getElementById('remember').checked;
|
| try {
|
| const res = await fetch('/api/auth/login', {
|
| method: 'POST',
|
| headers: { 'Content-Type': 'application/json' },
|
| credentials: 'same-origin',
|
| body: JSON.stringify({ username, password, remember, totp_code: code })
|
| });
|
| const data = await res.json();
|
| if (!res.ok) throw new Error(data.detail || 'Invalid code');
|
| if (!data.ok) throw new Error('Login failed');
|
| form._totpMode = false;
|
| finishLogin();
|
| } catch (err) {
|
| errEl.textContent = err.message;
|
| errEl.style.display = 'block';
|
| submitBtn.disabled = false;
|
| }
|
| return;
|
| }
|
|
|
|
|
| if (mode === 'setup' || mode === 'signup') {
|
| const confirm = document.getElementById('confirmPassword').value;
|
| if (password !== confirm) {
|
| errEl.textContent = 'Passwords do not match';
|
| errEl.style.display = 'block';
|
| submitBtn.disabled = false;
|
| return;
|
| }
|
| if (password.length < 8) {
|
| errEl.textContent = 'Password must be at least 8 characters';
|
| errEl.style.display = 'block';
|
| submitBtn.disabled = false;
|
| return;
|
| }
|
| }
|
|
|
|
|
| if (mode === 'setup' || mode === 'signup') {
|
| const endpoint = mode === 'setup' ? '/api/auth/setup' : '/api/auth/signup';
|
| try {
|
| const res = await fetch(endpoint, {
|
| method: 'POST',
|
| headers: { 'Content-Type': 'application/json' },
|
| credentials: 'same-origin',
|
| body: JSON.stringify({ username, password })
|
| });
|
| const data = await res.json();
|
| if (!res.ok) throw new Error(data.detail || 'Account creation failed');
|
| } catch (err) {
|
| errEl.textContent = err.message;
|
| errEl.style.display = 'block';
|
| submitBtn.disabled = false;
|
| return;
|
| }
|
| }
|
|
|
|
|
| const remember = document.getElementById('remember').checked;
|
| function finishLogin() {
|
| const _rem = document.getElementById('remember').checked;
|
| if (_rem) localStorage.setItem('odysseus-last-user', username);
|
| else localStorage.removeItem('odysseus-last-user');
|
| sessionStorage.removeItem('ody-session-active');
|
| submitBtn.innerHTML = '<span class="login-spinner" aria-hidden="true"></span>';
|
| submitBtn.disabled = true;
|
| Promise.all([
|
| fetch('/api/sessions', { credentials: 'same-origin' }).then(r => r.json()),
|
| fetch('/api/auth/features', { credentials: 'same-origin' }).then(r => r.json()),
|
| fetch('/api/auth/settings', { credentials: 'same-origin' }).then(r => r.json()),
|
| ]).then(([sess, feat, sett]) => {
|
| sessionStorage.setItem('ody-prefetch-sessions', JSON.stringify(sess));
|
| sessionStorage.setItem('ody-prefetch-features', JSON.stringify(feat));
|
| sessionStorage.setItem('ody-prefetch-settings', JSON.stringify(sett));
|
| }).catch(() => {}).finally(() => { window.location.replace('/'); });
|
| }
|
| async function doLogin(totpCode) {
|
| const loginBody = { username, password, remember };
|
| if (totpCode) loginBody.totp_code = totpCode;
|
| const res = await fetch('/api/auth/login', {
|
| method: 'POST',
|
| headers: { 'Content-Type': 'application/json' },
|
| credentials: 'same-origin',
|
| body: JSON.stringify(loginBody)
|
| });
|
| const data = await res.json();
|
| if (!res.ok) throw new Error(data.detail || 'Login failed');
|
| return data;
|
| }
|
| try {
|
| let data = await doLogin();
|
|
|
| if (data.requires_totp) {
|
| submitBtn.disabled = false;
|
|
|
| if (document.getElementById('totp-input')) return;
|
| form._totpMode = true;
|
| const totpWrap = document.createElement('div');
|
| totpWrap.style.cssText = 'margin-top:12px;';
|
| totpWrap.innerHTML = '<label for="totp-input" style="font-size:0.85em;opacity:0.7;display:block;margin-bottom:4px;">2FA Code</label><input type="text" id="totp-input" placeholder="Enter 6-digit code" aria-label="Two-factor authentication code" autocomplete="one-time-code" inputmode="numeric" maxlength="8" style="width:100%;padding:10px 12px;background:var(--bg);color:var(--fg);border:1px solid var(--border);border-radius:8px;font-size:14px;box-sizing:border-box;text-align:center;letter-spacing:4px;">';
|
| const formEl = submitBtn.parentElement;
|
| formEl.insertBefore(totpWrap, submitBtn);
|
| const totpInput = document.getElementById('totp-input');
|
| totpInput.focus();
|
| submitBtn.textContent = 'Verify';
|
| return;
|
| }
|
| finishLogin();
|
| } catch (err) {
|
| errEl.textContent = err.message;
|
| errEl.style.display = 'block';
|
| submitBtn.disabled = false;
|
| return;
|
| }
|
| });
|
|
|
|
|
| const eyeOpen = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>';
|
| const eyeClosed = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><line x1="8" y1="16" x2="16" y2="8"/><line x1="8" y1="8" x2="16" y2="16"/></svg>';
|
|
|
| function wireToggle(btnId, inputId) {
|
| const btn = document.getElementById(btnId);
|
| const inp = document.getElementById(inputId);
|
| if (!btn || !inp) return;
|
| btn.addEventListener('click', (e) => {
|
| e.preventDefault();
|
| const show = inp.type === 'password';
|
| inp.type = show ? 'text' : 'password';
|
| btn.innerHTML = show ? eyeOpen : eyeClosed;
|
| btn.setAttribute('aria-label', show ? 'Hide password' : 'Show password');
|
| inp.focus();
|
| });
|
| }
|
| wireToggle('pwToggle', 'password');
|
| wireToggle('pwToggleConfirm', 'confirmPassword');
|
| })();
|
|
|
|
|
| document.querySelectorAll('#submitBtn, .pw-toggle').forEach(btn => {
|
| btn.addEventListener('touchstart', (e) => { e.preventDefault(); }, { passive: false });
|
| btn.addEventListener('touchend', (e) => {
|
| e.preventDefault();
|
| btn.click();
|
| });
|
| });
|
|
|
|
|
| if (window.visualViewport) {
|
| const card = document.querySelector('.card');
|
| window.visualViewport.addEventListener('resize', () => {
|
| const vvh = window.visualViewport.height;
|
| const wh = window.innerHeight;
|
| if (vvh < wh * 0.8) {
|
| document.body.style.paddingTop = '5vh';
|
|
|
| if (document.activeElement && document.activeElement.tagName === 'INPUT') {
|
| document.activeElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
| }
|
| } else {
|
| document.body.style.paddingTop = '';
|
| }
|
| });
|
| }
|
| </script>
|
| <script type="module" nonce="{{CSP_NONCE}}">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| try {
|
| const tm = await import('/static/js/theme.js');
|
| const pattern = window.__loginBgPattern;
|
| if (pattern && tm.applyBgPattern) tm.applyBgPattern(pattern);
|
| } catch (e) {
|
| console.error('[login-bg] failed:', e);
|
| }
|
| </script>
|
| </body>
|
| </html>
|
|
|