fix(frontend): consolidate chat sidebar and modal auth flow
This commit is contained in:
1 parent
d267173e30
commit
474da7418e
7 files changed
+145
-125
No files matched your search
+14
-13
@@ -44,29 +44,30 @@
|
||||
<div class="chat-header-actions">
|
||||
<span id="chat-user-label" class="chat-user-label" hidden></span>
|
||||
<button id="auth-toggle-btn" type="button" class="btn btn-secondary">Sign in</button>
|
||||
<button id="auth-logout-btn" type="button" class="btn btn-secondary" hidden>Sign out</button> <button id="users-toggle-btn" type="button" class="btn btn-secondary" aria-controls="presence-panel" aria-expanded="true">Users</button> <button type="button" class="btn btn-secondary" onclick="togglePrivateDM()">New DM</button>
|
||||
<button id="auth-logout-btn" type="button" class="btn btn-secondary" hidden>Sign out</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="togglePrivateDM()">New DM</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="auth-panel" class="auth-panel" hidden>
|
||||
<input id="auth-username" type="text" class="input" placeholder="Username" autocomplete="username" />
|
||||
<input id="auth-password" type="password" class="input" placeholder="Password" autocomplete="current-password" />
|
||||
<div class="button-row">
|
||||
<button id="auth-login-btn" type="button" class="btn btn-primary">Log in</button>
|
||||
<button id="auth-register-btn" type="button" class="btn btn-secondary">Register</button>
|
||||
<div id="auth-panel" class="auth-modal" hidden>
|
||||
<div id="auth-backdrop" class="auth-modal-backdrop"></div>
|
||||
<div class="auth-modal-content">
|
||||
<button id="auth-close-btn" type="button" class="btn auth-close" aria-label="Close sign in">×</button>
|
||||
<h2 class="auth-modal-title">Sign in</h2>
|
||||
<input id="auth-username" type="text" class="input" placeholder="Username" autocomplete="username" />
|
||||
<input id="auth-password" type="password" class="input" placeholder="Password" autocomplete="current-password" />
|
||||
<div class="button-row">
|
||||
<button id="auth-login-btn" type="button" class="btn btn-primary">Log in</button>
|
||||
<button id="auth-register-btn" type="button" class="btn btn-secondary">Register</button>
|
||||
</div>
|
||||
<p id="auth-status" class="status-line" aria-live="polite"></p>
|
||||
</div>
|
||||
<p id="auth-status" class="status-line" aria-live="polite"></p>
|
||||
</div>
|
||||
|
||||
<div id="chat-flash" class="chat-flash" aria-live="polite" hidden></div>
|
||||
|
||||
<section id="messages" class="chat-messages" aria-live="polite"></section>
|
||||
|
||||
<div id="dm-search-row" class="dm-search-row" hidden>
|
||||
<input id="dm-search-input" type="text" class="input" placeholder="Search this conversation..." />
|
||||
<button id="dm-search-btn" type="button" class="btn btn-secondary">Search</button>
|
||||
</div>
|
||||
|
||||
<section id="presence-panel" class="presence-panel">
|
||||
<div id="presence-list" class="presence-list"></div>
|
||||
</section>
|
||||
|
||||
+11
-36
@@ -124,19 +124,18 @@ beforeEach(() => {
|
||||
<div id="chat-user-label" hidden></div>
|
||||
<button id="auth-toggle-btn" type="button">Sign in</button>
|
||||
<button id="auth-logout-btn" type="button" hidden>Logout</button>
|
||||
<div id="auth-panel" hidden>
|
||||
<input id="auth-username" type="text" />
|
||||
<input id="auth-password" type="password" />
|
||||
<span id="auth-status"></span>
|
||||
<button id="auth-login-btn" type="button">Login</button>
|
||||
<button id="auth-register-btn" type="button">Register</button>
|
||||
<div id="auth-panel" class="auth-modal" hidden>
|
||||
<div id="auth-backdrop" class="auth-modal-backdrop"></div>
|
||||
<div class="auth-modal-content">
|
||||
<button id="auth-close-btn" type="button">×</button>
|
||||
<input id="auth-username" type="text" />
|
||||
<input id="auth-password" type="password" />
|
||||
<span id="auth-status"></span>
|
||||
<button id="auth-login-btn" type="button">Login</button>
|
||||
<button id="auth-register-btn" type="button">Register</button>
|
||||
</div>
|
||||
</div>
|
||||
<button id="users-toggle-btn" type="button" aria-expanded="true">Users</button>
|
||||
<div id="typing-indicator" hidden></div>
|
||||
<div id="dm-search-row" hidden>
|
||||
<input id="dm-search-input" type="text" />
|
||||
<button id="dm-search-btn" type="button">Search</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Stub prompt for DM
|
||||
@@ -456,7 +455,7 @@ describe("sendMessage over websocket", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("auth and user list wiring", () => {
|
||||
describe("auth wiring", () => {
|
||||
it("logs in and updates the auth UI", async () => {
|
||||
await import("../src/main");
|
||||
|
||||
@@ -486,30 +485,6 @@ describe("auth and user list wiring", () => {
|
||||
expect(document.getElementById("auth-status")!.textContent).toContain("Signed in as alice");
|
||||
expect(document.getElementById("chat-user-label")!.textContent).toBe("alice");
|
||||
});
|
||||
|
||||
it("toggles the user list visibility", async () => {
|
||||
await import("../src/main");
|
||||
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ authenticated: false, entries: [] }),
|
||||
text: async () => "",
|
||||
}) as any;
|
||||
|
||||
document.dispatchEvent(new Event("DOMContentLoaded"));
|
||||
|
||||
const panel = document.getElementById("presence-panel")!;
|
||||
const btn = document.getElementById("users-toggle-btn")!;
|
||||
expect(panel.hidden).toBe(false);
|
||||
|
||||
btn.click();
|
||||
expect(panel.hidden).toBe(true);
|
||||
expect(btn.getAttribute("aria-expanded")).toBe("false");
|
||||
|
||||
btn.click();
|
||||
expect(panel.hidden).toBe(false);
|
||||
expect(btn.getAttribute("aria-expanded")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
describe("player error recovery", () => {
|
||||
|
||||
+44
-60
@@ -16,9 +16,7 @@ let ws: WebSocket | null = null;
|
||||
let currentRoom = "main";
|
||||
let username = "viewer" + Math.floor(Math.random() * 1000);
|
||||
let authenticated = false;
|
||||
let currentDMTarget = "";
|
||||
let chatVisible = true;
|
||||
let usersVisible = true;
|
||||
let typingUser = "";
|
||||
let lastTypingSent = 0;
|
||||
let typingTimeout: number | undefined;
|
||||
@@ -38,7 +36,6 @@ export function resetViewerStateForTests() {
|
||||
username = "viewer";
|
||||
authenticated = false;
|
||||
chatVisible = true;
|
||||
usersVisible = true;
|
||||
typingUser = "";
|
||||
lastTypingSent = 0;
|
||||
onlineUsers.clear();
|
||||
@@ -79,6 +76,10 @@ export function resolveManifestUrl() {
|
||||
}
|
||||
|
||||
function connectWebSocket(room: string) {
|
||||
// Clear previous messages so reconnects render a fresh history instead of
|
||||
// appending duplicates on top of the old log.
|
||||
clearMessages();
|
||||
|
||||
// Detach the old socket's close handler before closing it. Otherwise an
|
||||
// intentional reconnect (login/logout/room switch/session refresh) would
|
||||
// have the replaced socket schedule yet another reconnect, and each new
|
||||
@@ -137,6 +138,11 @@ function connectWebSocket(room: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function clearMessages() {
|
||||
const messages = document.getElementById("messages");
|
||||
if (messages) messages.innerHTML = "";
|
||||
}
|
||||
|
||||
function addMessage(type: string, text: string, sender = "", timestamp = new Date()) {
|
||||
const messages = document.getElementById("messages");
|
||||
if (!messages) return;
|
||||
@@ -281,6 +287,9 @@ export async function initPlayerAndChat(loadHlsModule: () => Promise<{ default:
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the user session first so we connect once with the correct username
|
||||
// (avoids pushing chat history twice on initial load).
|
||||
await sessionReady;
|
||||
connectWebSocket("main");
|
||||
|
||||
// Start schedule polling
|
||||
@@ -337,50 +346,34 @@ function sendMessage() {
|
||||
|
||||
function switchRoom(room: string) {
|
||||
currentRoom = room;
|
||||
const isDM = room.startsWith("dm:");
|
||||
document.getElementById("room-name")!.textContent =
|
||||
room === "main" ? "Main Channel" : room.charAt(0).toUpperCase() + room.slice(1) + " Room";
|
||||
addMessage("system", `Switched to ${room} room`);
|
||||
const searchRow = document.getElementById("dm-search-row");
|
||||
if (searchRow) searchRow.hidden = !isDM;
|
||||
connectWebSocket(room);
|
||||
}
|
||||
|
||||
function togglePrivateDM() {
|
||||
const target = prompt("Enter username to start private chat with:");
|
||||
if (target && target !== username) {
|
||||
currentDMTarget = target;
|
||||
const dmRoom = `dm:${[username, target].sort().join("-")}`;
|
||||
switchRoom(dmRoom);
|
||||
}
|
||||
}
|
||||
|
||||
async function searchDM() {
|
||||
const apiUrl = import.meta.env.VITE_PUBLIC_APP_URL?.trim() || window.location.origin;
|
||||
const input = document.getElementById("dm-search-input") as HTMLInputElement;
|
||||
const q = input.value.trim();
|
||||
if (!q || !currentDMTarget) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${apiUrl}/api/dm/search?with=${encodeURIComponent(currentDMTarget)}&q=${encodeURIComponent(q)}`,
|
||||
{ credentials: "include" },
|
||||
);
|
||||
if (!res.ok) return;
|
||||
const results = await res.json();
|
||||
addMessage("system", `Search results (${results.length}) for "${q}":`);
|
||||
for (const m of results) {
|
||||
addMessage(m.type || "private", m.text, m.username, m.timestamp);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
window.switchRoom = switchRoom;
|
||||
window.sendMessage = sendMessage;
|
||||
window.togglePrivateDM = togglePrivateDM;
|
||||
window.toggleChatPanel = toggleChatPanel;
|
||||
|
||||
function openAuthModal() {
|
||||
const panel = document.getElementById("auth-panel");
|
||||
if (panel) panel.hidden = false;
|
||||
}
|
||||
|
||||
function closeAuthModal() {
|
||||
const panel = document.getElementById("auth-panel");
|
||||
if (panel) panel.hidden = true;
|
||||
}
|
||||
|
||||
function updateAuthUI() {
|
||||
const label = document.getElementById("chat-user-label");
|
||||
const toggle = document.getElementById("auth-toggle-btn");
|
||||
@@ -395,25 +388,26 @@ function updateAuthUI() {
|
||||
if (panel) panel.hidden = true;
|
||||
}
|
||||
|
||||
async function refreshUserSession() {
|
||||
try {
|
||||
const apiUrl = import.meta.env.VITE_PUBLIC_APP_URL?.trim() || window.location.origin;
|
||||
const res = await fetch(`${apiUrl}/api/auth/session`, { credentials: "include" });
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const wasAnonymous = username.startsWith("viewer");
|
||||
if (data.authenticated && data.username) {
|
||||
authenticated = true;
|
||||
username = data.username;
|
||||
updateAuthUI();
|
||||
if (wasAnonymous) connectWebSocket(currentRoom);
|
||||
} else {
|
||||
authenticated = false;
|
||||
let sessionReady: Promise<void> = Promise.resolve();
|
||||
|
||||
function refreshUserSession(): void {
|
||||
sessionReady = (async () => {
|
||||
try {
|
||||
const apiUrl = import.meta.env.VITE_PUBLIC_APP_URL?.trim() || window.location.origin;
|
||||
const res = await fetch(`${apiUrl}/api/auth/session`, { credentials: "include" });
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
if (data.authenticated && data.username) {
|
||||
authenticated = true;
|
||||
username = data.username;
|
||||
} else {
|
||||
authenticated = false;
|
||||
}
|
||||
updateAuthUI();
|
||||
} catch {
|
||||
/* non-critical */
|
||||
}
|
||||
} catch {
|
||||
/* non-critical */
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
async function submitAuth(path: string) {
|
||||
@@ -476,15 +470,6 @@ async function logout() {
|
||||
connectWebSocket(currentRoom);
|
||||
}
|
||||
|
||||
function toggleUserList() {
|
||||
const panel = document.getElementById("presence-panel");
|
||||
const btn = document.getElementById("users-toggle-btn");
|
||||
if (!panel) return;
|
||||
usersVisible = !usersVisible;
|
||||
panel.hidden = !usersVisible;
|
||||
if (btn) btn.setAttribute("aria-expanded", String(usersVisible));
|
||||
}
|
||||
|
||||
function showTyping(name: string) {
|
||||
if (name === username) return;
|
||||
const el = document.getElementById("typing-indicator");
|
||||
@@ -521,15 +506,14 @@ function sendTyping() {
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
void initPlayerAndChat();
|
||||
void refreshUserSession();
|
||||
refreshUserSession();
|
||||
document.getElementById("auth-toggle-btn")?.addEventListener("click", () => {
|
||||
const panel = document.getElementById("auth-panel");
|
||||
if (panel) panel.hidden = !panel.hidden;
|
||||
if (!authenticated) openAuthModal();
|
||||
});
|
||||
document.getElementById("auth-close-btn")?.addEventListener("click", closeAuthModal);
|
||||
document.getElementById("auth-backdrop")?.addEventListener("click", closeAuthModal);
|
||||
document.getElementById("auth-login-btn")?.addEventListener("click", login);
|
||||
document.getElementById("auth-register-btn")?.addEventListener("click", register);
|
||||
document.getElementById("auth-logout-btn")?.addEventListener("click", logout);
|
||||
document.getElementById("dm-search-btn")?.addEventListener("click", searchDM);
|
||||
document.getElementById("users-toggle-btn")?.addEventListener("click", toggleUserList);
|
||||
document.getElementById("message-input")?.addEventListener("input", sendTyping);
|
||||
});
|
||||
+42
-8
@@ -305,19 +305,53 @@ select {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.auth-panel {
|
||||
.auth-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.auth-modal[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.auth-modal-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.auth-modal-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
border-top: 1px solid var(--bg-2);
|
||||
width: min(20rem, calc(100vw - 2rem));
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--bg-2);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--panel-2);
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.dm-search-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-top: 1px solid var(--bg-2);
|
||||
.auth-close {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.auth-modal-title {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--ink-0);
|
||||
}
|
||||
|
||||
.typing-indicator {
|
||||
|
||||
Reference in new issue
Block a user