yana-web 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,474 @@
1
+ <!DOCTYPE html>
2
+ <html lang="vi">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover" />
6
+ <link rel="icon" type="image/png" href="/logo.png" />
7
+ <title>Yana — Đăng nhập</title>
8
+ <link rel="preconnect" href="https://fonts.bunny.net" crossorigin />
9
+ <link href="https://fonts.bunny.net/css?family=be-vietnam-pro:300,400,500,600&display=swap" rel="stylesheet" />
10
+ <style>
11
+ :root {
12
+ --primary: #2f7e6e;
13
+ --ink: #1d3530; --ink-2: #44615a; --ink-3: #7a948d;
14
+ --border: rgba(31, 70, 60, .14);
15
+ --danger: #b3503e;
16
+ }
17
+ * { box-sizing: border-box; }
18
+ html, body { height: 100%; }
19
+ body {
20
+ margin: 0; display: grid; place-items: center; overflow: hidden;
21
+ font-family: "Be Vietnam Pro", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
22
+ background: linear-gradient(160deg, #f6faf7 30%, #ddeee7 100%);
23
+ color: var(--ink);
24
+ }
25
+
26
+ /* ── Aurora backdrop — slow drifting blobs ── */
27
+ .aurora { position: fixed; inset: 0; pointer-events: none; filter: blur(70px); }
28
+ .blob { position: absolute; border-radius: 50%; opacity: .5; animation: drift 26s ease-in-out infinite alternate; }
29
+ .b1 { width: 46vw; height: 46vw; left: -10vw; top: -16vh; background: radial-gradient(circle, rgba(122,184,168,.8), transparent 65%); }
30
+ .b2 { width: 38vw; height: 38vw; right: -8vw; bottom: -14vh; background: radial-gradient(circle, rgba(86,148,159,.7), transparent 65%); animation-delay: -9s; }
31
+ .b3 { width: 30vw; height: 30vw; right: 16vw; top: -10vh; background: radial-gradient(circle, rgba(236,196,134,.55), transparent 65%); animation-delay: -17s; }
32
+ @keyframes drift {
33
+ from { transform: translate(0, 0) scale(1); }
34
+ to { transform: translate(5vw, 6vh) scale(1.12); }
35
+ }
36
+ @media (prefers-reduced-motion: reduce) { .blob, .mark { animation: none !important; } }
37
+
38
+ /* ── Card ── */
39
+ .card {
40
+ position: relative; width: min(92vw, 400px); padding: 40px 36px 28px;
41
+ border-radius: 26px; text-align: center;
42
+ background: rgba(255,255,255,.58);
43
+ backdrop-filter: blur(22px) saturate(1.3);
44
+ -webkit-backdrop-filter: blur(22px) saturate(1.3);
45
+ border: 1px solid rgba(255,255,255,.7);
46
+ box-shadow: 0 24px 70px rgba(31,70,60,.16), inset 0 1px 0 rgba(255,255,255,.75);
47
+ animation: rise .55s cubic-bezier(.21,.8,.32,1) both;
48
+ }
49
+ @keyframes rise { from { opacity: 0; transform: translateY(16px) scale(.985); } }
50
+
51
+ /* Language toggle — top-right of the card */
52
+ .lang {
53
+ position: absolute; top: 14px; right: 14px;
54
+ display: flex; gap: 2px; padding: 3px;
55
+ background: rgba(31,70,60,.06); border-radius: 99px;
56
+ }
57
+ .lang button {
58
+ border: none; cursor: pointer; font-family: inherit;
59
+ font-size: 10.5px; font-weight: 600; letter-spacing: .03em;
60
+ padding: 3px 9px; border-radius: 99px; color: var(--ink-3);
61
+ background: transparent; transition: background .15s, color .15s;
62
+ }
63
+ .lang button.on { background: rgba(255,255,255,.9); color: var(--ink); box-shadow: 0 1px 3px rgba(31,70,60,.12); }
64
+
65
+ .mark {
66
+ width: 64px; height: 64px; margin: 0 auto 20px; border-radius: 21px;
67
+ display: grid; place-items: center; position: relative;
68
+ background: linear-gradient(150deg, color-mix(in oklab, var(--primary) 92%, white), color-mix(in oklab, var(--primary) 72%, #1d3530));
69
+ box-shadow: inset 0 1px 0 rgba(255,255,255,.4), 0 8px 24px color-mix(in oklab, var(--primary) 30%, transparent);
70
+ animation: breathe 5s ease-in-out infinite;
71
+ }
72
+ @keyframes breathe {
73
+ 0%, 100% { transform: scale(1); }
74
+ 50% { transform: scale(1.035); }
75
+ }
76
+
77
+ h1 { margin: 0 0 6px; font-size: 22px; font-weight: 600; letter-spacing: -0.015em; }
78
+ .sub { margin: 0 0 24px; font-size: 13px; color: var(--ink-3); line-height: 1.55; }
79
+
80
+ form { display: flex; flex-direction: column; gap: 12px; text-align: left; }
81
+ .field { position: relative; }
82
+ .field input[type="password"], .field input[type="text"] {
83
+ width: 100%; padding: 13px 46px 13px 16px; font-size: 14.5px; font-family: inherit;
84
+ border-radius: 14px; border: 1px solid var(--border); outline: none;
85
+ background: rgba(255,255,255,.75); color: var(--ink);
86
+ transition: border-color .15s, box-shadow .15s;
87
+ }
88
+ .field input:focus {
89
+ border-color: var(--primary);
90
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--primary) 15%, transparent);
91
+ }
92
+ .field input.bad { border-color: var(--danger); }
93
+ .field input.good { border-color: var(--primary); }
94
+ /* IME-friendly masking: browsers disable Vietnamese IME (Telex) inside
95
+ type="password", so supported engines use type="text" + CSS masking */
96
+ .field input.masked { -webkit-text-security: disc; }
97
+ .eye {
98
+ position: absolute; right: 6px; top: 50%; transform: translateY(-50%);
99
+ width: 34px; height: 34px; display: grid; place-items: center;
100
+ background: none; border: none; cursor: pointer; color: var(--ink-3); border-radius: 10px;
101
+ }
102
+ .eye:hover { color: var(--ink-2); }
103
+ .match {
104
+ position: absolute; right: 16px; top: 50%; transform: translateY(-50%);
105
+ font-size: 12px; pointer-events: none; font-weight: 500;
106
+ }
107
+ .match.ok { color: var(--primary); }
108
+ .match.no { color: var(--danger); }
109
+
110
+ /* Password strength (setup mode) */
111
+ .strength { display: none; margin-top: -4px; }
112
+ .strength.show { display: block; }
113
+ .strength .track { height: 4px; border-radius: 99px; background: rgba(31,70,60,.1); overflow: hidden; }
114
+ .strength .fill { height: 100%; width: 0; border-radius: 99px; transition: width .25s, background .25s; }
115
+ .strength .label { font-size: 11px; color: var(--ink-3); margin-top: 4px; min-height: 1.2em; }
116
+
117
+ /* Caps Lock hint */
118
+ .caps { display: none; font-size: 11.5px; color: var(--danger); margin-top: -4px; }
119
+ .caps.show { display: flex; align-items: center; gap: 5px; }
120
+
121
+ /* Remember + forgot row */
122
+ .row { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: -2px; }
123
+ .check { display: flex; align-items: center; gap: 7px; cursor: pointer; font-size: 12.5px; color: var(--ink-2); user-select: none; }
124
+ .check input { accent-color: var(--primary); width: 15px; height: 15px; cursor: pointer; }
125
+ .link { background: none; border: none; padding: 0; cursor: pointer; font-family: inherit; font-size: 12.5px; color: var(--primary); }
126
+ .link:hover { text-decoration: underline; }
127
+
128
+ .help {
129
+ display: none; margin-top: 2px; padding: 10px 13px; border-radius: 12px; text-align: left;
130
+ background: rgba(31,70,60,.05); border: 1px solid var(--border);
131
+ font-size: 12px; color: var(--ink-2); line-height: 1.6;
132
+ }
133
+ .help.show { display: block; }
134
+ .help code { font-size: 11px; background: rgba(255,255,255,.8); padding: 1px 5px; border-radius: 5px; }
135
+
136
+ button.cta {
137
+ width: 100%; padding: 13px; margin-top: 2px;
138
+ font-size: 14.5px; font-weight: 500; font-family: inherit;
139
+ border: none; border-radius: 14px; cursor: pointer; color: white;
140
+ background: var(--primary);
141
+ box-shadow: 0 8px 22px color-mix(in oklab, var(--primary) 35%, transparent);
142
+ transition: transform .12s, opacity .15s, box-shadow .2s;
143
+ display: flex; align-items: center; justify-content: center; gap: 9px;
144
+ }
145
+ button.cta:hover { box-shadow: 0 10px 28px color-mix(in oklab, var(--primary) 45%, transparent); }
146
+ button.cta:active { transform: translateY(1px); }
147
+ button.cta:disabled { opacity: .6; cursor: progress; }
148
+
149
+ .spinner {
150
+ width: 15px; height: 15px; border-radius: 50%; display: none;
151
+ border: 2px solid rgba(255,255,255,.4); border-top-color: white;
152
+ animation: spin .7s linear infinite;
153
+ }
154
+ @keyframes spin { to { transform: rotate(360deg); } }
155
+ .loading .spinner { display: inline-block; }
156
+
157
+ .msg { min-height: 1.35em; margin: 14px 0 0; font-size: 12.5px; }
158
+ .msg.err { color: var(--danger); animation: shake .3s; }
159
+ .msg.ok { color: var(--primary); }
160
+ @keyframes shake { 25% { transform: translateX(-4px); } 75% { transform: translateX(4px); } }
161
+
162
+ .foot {
163
+ margin-top: 22px; padding-top: 16px; border-top: 1px solid var(--border);
164
+ display: flex; align-items: center; justify-content: center; gap: 7px;
165
+ font-size: 11px; color: var(--ink-3); letter-spacing: .05em;
166
+ }
167
+ .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--primary); box-shadow: 0 0 6px var(--primary); }
168
+ </style>
169
+ </head>
170
+ <body>
171
+ <div class="aurora"><span class="blob b1"></span><span class="blob b2"></span><span class="blob b3"></span></div>
172
+
173
+ <main class="card">
174
+ <div class="lang" role="group" aria-label="Language">
175
+ <button type="button" id="lang-vi" class="on">VI</button>
176
+ <button type="button" id="lang-en">EN</button>
177
+ </div>
178
+
179
+ <div class="mark" aria-label="Yana">
180
+ <img src="/logo.png" alt="" width="38" height="38" style="display:block" />
181
+ </div>
182
+
183
+ <h1 id="title">Chào mừng trở lại</h1>
184
+ <p class="sub" id="sub">Đăng nhập để tiếp tục với Yana.</p>
185
+
186
+ <form id="form">
187
+ <div class="field" id="username-wrap">
188
+ <input type="text" id="username" placeholder="Tên tài khoản" required minlength="2" maxlength="32"
189
+ autocomplete="username" autocapitalize="off" spellcheck="false" autofocus />
190
+ </div>
191
+
192
+ <div class="field">
193
+ <input type="password" id="password" placeholder="Mật khẩu" required minlength="6"
194
+ autocomplete="current-password" />
195
+ <button type="button" class="eye" id="eye" aria-label="Hiện mật khẩu" tabindex="-1">
196
+ <svg id="eye-open" width="17" height="17" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
197
+ <path d="M2.5 10S5.5 4.8 10 4.8 17.5 10 17.5 10 14.5 15.2 10 15.2 2.5 10 2.5 10Z"/><circle cx="10" cy="10" r="2.6"/>
198
+ </svg>
199
+ <svg id="eye-closed" width="17" height="17" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" style="display:none">
200
+ <path d="M3.5 8.5c1.8 2 4 3.2 6.5 3.2s4.7-1.2 6.5-3.2M6 11l-1 2.2M10 11.9V14m4-3 1 2.2"/>
201
+ </svg>
202
+ </button>
203
+ </div>
204
+
205
+ <div class="caps" id="caps">⚠ <span id="caps-text">Caps Lock đang bật</span></div>
206
+
207
+ <div class="strength" id="strength">
208
+ <div class="track"><div class="fill" id="strength-fill"></div></div>
209
+ <div class="label" id="strength-label"></div>
210
+ </div>
211
+
212
+ <div class="field" id="confirm-wrap" style="display:none">
213
+ <input type="password" id="confirm" placeholder="Nhập lại mật khẩu" minlength="6" autocomplete="new-password" />
214
+ <span class="match" id="match" aria-live="polite"></span>
215
+ </div>
216
+
217
+ <div class="row" id="login-row" style="display:none">
218
+ <label class="check">
219
+ <input type="checkbox" id="remember" />
220
+ <span id="remember-text">Ghi nhớ 30 ngày</span>
221
+ </label>
222
+ <button type="button" class="link" id="forgot">Quên mật khẩu?</button>
223
+ </div>
224
+
225
+ <div class="help" id="help">
226
+ <span id="help-text">Yana chạy hoàn toàn trên máy bạn — không có email khôi phục.
227
+ Để đặt lại: xóa file <code>tools/yana-web/.yana/auth.json</code> trên máy chủ rồi tải lại trang này.</span>
228
+ </div>
229
+
230
+ <button type="submit" class="cta" id="submit">
231
+ <span class="spinner"></span><span id="cta-text">Tiếp tục</span>
232
+ <svg width="15" height="15" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 10h12m-5-5 5 5-5 5"/></svg>
233
+ </button>
234
+ </form>
235
+
236
+ <p class="msg" id="msg" role="alert"></p>
237
+
238
+ <div class="foot">
239
+ <span class="dot"></span> YAMTAM ENGINE · local &amp; private
240
+ <span style="opacity:.4">·</span>
241
+ <a href="/welcome.html" id="about-link" style="color:var(--primary);text-decoration:none">Giới thiệu Yana</a>
242
+ </div>
243
+ </main>
244
+
245
+ <script>
246
+ 'use strict';
247
+ var mode = 'login'; // 'login' | 'setup'
248
+ var storedUsername = null; // account name from /api/auth/status (login prefill)
249
+ var lang = localStorage.getItem('yana.login.lang') || 'vi';
250
+ var $ = function (id) { return document.getElementById(id); };
251
+
252
+ var T = {
253
+ vi: {
254
+ titleLogin: 'Chào mừng trở lại', subLogin: 'Đăng nhập để tiếp tục với Yana.',
255
+ titleSetup: 'Chào mừng đến Yana', subSetup: 'Lần đầu tiên ở đây — hãy tạo tài khoản cho mặt hồ của bạn.',
256
+ ctaLogin: 'Tiếp tục', ctaSetup: 'Tạo tài khoản',
257
+ username: 'Tên tài khoản',
258
+ password: 'Mật khẩu', confirm: 'Nhập lại mật khẩu',
259
+ remember: 'Ghi nhớ 30 ngày', forgot: 'Quên mật khẩu?',
260
+ help: 'Yana chạy hoàn toàn trên máy bạn — không có email khôi phục. Để đặt lại: xóa file tools/yana-web/.yana/auth.json trên máy chủ rồi tải lại trang này.',
261
+ caps: 'Caps Lock đang bật',
262
+ match: 'Khớp ✓', noMatch: 'Chưa khớp',
263
+ mismatch: 'Mật khẩu nhập lại không khớp.',
264
+ entering: 'Đang vào mặt hồ…', genericErr: 'Có lỗi xảy ra.',
265
+ connErr: 'Lỗi kết nối. Thử lại.', serverErr: 'Không kết nối được máy chủ.',
266
+ wrongPw: 'Sai mật khẩu.', tooMany: 'Thử quá nhiều lần — chờ 15 phút.',
267
+ wrongCred: 'Sai tên tài khoản hoặc mật khẩu.',
268
+ tooShort: 'Mật khẩu cần ít nhất 6 ký tự.',
269
+ nameShort: 'Tên tài khoản cần 2–32 ký tự.',
270
+ sWeak: 'Yếu — thêm ký tự nữa', sFair: 'Tạm ổn', sGood: 'Tốt', sStrong: 'Rất mạnh',
271
+ about: 'Giới thiệu Yana',
272
+ },
273
+ en: {
274
+ titleLogin: 'Welcome back', subLogin: 'Sign in to continue with Yana.',
275
+ titleSetup: 'Welcome to Yana', subSetup: 'First time here — create an account for your lake.',
276
+ ctaLogin: 'Continue', ctaSetup: 'Create account',
277
+ username: 'Account name',
278
+ password: 'Password', confirm: 'Confirm password',
279
+ remember: 'Remember for 30 days', forgot: 'Forgot password?',
280
+ help: 'Yana runs entirely on your machine — there is no recovery email. To reset: delete tools/yana-web/.yana/auth.json on the server, then reload this page.',
281
+ caps: 'Caps Lock is on',
282
+ match: 'Match ✓', noMatch: 'No match',
283
+ mismatch: 'Passwords do not match.',
284
+ entering: 'Entering the lake…', genericErr: 'Something went wrong.',
285
+ connErr: 'Connection error. Try again.', serverErr: 'Cannot reach the server.',
286
+ wrongPw: 'Wrong password.', tooMany: 'Too many attempts — wait 15 minutes.',
287
+ wrongCred: 'Wrong account name or password.',
288
+ tooShort: 'Password must be at least 6 characters.',
289
+ nameShort: 'Account name must be 2–32 characters.',
290
+ sWeak: 'Weak — add more characters', sFair: 'Fair', sGood: 'Good', sStrong: 'Very strong',
291
+ about: 'About Yana',
292
+ },
293
+ };
294
+ function t(k) { return T[lang][k]; }
295
+
296
+ // Vietnamese IME can emit decomposed Unicode (NFD) in one field and
297
+ // composed (NFC) in another — identical on screen, different strings.
298
+ // Normalize before comparing or sending.
299
+ function pw(id) { return $(id).value.normalize('NFC'); }
300
+
301
+ function setMsg(text, ok) {
302
+ $('msg').textContent = text || '';
303
+ $('msg').className = 'msg ' + (text ? (ok ? 'ok' : 'err') : '');
304
+ }
305
+ function setLoading(on) {
306
+ $('submit').disabled = on;
307
+ $('submit').classList.toggle('loading', on);
308
+ }
309
+
310
+ function applyLang() {
311
+ document.documentElement.lang = lang;
312
+ $('lang-vi').classList.toggle('on', lang === 'vi');
313
+ $('lang-en').classList.toggle('on', lang === 'en');
314
+ $('username').placeholder = t('username');
315
+ $('password').placeholder = t('password');
316
+ $('confirm').placeholder = t('confirm');
317
+ $('remember-text').textContent = t('remember');
318
+ $('forgot').textContent = t('forgot');
319
+ $('help-text').textContent = t('help');
320
+ $('caps-text').textContent = t('caps');
321
+ $('about-link').textContent = t('about');
322
+ applyMode();
323
+ }
324
+
325
+ function applyMode() {
326
+ var setup = mode === 'setup';
327
+ $('title').textContent = setup ? t('titleSetup') : t('titleLogin');
328
+ $('sub').textContent = setup ? t('subSetup') : t('subLogin');
329
+ $('cta-text').textContent = setup ? t('ctaSetup') : t('ctaLogin');
330
+ $('confirm-wrap').style.display = setup ? '' : 'none';
331
+ $('confirm').required = setup;
332
+ $('login-row').style.display = setup ? 'none' : 'flex';
333
+ $('strength').classList.toggle('show', setup);
334
+ $('password').autocomplete = setup ? 'new-password' : 'current-password';
335
+ // Accounts from before this upgrade have no stored name — hide the
336
+ // field at sign-in so the legacy owner isn't asked for a name that
337
+ // doesn't exist. Setup mode always shows it.
338
+ var showName = setup || !!storedUsername;
339
+ $('username-wrap').style.display = showName ? '' : 'none';
340
+ $('username').required = showName;
341
+ if (!setup && storedUsername && !$('username').value) {
342
+ $('username').value = storedUsername;
343
+ }
344
+ }
345
+
346
+ $('lang-vi').addEventListener('click', function () { lang = 'vi'; localStorage.setItem('yana.login.lang', lang); applyLang(); });
347
+ $('lang-en').addEventListener('click', function () { lang = 'en'; localStorage.setItem('yana.login.lang', lang); applyLang(); });
348
+
349
+ // Vietnamese IME (Telex) is disabled by browsers inside type="password".
350
+ // Where -webkit-text-security is supported (Chrome/Edge/Safari), switch to
351
+ // type="text" masked by CSS — IME composes normally, dots still shown.
352
+ var MASK_OK = window.CSS && CSS.supports && CSS.supports('-webkit-text-security', 'disc');
353
+ var visible = false;
354
+
355
+ function applyVisibility() {
356
+ ['password', 'confirm'].forEach(function (id) {
357
+ var el = $(id);
358
+ if (MASK_OK) {
359
+ el.type = 'text';
360
+ el.classList.toggle('masked', !visible);
361
+ } else {
362
+ el.type = visible ? 'text' : 'password';
363
+ }
364
+ });
365
+ $('eye-open').style.display = visible ? 'none' : '';
366
+ $('eye-closed').style.display = visible ? '' : 'none';
367
+ }
368
+ applyVisibility();
369
+
370
+ // Show/hide password — applies to both fields so what you compare is what you see
371
+ $('eye').addEventListener('click', function () {
372
+ visible = !visible;
373
+ applyVisibility();
374
+ $('password').focus();
375
+ });
376
+
377
+ // Caps Lock hint
378
+ function capsCheck(e) {
379
+ if (typeof e.getModifierState !== 'function') return;
380
+ $('caps').classList.toggle('show', e.getModifierState('CapsLock'));
381
+ }
382
+ $('password').addEventListener('keyup', capsCheck);
383
+ $('confirm').addEventListener('keyup', capsCheck);
384
+
385
+ // Password strength (setup mode): length + variety heuristic
386
+ function strength(v) {
387
+ var score = 0;
388
+ if (v.length >= 6) score++;
389
+ if (v.length >= 10) score++;
390
+ if (/[A-ZÀ-Ỹ]/.test(v) && /[a-zà-ỹ]/.test(v)) score++;
391
+ if (/\d/.test(v)) score++;
392
+ if (/[^A-Za-z0-9À-ỹ]/.test(v)) score++;
393
+ return Math.min(score, 4);
394
+ }
395
+ var S_COLORS = ['#b3503e', '#b3503e', '#b78f3d', '#6f8f5a', '#2f7e6e'];
396
+ function updateStrength() {
397
+ if (mode !== 'setup') return;
398
+ var v = pw('password');
399
+ var s = v ? strength(v) : 0;
400
+ $('strength-fill').style.width = (s / 4 * 100) + '%';
401
+ $('strength-fill').style.background = S_COLORS[s];
402
+ $('strength-label').textContent = !v ? '' : [t('sWeak'), t('sWeak'), t('sFair'), t('sGood'), t('sStrong')][s];
403
+ }
404
+
405
+ // Live match indicator on the confirm field
406
+ function updateMatch() {
407
+ var c = $('confirm'), m = $('match');
408
+ if (mode !== 'setup' || !c.value) {
409
+ m.textContent = ''; c.classList.remove('good', 'bad'); return;
410
+ }
411
+ var ok = pw('password') === pw('confirm');
412
+ m.textContent = ok ? t('match') : t('noMatch');
413
+ m.className = 'match ' + (ok ? 'ok' : 'no');
414
+ c.classList.toggle('good', ok);
415
+ c.classList.toggle('bad', !ok);
416
+ }
417
+ $('password').addEventListener('input', function () { updateStrength(); updateMatch(); });
418
+ $('confirm').addEventListener('input', updateMatch);
419
+
420
+ // Forgot password — local recovery instructions
421
+ $('forgot').addEventListener('click', function () {
422
+ $('help').classList.toggle('show');
423
+ });
424
+
425
+ fetch('/api/auth/status')
426
+ .then(function (r) { return r.json(); })
427
+ .then(function (d) {
428
+ if (d.authed) { location.replace('/'); return; }
429
+ mode = d.setup ? 'login' : 'setup';
430
+ storedUsername = d.username || null;
431
+ applyLang();
432
+ if (mode === 'login' && storedUsername) $('password').focus();
433
+ })
434
+ .catch(function () { setMsg(t('serverErr')); });
435
+
436
+ function apiError(msg) {
437
+ if (/wrong username/i.test(msg)) return t('wrongCred');
438
+ if (/wrong password/i.test(msg)) return t('wrongPw');
439
+ if (/too many/i.test(msg)) return t('tooMany');
440
+ if (/at least 6/i.test(msg)) return t('tooShort');
441
+ if (/username must/i.test(msg)) return t('nameShort');
442
+ return msg || t('genericErr');
443
+ }
444
+
445
+ $('form').addEventListener('submit', function (e) {
446
+ e.preventDefault();
447
+ var username = $('username').value.normalize('NFC').trim();
448
+ var password = pw('password');
449
+ if (mode === 'setup' && (username.length < 2 || username.length > 32)) {
450
+ setMsg(t('nameShort')); return;
451
+ }
452
+ if (mode === 'setup' && password !== pw('confirm')) {
453
+ setMsg(t('mismatch')); updateMatch(); return;
454
+ }
455
+ setLoading(true);
456
+ setMsg('');
457
+
458
+ fetch(mode === 'setup' ? '/api/auth/setup' : '/api/auth/login', {
459
+ method: 'POST',
460
+ headers: { 'Content-Type': 'application/json' },
461
+ body: JSON.stringify({ username: username, password: password, remember: $('remember').checked }),
462
+ })
463
+ .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
464
+ .then(function (out) {
465
+ if (out.ok) { setMsg(t('entering'), true); location.replace('/'); }
466
+ else { setMsg(apiError(out.d.error)); setLoading(false); }
467
+ })
468
+ .catch(function () { setMsg(t('connErr')); setLoading(false); });
469
+ });
470
+
471
+ applyLang();
472
+ </script>
473
+ </body>
474
+ </html>