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,6 +20,7 @@ | ||
| 20 | <div class="spacer"></div> | 20 | <div class="spacer"></div> |
| 21 | <div class="watching" id="watchingCount" hidden></div> | 21 | <div class="watching" id="watchingCount" hidden></div> |
| 22 | <button class="topbar-pill" id="muteBtn">🔇 静音</button> | 22 | <button class="topbar-pill" id="muteBtn">🔇 静音</button> |
| 23 | + <button class="topbar-pill" id="authBtn">登录</button> | ||
| 23 | <a class="topbar-pill" href="live.html">AI 短剧频道 →</a> | 24 | <a class="topbar-pill" href="live.html">AI 短剧频道 →</a> |
| 24 | </header> | 25 | </header> |
| 25 | 26 | ||
| @@ -79,6 +80,19 @@ | @@ -79,6 +80,19 @@ | ||
| 79 | </div> | 80 | </div> |
| 80 | </div> | 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 | <script> | 96 | <script> |
| 83 | let wardrobe = []; | 97 | let wardrobe = []; |
| 84 | let leftTab = "wardrobe"; | 98 | let leftTab = "wardrobe"; |
| @@ -169,6 +183,88 @@ function ensureIdentity() { | @@ -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 | muteBtn.addEventListener("click", () => { | 268 | muteBtn.addEventListener("click", () => { |
| 173 | player.muted = !player.muted; | 269 | player.muted = !player.muted; |
| 174 | muteBtn.textContent = player.muted ? "🔇 静音" : "🔈 有声"; | 270 | muteBtn.textContent = player.muted ? "🔇 静音" : "🔈 有声"; |
live.css
| @@ -445,6 +445,21 @@ body { | @@ -445,6 +445,21 @@ body { | ||
| 445 | } | 445 | } |
| 446 | .nickname-box button:active { transform: translate(2px, 2px); box-shadow: 2px 2px 0 #000; } | 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 | /* ---- mobile sub-tabs (live mode only) ---- */ | 463 | /* ---- mobile sub-tabs (live mode only) ---- */ |
| 449 | .mobile-subtabs { | 464 | .mobile-subtabs { |
| 450 | display: none; | 465 | display: none; |
live.html
| @@ -22,6 +22,7 @@ | @@ -22,6 +22,7 @@ | ||
| 22 | <div class="watching" id="watchingCount" hidden></div> | 22 | <div class="watching" id="watchingCount" hidden></div> |
| 23 | <button class="topbar-pill" id="chatToggleBtn">✕ 聊天</button> | 23 | <button class="topbar-pill" id="chatToggleBtn">✕ 聊天</button> |
| 24 | <button class="topbar-pill" id="muteBtn">🔈 有声</button> | 24 | <button class="topbar-pill" id="muteBtn">🔈 有声</button> |
| 25 | + <button class="topbar-pill" id="authBtn">登录</button> | ||
| 25 | <a class="topbar-pill" href="dance.html">换装跳舞 →</a> | 26 | <a class="topbar-pill" href="dance.html">换装跳舞 →</a> |
| 26 | </header> | 27 | </header> |
| 27 | 28 | ||
| @@ -83,6 +84,19 @@ | @@ -83,6 +84,19 @@ | ||
| 83 | </div> | 84 | </div> |
| 84 | </div> | 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 | <script> | 100 | <script> |
| 87 | let mode = "live"; | 101 | let mode = "live"; |
| 88 | let clips = []; // browse mode: full list | 102 | let clips = []; // browse mode: full list |
| @@ -247,6 +261,89 @@ chatInput.addEventListener("focus", () => ensureIdentity()); | @@ -247,6 +261,89 @@ chatInput.addEventListener("focus", () => ensureIdentity()); | ||
| 247 | 261 | ||
| 248 | updateNicknameLabel(); | 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 | function renderBrowseList() { | 347 | function renderBrowseList() { |
| 251 | if (clips.length === 0) { | 348 | if (clips.length === 0) { |
| 252 | clipList.innerHTML = `<div class="section-head">片段列表</div><div class="empty-hint">片段库为空</div>`; | 349 | clipList.innerHTML = `<div class="section-head">片段列表</div><div class="empty-hint">片段库为空</div>`; |