Commit af536bdd9cb1ec274ee4697492beb73d303b311d

Authored by yibo
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
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(&quot;focus&quot;, () =&gt; ensureIdentity()); @@ -247,6 +261,89 @@ chatInput.addEventListener(&quot;focus&quot;, () =&gt; 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>`;