Commit af536bdd9cb1ec274ee4697492beb73d303b311d
1 parent
3ef2f7b0
Add T22: login/register UI on live.html and dance.html
Reuses the existing nickname-modal/.nickname-box comic-panel look (a new .auth-error line and a plain-text auth-link-btn are the only new CSS) rather than inventing a second visual language. A "登录" topbar button opens the modal (email/password, toggle between login/ register, cancel); once logged in it becomes "<nickname or email prefix> · 退出". Entirely optional, same as T19-T21 designed it: the modal's only trigger is that one button, nothing forces it open, and free browsing/ voting keeps working on the plain viewer_uid cookie exactly as before. Manually verified end to end against the real running server (Chrome): register auto-logs in, an existing viewer_nickname cookie bound onto the new account and shown in the button, logout reverts the UI. auth-ui.test.js is a structural check (id/getElementById consistency, all four /api/auth/* endpoints wired, modal starts hidden) since no DOM/browser tooling is available to `node --test` here to render the pages for a full interaction test. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Deddn3sM3oQnmvqWSMb4y
Showing
4 changed files
with
250 additions
and
0 deletions
auth-ui.test.js
0 → 100644
| 1 | +// Structural check for T22's login/register UI in live.html and dance.html | |
| 2 | +// (dev-plan-3.md). No browser/DOM tooling is available to `node --test` | |
| 3 | +// here, so this can't render the pages — but it catches the easy mistake | |
| 4 | +// in this style of plain-HTML+inline-JS codebase: an element id renamed in | |
| 5 | +// the markup without updating the matching getElementById() call, which | |
| 6 | +// would otherwise fail silently in a real browser (getElementById returns | |
| 7 | +// null, then the first click throws and nothing visibly explains why). | |
| 8 | +// The actual end-to-end behavior (register/login/logout, nickname binding | |
| 9 | +// from an existing viewer_uid cookie) was verified manually in a real | |
| 10 | +// browser against the running server; see the session notes. | |
| 11 | +const test = require("node:test"); | |
| 12 | +const assert = require("node:assert/strict"); | |
| 13 | +const fs = require("node:fs"); | |
| 14 | +const path = require("node:path"); | |
| 15 | + | |
| 16 | +const AUTH_IDS = ["authBtn", "authModal", "authTitle", "authEmail", "authPassword", "authError", "authSubmitBtn", "authToggleBtn", "authCloseBtn"]; | |
| 17 | +// register/login share one call site (`` `/api/auth/${authMode}` ``, authMode | |
| 18 | +// is "register" or "login"), so check for that template plus the two literal | |
| 19 | +// endpoints separately, rather than four literal path strings. | |
| 20 | +const AUTH_ENDPOINTS = ["/api/auth/${authMode}", "/api/auth/logout", "/api/auth/me"]; | |
| 21 | + | |
| 22 | +for (const page of ["live.html", "dance.html"]) { | |
| 23 | + test(`${page}: every auth element id used in the script is actually declared in the markup`, () => { | |
| 24 | + const html = fs.readFileSync(path.join(__dirname, page), "utf8"); | |
| 25 | + for (const id of AUTH_IDS) { | |
| 26 | + assert.match(html, new RegExp(`id="${id}"`), `markup is missing id="${id}"`); | |
| 27 | + assert.match(html, new RegExp(`getElementById\\("${id}"\\)`), `script never looks up #${id}`); | |
| 28 | + } | |
| 29 | + }); | |
| 30 | + | |
| 31 | + test(`${page}: wires up all four auth endpoints`, () => { | |
| 32 | + const html = fs.readFileSync(path.join(__dirname, page), "utf8"); | |
| 33 | + for (const endpoint of AUTH_ENDPOINTS) { | |
| 34 | + assert.ok(html.includes(endpoint), `script never calls ${endpoint}`); | |
| 35 | + } | |
| 36 | + }); | |
| 37 | + | |
| 38 | + test(`${page}: the auth modal is hidden by default (must not force a login gate)`, () => { | |
| 39 | + const html = fs.readFileSync(path.join(__dirname, page), "utf8"); | |
| 40 | + assert.match(html, /<div class="nickname-modal" id="authModal" hidden>/, "authModal must start hidden — logging in is optional, never a forced gate"); | |
| 41 | + }); | |
| 42 | +} | ... | ... |
dance.html
| ... | ... | @@ -20,6 +20,7 @@ |
| 20 | 20 | <div class="spacer"></div> |
| 21 | 21 | <div class="watching" id="watchingCount" hidden></div> |
| 22 | 22 | <button class="topbar-pill" id="muteBtn">🔇 静音</button> |
| 23 | + <button class="topbar-pill" id="authBtn">登录</button> | |
| 23 | 24 | <a class="topbar-pill" href="live.html">AI 短剧频道 →</a> |
| 24 | 25 | </header> |
| 25 | 26 | |
| ... | ... | @@ -79,6 +80,19 @@ |
| 79 | 80 | </div> |
| 80 | 81 | </div> |
| 81 | 82 | |
| 83 | +<!-- 登录完全可选,不阻塞免费模式一的任何现有流程(dev-plan-3.md T22) --> | |
| 84 | +<div class="nickname-modal" id="authModal" hidden> | |
| 85 | + <div class="nickname-box"> | |
| 86 | + <div class="nickname-title" id="authTitle">登录</div> | |
| 87 | + <input type="email" id="authEmail" placeholder="邮箱" /> | |
| 88 | + <input type="password" id="authPassword" placeholder="密码(至少8位)" /> | |
| 89 | + <div class="auth-error" id="authError" hidden></div> | |
| 90 | + <button id="authSubmitBtn">登录</button> | |
| 91 | + <button class="auth-link-btn" id="authToggleBtn">没有账号?去注册</button> | |
| 92 | + <button class="auth-link-btn" id="authCloseBtn">取消</button> | |
| 93 | + </div> | |
| 94 | +</div> | |
| 95 | + | |
| 82 | 96 | <script> |
| 83 | 97 | let wardrobe = []; |
| 84 | 98 | let leftTab = "wardrobe"; |
| ... | ... | @@ -169,6 +183,88 @@ function ensureIdentity() { |
| 169 | 183 | }); |
| 170 | 184 | } |
| 171 | 185 | |
| 186 | +// ---- account login (dev-plan-3.md T19-T22) — entirely optional. Free | |
| 187 | +// wardrobe picks/voting keep using the plain viewer_uid cookie above | |
| 188 | +// exactly as before; logging in just also ties that identity to an | |
| 189 | +// account server-side (T21) and, on login, re-syncs viewer_uid/ | |
| 190 | +// viewer_nickname onto this browser so switching devices or clearing | |
| 191 | +// cookies doesn't silently start a fresh anonymous identity. | |
| 192 | +const authBtn = document.getElementById("authBtn"); | |
| 193 | +const authModal = document.getElementById("authModal"); | |
| 194 | +const authTitle = document.getElementById("authTitle"); | |
| 195 | +const authEmail = document.getElementById("authEmail"); | |
| 196 | +const authPassword = document.getElementById("authPassword"); | |
| 197 | +const authError = document.getElementById("authError"); | |
| 198 | +const authSubmitBtn = document.getElementById("authSubmitBtn"); | |
| 199 | +const authToggleBtn = document.getElementById("authToggleBtn"); | |
| 200 | +const authCloseBtn = document.getElementById("authCloseBtn"); | |
| 201 | +let authMode = "login"; | |
| 202 | + | |
| 203 | +function renderAuthButton(user) { | |
| 204 | + authBtn.textContent = user ? `${user.nickname || user.email.split("@")[0]} · 退出` : "登录"; | |
| 205 | + authBtn.dataset.loggedIn = user ? "1" : ""; | |
| 206 | +} | |
| 207 | + | |
| 208 | +async function refreshAuthState() { | |
| 209 | + try { | |
| 210 | + const res = await fetch("/api/auth/me"); | |
| 211 | + if (res.ok) { | |
| 212 | + renderAuthButton(await res.json()); | |
| 213 | + return; | |
| 214 | + } | |
| 215 | + } catch {} | |
| 216 | + renderAuthButton(null); | |
| 217 | +} | |
| 218 | + | |
| 219 | +function openAuthModal(mode) { | |
| 220 | + authMode = mode; | |
| 221 | + authTitle.textContent = mode === "login" ? "登录" : "注册"; | |
| 222 | + authSubmitBtn.textContent = mode === "login" ? "登录" : "注册"; | |
| 223 | + authToggleBtn.textContent = mode === "login" ? "没有账号?去注册" : "已有账号?去登录"; | |
| 224 | + authError.hidden = true; | |
| 225 | + authEmail.value = ""; | |
| 226 | + authPassword.value = ""; | |
| 227 | + authModal.hidden = false; | |
| 228 | + authEmail.focus(); | |
| 229 | +} | |
| 230 | + | |
| 231 | +authBtn.addEventListener("click", async () => { | |
| 232 | + if (authBtn.dataset.loggedIn) { | |
| 233 | + await fetch("/api/auth/logout", { method: "POST" }); | |
| 234 | + await refreshAuthState(); | |
| 235 | + return; | |
| 236 | + } | |
| 237 | + openAuthModal("login"); | |
| 238 | +}); | |
| 239 | +authToggleBtn.addEventListener("click", () => openAuthModal(authMode === "login" ? "register" : "login")); | |
| 240 | +authCloseBtn.addEventListener("click", () => { | |
| 241 | + authModal.hidden = true; | |
| 242 | +}); | |
| 243 | +async function submitAuth() { | |
| 244 | + const email = authEmail.value.trim(); | |
| 245 | + const password = authPassword.value; | |
| 246 | + authError.hidden = true; | |
| 247 | + const res = await fetch(`/api/auth/${authMode}`, { | |
| 248 | + method: "POST", | |
| 249 | + headers: { "Content-Type": "application/json" }, | |
| 250 | + body: JSON.stringify({ email, password }), | |
| 251 | + }); | |
| 252 | + const body = await res.json().catch(() => ({})); | |
| 253 | + if (!res.ok) { | |
| 254 | + authError.textContent = body.error || "出错了,请重试"; | |
| 255 | + authError.hidden = false; | |
| 256 | + return; | |
| 257 | + } | |
| 258 | + authModal.hidden = true; | |
| 259 | + await refreshAuthState(); | |
| 260 | +} | |
| 261 | +authSubmitBtn.addEventListener("click", submitAuth); | |
| 262 | +authPassword.addEventListener("keydown", (e) => { | |
| 263 | + if (e.key === "Enter") submitAuth(); | |
| 264 | +}); | |
| 265 | + | |
| 266 | +refreshAuthState(); | |
| 267 | + | |
| 172 | 268 | muteBtn.addEventListener("click", () => { |
| 173 | 269 | player.muted = !player.muted; |
| 174 | 270 | muteBtn.textContent = player.muted ? "🔇 静音" : "🔈 有声"; | ... | ... |
live.css
| ... | ... | @@ -445,6 +445,21 @@ body { |
| 445 | 445 | } |
| 446 | 446 | .nickname-box button:active { transform: translate(2px, 2px); box-shadow: 2px 2px 0 #000; } |
| 447 | 447 | |
| 448 | +/* ---- account login modal (dev-plan-3.md T22) — reuses .nickname-modal/ | |
| 449 | + .nickname-box, just two extra bits: an error line and a plain-text | |
| 450 | + toggle/cancel button instead of the full comic-box button look. ---- */ | |
| 451 | +.auth-error { color: #c0392b; font: bold 13px Tahoma, sans-serif; } | |
| 452 | +.nickname-box button.auth-link-btn { | |
| 453 | + background: none; | |
| 454 | + border: none; | |
| 455 | + box-shadow: none; | |
| 456 | + text-decoration: underline; | |
| 457 | + padding: 0; | |
| 458 | + font: bold 13px Tahoma, sans-serif; | |
| 459 | + cursor: pointer; | |
| 460 | +} | |
| 461 | +.nickname-box button.auth-link-btn:active { transform: none; } | |
| 462 | + | |
| 448 | 463 | /* ---- mobile sub-tabs (live mode only) ---- */ |
| 449 | 464 | .mobile-subtabs { |
| 450 | 465 | display: none; | ... | ... |
live.html
| ... | ... | @@ -22,6 +22,7 @@ |
| 22 | 22 | <div class="watching" id="watchingCount" hidden></div> |
| 23 | 23 | <button class="topbar-pill" id="chatToggleBtn">✕ 聊天</button> |
| 24 | 24 | <button class="topbar-pill" id="muteBtn">🔈 有声</button> |
| 25 | + <button class="topbar-pill" id="authBtn">登录</button> | |
| 25 | 26 | <a class="topbar-pill" href="dance.html">换装跳舞 →</a> |
| 26 | 27 | </header> |
| 27 | 28 | |
| ... | ... | @@ -83,6 +84,19 @@ |
| 83 | 84 | </div> |
| 84 | 85 | </div> |
| 85 | 86 | |
| 87 | +<!-- 登录完全可选,不阻塞免费模式一的任何现有流程(dev-plan-3.md T22) --> | |
| 88 | +<div class="nickname-modal" id="authModal" hidden> | |
| 89 | + <div class="nickname-box"> | |
| 90 | + <div class="nickname-title" id="authTitle">登录</div> | |
| 91 | + <input type="email" id="authEmail" placeholder="邮箱" /> | |
| 92 | + <input type="password" id="authPassword" placeholder="密码(至少8位)" /> | |
| 93 | + <div class="auth-error" id="authError" hidden></div> | |
| 94 | + <button id="authSubmitBtn">登录</button> | |
| 95 | + <button class="auth-link-btn" id="authToggleBtn">没有账号?去注册</button> | |
| 96 | + <button class="auth-link-btn" id="authCloseBtn">取消</button> | |
| 97 | + </div> | |
| 98 | +</div> | |
| 99 | + | |
| 86 | 100 | <script> |
| 87 | 101 | let mode = "live"; |
| 88 | 102 | let clips = []; // browse mode: full list |
| ... | ... | @@ -247,6 +261,89 @@ chatInput.addEventListener("focus", () => ensureIdentity()); |
| 247 | 261 | |
| 248 | 262 | updateNicknameLabel(); |
| 249 | 263 | |
| 264 | +// ---- account login (dev-plan-3.md T19-T22) — entirely optional. Free | |
| 265 | +// chat/voting keeps using the plain viewer_uid cookie above exactly as | |
| 266 | +// before; logging in just also ties that identity to an account | |
| 267 | +// server-side (T21) and, on login, re-syncs viewer_uid/viewer_nickname | |
| 268 | +// onto this browser so switching devices or clearing cookies doesn't | |
| 269 | +// silently start a fresh anonymous identity. | |
| 270 | +const authBtn = document.getElementById("authBtn"); | |
| 271 | +const authModal = document.getElementById("authModal"); | |
| 272 | +const authTitle = document.getElementById("authTitle"); | |
| 273 | +const authEmail = document.getElementById("authEmail"); | |
| 274 | +const authPassword = document.getElementById("authPassword"); | |
| 275 | +const authError = document.getElementById("authError"); | |
| 276 | +const authSubmitBtn = document.getElementById("authSubmitBtn"); | |
| 277 | +const authToggleBtn = document.getElementById("authToggleBtn"); | |
| 278 | +const authCloseBtn = document.getElementById("authCloseBtn"); | |
| 279 | +let authMode = "login"; | |
| 280 | + | |
| 281 | +function renderAuthButton(user) { | |
| 282 | + authBtn.textContent = user ? `${user.nickname || user.email.split("@")[0]} · 退出` : "登录"; | |
| 283 | + authBtn.dataset.loggedIn = user ? "1" : ""; | |
| 284 | +} | |
| 285 | + | |
| 286 | +async function refreshAuthState() { | |
| 287 | + try { | |
| 288 | + const res = await fetch("/api/auth/me"); | |
| 289 | + if (res.ok) { | |
| 290 | + renderAuthButton(await res.json()); | |
| 291 | + updateNicknameLabel(); // login may have just re-synced viewer_uid/nickname | |
| 292 | + return; | |
| 293 | + } | |
| 294 | + } catch {} | |
| 295 | + renderAuthButton(null); | |
| 296 | +} | |
| 297 | + | |
| 298 | +function openAuthModal(mode) { | |
| 299 | + authMode = mode; | |
| 300 | + authTitle.textContent = mode === "login" ? "登录" : "注册"; | |
| 301 | + authSubmitBtn.textContent = mode === "login" ? "登录" : "注册"; | |
| 302 | + authToggleBtn.textContent = mode === "login" ? "没有账号?去注册" : "已有账号?去登录"; | |
| 303 | + authError.hidden = true; | |
| 304 | + authEmail.value = ""; | |
| 305 | + authPassword.value = ""; | |
| 306 | + authModal.hidden = false; | |
| 307 | + authEmail.focus(); | |
| 308 | +} | |
| 309 | + | |
| 310 | +authBtn.addEventListener("click", async () => { | |
| 311 | + if (authBtn.dataset.loggedIn) { | |
| 312 | + await fetch("/api/auth/logout", { method: "POST" }); | |
| 313 | + await refreshAuthState(); | |
| 314 | + return; | |
| 315 | + } | |
| 316 | + openAuthModal("login"); | |
| 317 | +}); | |
| 318 | +authToggleBtn.addEventListener("click", () => openAuthModal(authMode === "login" ? "register" : "login")); | |
| 319 | +authCloseBtn.addEventListener("click", () => { | |
| 320 | + authModal.hidden = true; | |
| 321 | +}); | |
| 322 | +async function submitAuth() { | |
| 323 | + const email = authEmail.value.trim(); | |
| 324 | + const password = authPassword.value; | |
| 325 | + authError.hidden = true; | |
| 326 | + const res = await fetch(`/api/auth/${authMode}`, { | |
| 327 | + method: "POST", | |
| 328 | + headers: { "Content-Type": "application/json" }, | |
| 329 | + body: JSON.stringify({ email, password }), | |
| 330 | + }); | |
| 331 | + const body = await res.json().catch(() => ({})); | |
| 332 | + if (!res.ok) { | |
| 333 | + authError.textContent = body.error || "出错了,请重试"; | |
| 334 | + authError.hidden = false; | |
| 335 | + return; | |
| 336 | + } | |
| 337 | + authModal.hidden = true; | |
| 338 | + await refreshAuthState(); | |
| 339 | +} | |
| 340 | +authSubmitBtn.addEventListener("click", submitAuth); | |
| 341 | +authPassword.addEventListener("keydown", (e) => { | |
| 342 | + if (e.key === "Enter") submitAuth(); | |
| 343 | +}); | |
| 344 | + | |
| 345 | +refreshAuthState(); | |
| 346 | + | |
| 250 | 347 | function renderBrowseList() { |
| 251 | 348 | if (clips.length === 0) { |
| 252 | 349 | clipList.innerHTML = `<div class="section-head">片段列表</div><div class="empty-hint">片段库为空</div>`; | ... | ... |