From 944b4bdc8a9dd2e6cd2881034865056932da956e Mon Sep 17 00:00:00 2001 From: Nighthawk Date: Fri, 5 Sep 2025 02:02:26 -0400 Subject: [PATCH] Divided up ui.js functions. --- server/database_manager.py | 60 +++++++- server/user_manager.py | 56 ++++--- web/js/avatars.js | 67 +++++++++ web/js/commands.js | 52 +++++++ web/js/dom-elements.js | 59 ++++++++ web/js/speech-recognition.js | 76 ++++++++++ web/js/ui.js | 278 ++++++++--------------------------- web/style.css | 129 ++++------------ 8 files changed, 431 insertions(+), 346 deletions(-) create mode 100644 web/js/avatars.js create mode 100644 web/js/commands.js create mode 100644 web/js/dom-elements.js create mode 100644 web/js/speech-recognition.js diff --git a/server/database_manager.py b/server/database_manager.py index 6a3f1a0..3a63d94 100644 --- a/server/database_manager.py +++ b/server/database_manager.py @@ -33,9 +33,10 @@ class DatabaseManager: # Connect to the users database self.users_db_path.parent.mkdir(parents=True, exist_ok=True) self.users_conn = sqlite3.connect(self.users_db_path, check_same_thread=False) - self.users_conn.row_factory = sqlite3.Row # Use Row factory for dict-like user results + self.users_conn.row_factory = sqlite3.Row logger.info(f"Connected to users database at '{self.users_db_path}'.") self._create_users_table() + self._create_sessions_table() # NEW: Create sessions table on startup except sqlite3.Error as e: logger.critical(f"Database connection failed: {e}", exc_info=True) @@ -73,6 +74,63 @@ class DatabaseManager: except sqlite3.Error as e: logger.error(f"Failed to create 'users' table: {e}", exc_info=True) + def _create_sessions_table(self): + """Creates the 'sessions' table in the users database if it's not present.""" + if not self.users_conn: return + try: + cursor = self.users_conn.cursor() + cursor.execute(""" + CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + username_lower TEXT NOT NULL, + FOREIGN KEY (username_lower) REFERENCES users (username_lower) + ) + """) + self.users_conn.commit() + except sqlite3.Error as e: + logger.error(f"Failed to create 'sessions' table: {e}", exc_info=True) + + # --- Session Management Methods (Uses users_conn) --- + + def create_session(self, token: str, username: str) -> bool: + """Stores a new session token in the database.""" + if not self.users_conn: return False + try: + cursor = self.users_conn.cursor() + cursor.execute( + "INSERT INTO sessions (token, username_lower) VALUES (?, ?)", + (token, username.lower()) + ) + self.users_conn.commit() + return True + except sqlite3.Error as e: + logger.error(f"Failed to create session for user '{username}': {e}", exc_info=True) + return False + + def get_session_by_token(self, token: str) -> Optional[Dict[str, Any]]: + """Retrieves a session from the database by its token.""" + if not self.users_conn: return None + try: + cursor = self.users_conn.cursor() + cursor.execute("SELECT * FROM sessions WHERE token = ?", (token,)) + row = cursor.fetchone() + return dict(row) if row else None + except sqlite3.Error as e: + logger.error(f"Failed to retrieve session for token '{token[:8]}...': {e}", exc_info=True) + return None + + def delete_session(self, token: str) -> bool: + """Deletes a session token from the database (logout).""" + if not self.users_conn: return False + try: + cursor = self.users_conn.cursor() + cursor.execute("DELETE FROM sessions WHERE token = ?", (token,)) + self.users_conn.commit() + return True + except sqlite3.Error as e: + logger.error(f"Failed to delete session for token '{token[:8]}...': {e}", exc_info=True) + return False + # --- User Management Methods (Uses users_conn) --- def add_user(self, username: str, hashed_password: str, avatar_style: str) -> bool: diff --git a/server/user_manager.py b/server/user_manager.py index 7bab061..952724e 100644 --- a/server/user_manager.py +++ b/server/user_manager.py @@ -7,7 +7,7 @@ from passlib.context import CryptContext from .logger import logger from .database_manager import DatabaseManager -# These TypedDicts define the shape of user data for different contexts. +# TypedDicts remain unchanged class StoredUserData(TypedDict): """Represents the data shape of a user record from the database.""" username_cased: str @@ -20,14 +20,12 @@ class ClientUserData(TypedDict): name: str avatar_style: str -# CryptContext for hashing and verifying user passwords securely. pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") class UserManager: """ Manages player registration and authentication using the SQLite database. - - Stores users with hashed passwords for persistence. - - Issues temporary in-memory session tokens upon successful login. + - Stores users and persistent session tokens in the database. """ def __init__(self, db_manager: DatabaseManager): @@ -38,9 +36,9 @@ class UserManager: db_manager: An active instance of the DatabaseManager. """ self.db = db_manager - # Sessions are kept in memory. A server restart will log everyone out. - self._sessions: Dict[str, str] = {} - logger.info("UserManager initialized with database backend.") + # REMOVED: The in-memory session dictionary is no longer needed. + # self._sessions: Dict[str, str] = {} + logger.info("UserManager initialized with database backend for users and sessions.") def _get_password_hash(self, password: str) -> str: """Hashes a plain-text password.""" @@ -67,7 +65,6 @@ class UserManager: if len(password) < 8: return False, "Password must be at least 8 characters long." - # Check if user already exists in the database if self.db.get_user_by_name(name): return False, "Player name is already registered." @@ -82,11 +79,7 @@ class UserManager: def login(self, name: str, password: str) -> Optional[ClientUserData]: """ - Verifies user credentials against the database and creates a session token. - - Args: - name: The username to log in with. - password: The plain-text password. + Verifies user credentials and creates a persistent session in the database. Returns: A dictionary with client-safe user data if successful, otherwise None. @@ -99,12 +92,17 @@ class UserManager: return None session_token = str(uuid.uuid4()) - # Store the correctly-cased username in the session map - self._sessions[session_token] = user_data["username_cased"] - logger.info(f"Player '{user_data['username_cased']}' logged in successfully.") + + # UPDATED: Create the session in the database instead of in memory. + username_cased = user_data["username_cased"] + if not self.db.create_session(session_token, username_cased): + logger.error(f"Failed to create database session for user '{username_cased}'") + return None + + logger.info(f"Player '{username_cased}' logged in. DB session created.") client_data: ClientUserData = { - "name": user_data["username_cased"], + "name": username_cased, "avatar_style": user_data["avatar_style"], "token": session_token } @@ -112,29 +110,25 @@ class UserManager: def get_user_by_token(self, token: str) -> Optional[Dict[str, Any]]: """ - Finds a user's full data from the database using their session token. - - Args: - token: The user's active session token. + Finds a user's data from the DB using their persistent session token. Returns: - A dictionary with the user's database record if the token is valid, - otherwise None. + A dictionary with the user's database record if the token is valid. """ - username = self._sessions.get(token) - if not username: + # UPDATED: Validate the token against the database. + session = self.db.get_session_by_token(token) + if not session: return None - # Fetch fresh user data from the database - return self.db.get_user_by_name(username) + # Session is valid, now fetch the full user data using the username from the session. + return self.db.get_user_by_name(session["username_lower"]) def logout(self, token: str): """ - Removes a session token, effectively logging the user out. + Deletes a session token from the database. Args: token: The session token to invalidate. """ - if token in self._sessions: - del self._sessions[token] - logger.info(f"Session token {token[:8]}... ended.") \ No newline at end of file + if self.db.delete_session(token): + logger.info(f"Session token {token[:8]}... deleted from database.") \ No newline at end of file diff --git a/web/js/avatars.js b/web/js/avatars.js new file mode 100644 index 0000000..376cf95 --- /dev/null +++ b/web/js/avatars.js @@ -0,0 +1,67 @@ +// web/js/avatars.js + +/** + * @typedef {import('./dom-elements.js').dom} DOM_Elements + * @typedef {import('./state.js').AppState} AppState + */ + +// A list of all available DiceBear avatar styles for the registration form. +const AVATAR_STYLES = [ + "adventurer", "adventurer-neutral", "avataaars", "big-ears", "big-smile", + "bottts", "croodles", "fun-emoji", "icons", "identicon", "initials", + "lorelei", "micah", "miniavs", "open-peeps", "personas", "pixel-art", "rings" +]; + +/** + * Updates the avatar preview image and highlights the currently selected style. + * @param {DOM_Elements} dom The centralized DOM elements object. + * @param {AppState} state The central state object. + */ +function updateAvatarSelectionUI(dom, state) { + const name = dom.registerNameInput.value.trim() || 'player'; + dom.registerAvatarPreview.src = `https://api.dicebear.com/9.x/${state.selectedAvatarStyle}/svg?seed=${encodeURIComponent(name)}`; + dom.avatarSelectionGrid.querySelectorAll('.avatar-option').forEach(opt => { + opt.classList.toggle('selected', opt.dataset.style === state.selectedAvatarStyle); + }); +} + +/** + * Generates the grid of clickable avatar style options. + * @param {DOM_Elements} dom The centralized DOM elements object. + * @param {AppState} state The central state object. + */ +function populateAvatarGrid(dom, state) { + const grid = dom.avatarSelectionGrid; + grid.innerHTML = ''; + AVATAR_STYLES.forEach(style => { + const option = document.createElement('div'); + option.className = 'avatar-option'; + option.dataset.style = style; + const img = document.createElement('img'); + img.src = `https://api.dicebear.com/9.x/${style}/svg`; + img.alt = style; + option.appendChild(img); + grid.appendChild(option); + }); + updateAvatarSelectionUI(dom, state); +} + +/** + * Initializes all functionality for the avatar selection component. + * @param {DOM_Elements} dom The centralized DOM elements object. + * @param {AppState} state The central state object. + */ +export function initAvatarSelection(dom, state) { + // Generate the avatar grid when the module is initialized. + populateAvatarGrid(dom, state); + + // Attach event listeners for dynamic updates. + dom.registerNameInput.addEventListener('input', () => updateAvatarSelectionUI(dom, state)); + dom.avatarSelectionGrid.addEventListener('click', (e) => { + const option = e.target.closest('.avatar-option'); + if (option && option.dataset.style) { + state.selectedAvatarStyle = option.dataset.style; + updateAvatarSelectionUI(dom, state); + } + }); +} \ No newline at end of file diff --git a/web/js/commands.js b/web/js/commands.js new file mode 100644 index 0000000..ab50614 --- /dev/null +++ b/web/js/commands.js @@ -0,0 +1,52 @@ +// web/js/commands.js + +/** + * @typedef {import('./dom-elements.js').dom} DOM_Elements + */ + +// Define the available slash commands and their descriptions. +const COMMANDS = { + "/roll": "[dice] - Rolls dice (e.g., 2d6+3). Defaults to 1d20.", + "/ooc": "[message] - Sends an out-of-character message.", + "/remember": "[fact] - Saves a fact to the GM's long-term memory.", + "/save": "- Saves the current game session.", + "/next": "- Submits the current turn actions to the GM." +}; + +/** + * Updates the command preview UI based on the user's input. + * @param {DOM_Elements} dom - The centralized DOM elements object. + */ +function updateCommandPreview(dom) { + const text = dom.messageInput.value; + if (text.startsWith('/')) { + const [typedCmd] = text.split(' '); + let html = '

Commands

'; + dom.commandPreview.innerHTML = html; + dom.commandPreview.style.display = 'block'; + } else { + dom.commandPreview.style.display = 'none'; + } +} + +/** + * Initializes the command preview functionality. + * @param {DOM_Elements} dom - The centralized DOM elements object. + */ +export function initCommandPreview(dom) { + // Attach event listener to the message input to update the preview on typing. + dom.messageInput.addEventListener('input', () => updateCommandPreview(dom)); + + // Also attach a listener to the send button to clear the preview after sending. + dom.sendButton.addEventListener('click', () => { + // A small delay ensures this runs after the input is cleared. + setTimeout(() => updateCommandPreview(dom), 0); + }); +} \ No newline at end of file diff --git a/web/js/dom-elements.js b/web/js/dom-elements.js new file mode 100644 index 0000000..33e8bb3 --- /dev/null +++ b/web/js/dom-elements.js @@ -0,0 +1,59 @@ +// web/js/dom-elements.js + +/** + * A centralized cache of all DOM elements used by the UI. + * This object is queried once and then exported for use by other modules. + */ +export const dom = { + // Auth Forms & Views + loginView: document.getElementById('login-view'), + registerView: document.getElementById('register-view'), + loginForm: document.getElementById('login-form'), + registerForm: document.getElementById('register-form'), + loginNameInput: document.getElementById('login-name'), + loginPasswordInput: document.getElementById('login-password'), + registerNameInput: document.getElementById('register-name'), + registerPasswordInput: document.getElementById('register-password'), + registerConfirmPasswordInput: document.getElementById('register-confirm-password'), + loginError: document.getElementById('login-error'), + registerError: document.getElementById('register-error'), + switchToRegisterBtn: document.getElementById('switch-to-register'), + switchToLoginBtn: document.getElementById('switch-to-login'), + + // Avatar Selection + avatarSelectionGrid: document.getElementById('avatar-selection-grid'), + registerAvatarPreview: document.getElementById('register-avatar-preview'), + + // Main App View + mainAppView: document.getElementById('main-app-view'), + sidebar: document.querySelector('.sidebar'), + chatArea: document.querySelector('.chat-area'), + + // Player Info & Connection + playerIdentity: document.getElementById('player-identity'), + playerCardAvatar: document.getElementById('player-card-avatar'), + playerCardName: document.getElementById('player-card-name'), + logoutButton: document.getElementById('logout-button'), + connectionForm: document.getElementById('connection-form'), + roomIdInput: document.getElementById('room-id-input'), + joinButton: document.getElementById('join-button'), + + // In-Room Info + roomInfo: document.getElementById('room-info'), + roomName: document.getElementById('room-name'), + playerList: document.getElementById('player-list'), + hostControlsContainer: document.getElementById('host-controls-container'), + startGameButton: document.getElementById('start-game-button'), + resumeGameButton: document.getElementById('resume-game-button'), + leaveButton: document.getElementById('leave-button'), + + // Chat & Input + chatLog: document.getElementById('chat-log'), + messageInput: document.getElementById('message-input'), + sendButton: document.getElementById('send-button'), + resolveButton: document.getElementById('resolve-button'), + gmThinkingIndicator: document.getElementById('gm-thinking-indicator'), + commandPreview: document.getElementById('command-preview'), + micButton: document.getElementById('mic-button'), + themeToggle: document.getElementById('theme-toggle'), +}; \ No newline at end of file diff --git a/web/js/speech-recognition.js b/web/js/speech-recognition.js new file mode 100644 index 0000000..5df19be --- /dev/null +++ b/web/js/speech-recognition.js @@ -0,0 +1,76 @@ +// web/js/speech-recognition.js + +/** + * Initializes and manages the Web Speech API for speech-to-text functionality. + * @param {object} options - Configuration for the speech recognition module. + * @param {(text: string) => void} options.onFinalResult - Callback function when a final transcript is ready. + * @param {(isListening: boolean) => void} options.onStatusChange - Callback function for listening status updates. + * @returns {object|null} An object with methods to control speech recognition, or null if not supported. + */ +export function initSpeechRecognition({ onFinalResult, onStatusChange }) { + // Check for browser support. + const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; + if (!SpeechRecognition) { + console.warn("Web Speech API is not supported in this browser."); + return null; + } + + const recognition = new SpeechRecognition(); + recognition.continuous = false; // We want to process speech in single chunks. + recognition.interimResults = false; // We only care about the final, most accurate result. + recognition.lang = 'en-US'; // Set language. + + let isListening = false; + + // Event handler for when the API has a final result. + recognition.onresult = (event) => { + const transcript = event.results[0][0].transcript; + if (onFinalResult) { + onFinalResult(transcript); + } + }; + + // Event handler for any errors. + recognition.onerror = (event) => { + console.error("Speech recognition error:", event.error); + if (isListening) { + stopListening(); // Ensure we stop if there's an error. + } + }; + + // Event handler for when listening automatically ends. + recognition.onend = () => { + if (isListening) { + stopListening(); + } + }; + + /** + * Starts listening for speech. + */ + function startListening() { + if (isListening) return; + try { + recognition.start(); + isListening = true; + if (onStatusChange) onStatusChange(true); + } catch (e) { + console.error("Could not start speech recognition:", e); + } + } + + /** + * Manually stops listening for speech. + */ + function stopListening() { + if (!isListening) return; + recognition.stop(); + isListening = false; + if (onStatusChange) onStatusChange(false); + } + + return { + startListening, + stopListening, + }; +} \ No newline at end of file diff --git a/web/js/ui.js b/web/js/ui.js index 794890b..56f6210 100644 --- a/web/js/ui.js +++ b/web/js/ui.js @@ -1,5 +1,9 @@ // web/js/ui.js import { initAudio } from './audio.js'; +import { dom } from './dom-elements.js'; +import { initSpeechRecognition } from './speech-recognition.js'; +import { initCommandPreview } from './commands.js'; +import { initAvatarSelection } from './avatars.js'; /** * @typedef {import('./state.js').AppState} AppState @@ -22,88 +26,26 @@ import { initAudio } from './audio.js'; * @property {() => void} showLoginView */ -// List of available avatar styles. -const AVATAR_STYLES = [ - "adventurer", "adventurer-neutral", "avataaars", "big-ears", "big-smile", - "bottts", "croodles", "fun-emoji", "icons", "identicon", "initials", - "lorelei", "micah", "miniavs", "open-peeps", "personas", "pixel-art", "rings" -]; - -// NEW: Define available slash commands for the previewer. -const COMMANDS = { - "/roll": "[dice] - Rolls dice (e.g., 2d6+3). Defaults to 1d20.", - "/ooc": "[message] - Sends an out-of-character message.", - "/remember": "[fact] - Saves a fact to the GM's long-term memory.", - "/save": "- Saves the current game session.", - "/next": "- Submits the current turn actions to the GM." -}; - - /** - * Initializes and returns the UI module. + * Initializes and returns the UI module, which orchestrates all sub-modules. * @param {AppState} state - The central state object. * @returns {AppUI} */ export function initUI(state) { - // A cache for all DOM elements we will interact with. - const dom = { - // Auth Forms & Views - loginView: document.getElementById('login-view'), - registerView: document.getElementById('register-view'), - loginForm: document.getElementById('login-form'), - registerForm: document.getElementById('register-form'), - loginNameInput: document.getElementById('login-name'), - loginPasswordInput: document.getElementById('login-password'), - registerNameInput: document.getElementById('register-name'), - registerPasswordInput: document.getElementById('register-password'), - registerConfirmPasswordInput: document.getElementById('register-confirm-password'), - loginError: document.getElementById('login-error'), - registerError: document.getElementById('register-error'), - switchToRegisterBtn: document.getElementById('switch-to-register'), - switchToLoginBtn: document.getElementById('switch-to-login'), - - // Avatar Selection - avatarSelectionGrid: document.getElementById('avatar-selection-grid'), - registerAvatarPreview: document.getElementById('register-avatar-preview'), - - // Main App View - mainAppView: document.getElementById('main-app-view'), - sidebar: document.querySelector('.sidebar'), - chatArea: document.querySelector('.chat-area'), - - // Player Info & Connection - playerIdentity: document.getElementById('player-identity'), - playerCardAvatar: document.getElementById('player-card-avatar'), - playerCardName: document.getElementById('player-card-name'), - logoutButton: document.getElementById('logout-button'), - connectionForm: document.getElementById('connection-form'), - roomIdInput: document.getElementById('room-id-input'), - joinButton: document.getElementById('join-button'), - - // In-Room Info - roomInfo: document.getElementById('room-info'), - roomName: document.getElementById('room-name'), - playerList: document.getElementById('player-list'), - hostControlsContainer: document.getElementById('host-controls-container'), - startGameButton: document.getElementById('start-game-button'), - resumeGameButton: document.getElementById('resume-game-button'), - leaveButton: document.getElementById('leave-button'), - - // Chat - chatLog: document.getElementById('chat-log'), - messageInput: document.getElementById('message-input'), - sendButton: document.getElementById('send-button'), - resolveButton: document.getElementById('resolve-button'), - gmThinkingIndicator: document.getElementById('gm-thinking-indicator'), - commandPreview: document.getElementById('command-preview'), // Command preview element - themeToggle: document.getElementById('theme-toggle'), - }; - - // Module dependencies, to be set later. let api = null; const audio = initAudio(state); + + // Initialize all imported UI sub-modules + const speech = initSpeechRecognition({ + onFinalResult: (text) => { dom.messageInput.value = text; }, + onStatusChange: (isListening) => { dom.micButton.classList.toggle('listening', isListening); } + }); + initCommandPreview(dom); + initAvatarSelection(dom, state); - /** Main render function to switch between primary UI views */ + /** + * Main render function to switch between the primary UI views (auth vs. app). + */ function _render() { const mainElement = document.querySelector('main'); dom.loginView.style.display = 'none'; @@ -138,64 +80,21 @@ export function initUI(state) { } } } - - /** NEW: Updates the command preview based on input */ - function _updateCommandPreview() { - const text = dom.messageInput.value; - if (text.startsWith('/')) { - const [typedCmd] = text.split(' '); - let html = '

Commands

'; - dom.commandPreview.innerHTML = html; - dom.commandPreview.style.display = 'block'; - } else { - dom.commandPreview.style.display = 'none'; - } - } - - // --- Other private functions (_updateAvatarSelectionUI, _populateAvatarGrid, etc. are unchanged) --- - - function _updateAvatarSelectionUI() { - const name = dom.registerNameInput.value.trim() || 'player'; - dom.registerAvatarPreview.src = `https://api.dicebear.com/9.x/${state.selectedAvatarStyle}/svg?seed=${encodeURIComponent(name)}`; - dom.avatarSelectionGrid.querySelectorAll('.avatar-option').forEach(opt => { - opt.classList.toggle('selected', opt.dataset.style === state.selectedAvatarStyle); - }); - } - - function _populateAvatarGrid() { - const grid = dom.avatarSelectionGrid; - grid.innerHTML = ''; - AVATAR_STYLES.forEach(style => { - const option = document.createElement('div'); - option.className = 'avatar-option'; - option.dataset.style = style; - const img = document.createElement('img'); - img.src = `https://api.dicebear.com/9.x/${style}/svg`; - img.alt = style; - option.appendChild(img); - grid.appendChild(option); - }); - _updateAvatarSelectionUI(); - } + /** Displays an error message in a designated element. */ function _showError(element, message) { element.textContent = message; element.style.display = 'block'; } + /** Hides an error message element. */ function _hideError(element) { element.style.display = 'none'; } - /** Attach all event listeners for the application */ + /** Attaches all persistent event listeners for the application. */ function _attachListeners() { - // --- Auth & Connection Listeners (mostly unchanged) --- + // --- Auth & Connection Listeners --- dom.switchToRegisterBtn.addEventListener('click', () => { state.uiView = 'register'; _render(); }); dom.switchToLoginBtn.addEventListener('click', () => { state.uiView = 'login'; _render(); }); @@ -205,18 +104,10 @@ export function initUI(state) { const name = dom.registerNameInput.value.trim(); const password = dom.registerPasswordInput.value; const confirm = dom.registerConfirmPasswordInput.value; - if (password !== confirm) { - _showError(dom.registerError, "Passwords do not match."); - return; - } + if (password !== confirm) { _showError(dom.registerError, "Passwords do not match."); return; } const result = await api.register(name, state.selectedAvatarStyle, password); - if (result.success) { - alert(result.message); - state.uiView = 'login'; - _render(); - } else { - _showError(dom.registerError, result.message); - } + if (result.success) { alert(result.message); state.uiView = 'login'; _render(); } + else { _showError(dom.registerError, result.message); } }); dom.loginForm.addEventListener('submit', async (e) => { @@ -230,9 +121,7 @@ export function initUI(state) { localStorage.setItem('vdm-player', JSON.stringify(result.data)); state.uiView = 'chat'; _render(); - } else { - _showError(dom.loginError, result.message); - } + } else { _showError(dom.loginError, result.message); } }); dom.logoutButton.addEventListener('click', () => api.logout()); @@ -242,48 +131,37 @@ export function initUI(state) { }); dom.leaveButton.addEventListener('click', () => api.disconnect()); - // --- Avatar Selection Listeners (unchanged) --- - dom.registerNameInput.addEventListener('input', () => _updateAvatarSelectionUI()); - dom.avatarSelectionGrid.addEventListener('click', (e) => { - const option = e.target.closest('.avatar-option'); - if (option && option.dataset.style) { - state.selectedAvatarStyle = option.dataset.style; - _updateAvatarSelectionUI(); - } - }); - - // --- Chat Listeners --- + // --- Chat & Speech Listeners --- dom.sendButton.addEventListener('click', () => { const message = dom.messageInput.value.trim(); if (message) api.sendMessage('say', { message }); dom.messageInput.value = ''; - _updateCommandPreview(); dom.messageInput.focus(); }); - dom.messageInput.addEventListener('keydown', (e) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - dom.sendButton.click(); - } + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); dom.sendButton.click(); } }); - // NEW: Add input event listener for command preview - dom.messageInput.addEventListener('input', _updateCommandPreview); + if (speech) { + dom.micButton.addEventListener('mousedown', speech.startListening); + dom.micButton.addEventListener('mouseup', speech.stopListening); + dom.micButton.addEventListener('touchstart', speech.startListening, { passive: true }); + dom.micButton.addEventListener('touchend', speech.stopListening); + } else { + dom.micButton.style.display = 'none'; + } + // --- Host & Game Listeners --- dom.resolveButton.addEventListener('click', () => api.sendMessage('submit_turn')); dom.startGameButton.addEventListener('click', () => api.sendMessage('start_game')); dom.resumeGameButton.addEventListener('click', () => api.sendMessage('resume_game')); - // --- Misc Listeners (unchanged) --- + // --- Misc Listeners --- dom.themeToggle.addEventListener('click', () => { const isLight = document.body.classList.toggle('light-theme'); localStorage.setItem('vdm-theme', isLight ? 'light' : 'dark'); }); - const resumeAudio = () => { - if (state.audioContext && state.audioContext.state === 'suspended') { - state.audioContext.resume(); - } + if (state.audioContext && state.audioContext.state === 'suspended') { state.audioContext.resume(); } document.body.removeEventListener('click', resumeAudio); }; document.body.addEventListener('click', resumeAudio); @@ -292,19 +170,16 @@ export function initUI(state) { // --- Initialize --- const savedTheme = localStorage.getItem('vdm-theme') || 'dark'; document.body.classList.toggle('light-theme', savedTheme === 'light'); - _populateAvatarGrid(); _attachListeners(); - _render(); // Set initial view + _render(); - /** @type {AppUI} */ + // --- Public Interface --- const publicInterface = { - setApi(apiModule) { - api = apiModule; - }, - + setApi(apiModule) { api = apiModule; }, logMessage(type, data, isBatch = false) { const msgDiv = document.createElement('div'); msgDiv.classList.add('msg', type); + if (type === 'system') { msgDiv.textContent = data.message; } else if (type === 'chat') { @@ -336,24 +211,17 @@ export function initUI(state) { msgDiv.appendChild(avatarImg); msgDiv.appendChild(contentDiv); } + dom.chatLog.appendChild(msgDiv); if (!isBatch) { dom.chatLog.scrollTop = dom.chatLog.scrollHeight; } }, - - // NEW: Function to load historical messages loadChatHistory(messages) { - dom.chatLog.innerHTML = ''; // Clear the chat log first - messages.forEach(msg => { - // The 'chat' type is hardcoded as only chat messages are in history - this.logMessage('chat', msg, true); - }); - dom.chatLog.scrollTop = dom.chatLog.scrollHeight; // Scroll to bottom after batch rendering + dom.chatLog.innerHTML = ''; + messages.forEach(msg => { this.logMessage('chat', msg, true); }); + dom.chatLog.scrollTop = dom.chatLog.scrollHeight; }, - - // --- Other public methods (updateRoomState, streaming handlers, etc.) are mostly unchanged --- - updateRoomState(room) { state.room = room; dom.roomName.textContent = room.room_id; @@ -370,23 +238,24 @@ export function initUI(state) { const nameSpan = document.createElement('span'); nameSpan.className = 'player-name'; nameSpan.textContent = player.name; - if (room.host_player_id === player.id) { - nameSpan.textContent += ' 👑'; // Host indicator - } - - const hpSpan = document.createElement('span'); - hpSpan.className = 'player-hp'; - if (player.sheet) { - hpSpan.textContent = `${player.sheet.hp}/${player.sheet.max_hp} HP`; + if (room.host_player_id === player.id) { nameSpan.textContent += ' 👑'; } + + const turnIndicator = document.createElement('span'); + turnIndicator.className = 'turn-indicator'; + if (room.current_turn_actions && room.current_turn_actions[player.id]) { + playerLi.classList.add('action-submitted'); + turnIndicator.textContent = '✅'; } + // REMOVED: The hpSpan logic is gone. + playerLi.appendChild(avatarImg); playerLi.appendChild(nameSpan); - playerLi.appendChild(hpSpan); + playerLi.appendChild(turnIndicator); dom.playerList.appendChild(playerLi); }); - const isHost = (state.playerInfo && room.host_player_id === state.playerInfo.id); + const isHost = (state.playerInfo && room.host_player_id === state.clientId); const inLobby = room.game_state === "LOBBY"; const gmIsProcessing = room.turn_state === "GM_PROCESSING"; const actionsExist = Object.keys(room.current_turn_actions || {}).length > 0; @@ -398,23 +267,16 @@ export function initUI(state) { dom.resolveButton.disabled = !actionsExist || gmIsProcessing; dom.messageInput.disabled = gmIsProcessing || inLobby; dom.sendButton.disabled = gmIsProcessing || inLobby; + dom.micButton.disabled = gmIsProcessing || inLobby; }, - - showRoomView(roomId) { - state.isConnected = true; - _render(); - }, - showConnectionView() { - state.isConnected = false; - _render(); - }, + showRoomView(roomId) { state.isConnected = true; _render(); }, + showConnectionView() { state.isConnected = false; _render(); }, showLoginView() { state.uiView = 'login'; state.isConnected = false; - state.playerInfo = null; // Ensure player info is cleared + state.playerInfo = null; _render(); }, - handleStreamStart() { const msgDiv = document.createElement('div'); msgDiv.classList.add('msg', 'chat', 'gm', 'streaming'); @@ -427,31 +289,21 @@ export function initUI(state) { authorSpan.className = 'author'; authorSpan.textContent = 'GM'; const messageSpan = document.createElement('span'); - contentDiv.appendChild(authorSpan); contentDiv.appendChild(messageSpan); msgDiv.appendChild(avatarImg); msgDiv.appendChild(contentDiv); dom.chatLog.appendChild(msgDiv); - - state.activeStream = { - messageElement: msgDiv, - contentElement: messageSpan, - }; + state.activeStream = { messageElement: msgDiv, contentElement: messageSpan }; audio.startStream(); }, - handleChatChunk(content) { if (state.activeStream && state.activeStream.contentElement) { state.activeStream.contentElement.textContent += content; dom.chatLog.scrollTop = dom.chatLog.scrollHeight; } }, - - handleAudioChunk(chunk) { - audio.queueAndPlay(chunk); - }, - + handleAudioChunk(chunk) { audio.queueAndPlay(chunk); }, handleStreamEnd(finalMessage) { if (state.activeStream && state.activeStream.messageElement) { state.activeStream.messageElement.classList.remove('streaming'); @@ -459,11 +311,7 @@ export function initUI(state) { state.activeStream = null; audio.endStream(); }, - - playAudioFile(url) { - audio.playFullAudioFile(url); - } + playAudioFile(url) { audio.playFullAudioFile(url); } }; - return publicInterface; } \ No newline at end of file diff --git a/web/style.css b/web/style.css index 523e034..31ff8ff 100644 --- a/web/style.css +++ b/web/style.css @@ -7,6 +7,7 @@ --font-sans: 'Inter', sans-serif; --error-red: #f04747; --link-blue: #7289da; + --success-green: #43b581; /* Light Theme */ --bg-light: #f4f5f7; @@ -41,40 +42,11 @@ body.light-theme { background-color: var(--bg-light); color: var(--text-primary- /* --- 3. Main Layout Containers --- */ header { display: flex; justify-content: space-between; align-items: center; padding: 0.75rem 1.5rem; background-color: var(--surface-dark); border-bottom: 1px solid var(--border-dark); flex-shrink: 0; } body.light-theme header { background-color: var(--surface-light); border-bottom-color: var(--border-light); } - -main { - display: flex; - flex-grow: 1; - overflow: hidden; -} - -main.auth-mode { - justify-content: center; - align-items: flex-start; - padding-top: 10vh; -} - -main.auth-mode > .auth-form-container { - width: 380px; - max-width: 90%; - background-color: var(--surface-dark); - padding: 2rem; - border-radius: 8px; - border: 1px solid var(--border-dark); - box-shadow: 0 8px 24px rgba(0,0,0,0.3); -} - -body.light-theme main.auth-mode > .auth-form-container { - background-color: var(--surface-light); - border-color: var(--border-light); - box-shadow: 0 8px 24px rgba(0,0,0,0.1); -} - -main.app-mode > #main-app-view { - display: flex; - width: 100%; -} - +main { display: flex; flex-grow: 1; overflow: hidden; } +main.auth-mode { justify-content: center; align-items: flex-start; padding-top: 10vh; } +main.auth-mode > .auth-form-container { width: 380px; max-width: 90%; background-color: var(--surface-dark); padding: 2rem; border-radius: 8px; border: 1px solid var(--border-dark); box-shadow: 0 8px 24px rgba(0,0,0,0.3); } +body.light-theme main.auth-mode > .auth-form-container { background-color: var(--surface-light); border-color: var(--border-light); box-shadow: 0 8px 24px rgba(0,0,0,0.1); } +main.app-mode > #main-app-view { display: flex; width: 100%; } .sidebar { width: 280px; flex-shrink: 0; background-color: var(--surface-dark); padding: 1.5rem; display: flex; flex-direction: column; gap: 2rem; overflow-y: auto; border-right: 1px solid var(--border-dark); } body.light-theme .sidebar { background-color: var(--surface-light); border-right-color: var(--border-light); } .chat-area { display: flex; flex-direction: column; flex-grow: 1; } @@ -83,12 +55,10 @@ body.light-theme .sidebar { background-color: var(--surface-light); border-right .sidebar-section { display: flex; flex-direction: column; gap: 1rem; } .sidebar-section h3, .sidebar-section h4 { color: var(--text-primary-dark); border-bottom: 1px solid var(--border-dark); padding-bottom: 0.5rem; font-size: 1.1rem; } body.light-theme .sidebar-section h3, body.light-theme .sidebar-section h4 { color: var(--text-primary-light); border-bottom-color: var(--border-light); } - .auth-form-container h3 { text-align: center; } .auth-form-container .link-button { background: none; border: none; color: var(--link-blue); text-decoration: underline; padding: 0.25rem 0.5rem; cursor: pointer; font-size: 0.85rem; } body.light-theme .auth-form-container .link-button { color: var(--accent-light); } .auth-form-container .link-button:hover { text-decoration: none; } - .avatar-preview-container { display: flex; align-items: center; gap: 0.75rem; background-color: var(--bg-dark); padding: 0.5rem; border-radius: 8px; border: 1px solid var(--border-dark); margin-bottom: 0.5rem;} body.light-theme .avatar-preview-container { background-color: var(--bg-light); border-color: var(--border-light); } #register-avatar-preview { width: 40px; height: 40px; border-radius: 50%; flex-shrink: 0; border: 2px solid var(--border-dark); } @@ -99,50 +69,39 @@ body.light-theme #register-avatar-preview { border-color: var(--border-light); } .avatar-option.selected { border-color: var(--accent-dark); } body.light-theme .avatar-option:hover { border-color: var(--border-light); } body.light-theme .avatar-option.selected { border-color: var(--accent-light); } - .player-card { display: flex; align-items: center; gap: 1rem; background-color: var(--bg-dark); padding: 0.75rem; border-radius: 8px; border: 1px solid var(--border-dark); margin-bottom: 0.5rem;} body.light-theme .player-card { background-color: var(--bg-light); border-color: var(--border-light); } #player-card-avatar { width: 40px; height: 40px; border-radius: 50%; border: 2px solid var(--border-dark); } body.light-theme #player-card-avatar { border-color: var(--border-light); } #player-card-name { font-weight: 700; font-size: 1.1rem; } - #player-list-container { max-height: 200px; overflow-y: auto; background-color: var(--bg-dark); padding: 0.75rem; border-radius: 8px; border: 1px solid var(--border-dark); } body.light-theme #player-list-container { background-color: var(--bg-light); border-color: var(--border-light); } #player-list { list-style-type: none; display: flex; flex-direction: column; gap: 0.75rem; } -#player-list li { display: flex; align-items: center; gap: 0.75rem; color: var(--text-secondary-dark); font-size: 0.9rem;} +#player-list li { display: flex; align-items: center; gap: 0.75rem; color: var(--text-secondary-dark); font-size: 0.9rem; transition: color 0.2s; } body.light-theme #player-list li { color: var(--text-secondary-light); } #player-list .player-inactive { opacity: 0.5; font-style: italic; } .player-list-avatar { width: 28px; height: 28px; border-radius: 50%; background-color: var(--border-dark); } body.light-theme .player-list-avatar { background-color: var(--border-light); } .player-name { flex-grow: 1; } -.player-hp { font-size: 0.8rem; font-weight: 500; color: var(--text-secondary-dark); background-color: var(--bg-dark); padding: 0.15rem 0.4rem; border-radius: 4px; white-space: nowrap; } -body.light-theme .player-hp { color: var(--text-secondary-light); background-color: var(--bg-light); } - #host-controls-container { display: flex; flex-direction: column; gap: 1rem; } /* --- 5. Generic Form & Button Styling --- */ button { padding: 0.65rem 1rem; font-family: var(--font-sans); font-size: 0.9rem; font-weight: 500; border-radius: 5px; border: none; cursor: pointer; transition: background-color 0.2s, color 0.2s, opacity 0.2s; display: flex; align-items: center; justify-content: center; gap: 0.5rem; width: 100%; } button:disabled { cursor: not-allowed; opacity: 0.5; } - #theme-toggle { background: none; border: none; font-size: 1.5rem; padding: 0.25rem; margin-left: auto; width: auto; } - .sidebar button, .auth-form-container button { background-color: var(--accent-dark); color: white; } .sidebar button:not(:disabled):hover, .auth-form-container button:not(:disabled):hover { background-color: var(--accent-hover-dark); } body.light-theme .sidebar button, body.light-theme .auth-form-container button { background-color: var(--accent-light); } body.light-theme .sidebar button:not(:disabled):hover, body.light-theme .auth-form-container button:not(:disabled):hover { background-color: var(--accent-hover-light); } - #leave-button, #logout-button { background-color: var(--bg-dark); color: var(--text-primary-dark); border: 1px solid var(--border-dark); } #leave-button:hover, #logout-button:hover { background-color: var(--surface-dark); } body.light-theme #leave-button, body.light-theme #logout-button { background-color: var(--bg-light); color: var(--text-primary-light); border-color: var(--border-light); } body.light-theme #leave-button:hover, body.light-theme #logout-button:hover { background-color: var(--surface-light); } - .chat-input-area button { background-color: var(--accent-dark); color: white; width: auto; } .chat-input-area button:not(:disabled):hover { background-color: var(--accent-hover-dark); } body.light-theme .chat-input-area button { background-color: var(--accent-light); } body.light-theme .chat-input-area button:not(:disabled):hover { background-color: var(--accent-hover-light); } - #mic-button.listening { color: var(--error-red); } - input[type="text"], input[type="password"], textarea { width: 100%; padding: 0.65rem 1rem; border-radius: 5px; border: 1px solid var(--border-dark); background-color: var(--bg-dark); color: var(--text-primary-dark); font-family: var(--font-sans); font-size: 0.9rem; margin-bottom: 0.5rem; } body.light-theme input[type="text"], body.light-theme input[type="password"], body.light-theme textarea { border-color: var(--border-light); background-color: var(--bg-light); color: var(--text-primary-light); } input[type="text"]:focus, input[type="password"]:focus, textarea:focus { outline: none; border-color: var(--accent-dark); box-shadow: 0 0 0 2px rgba(114, 137, 218, 0.3); } @@ -158,25 +117,20 @@ body.light-theme .chat-input-area { background-color: var(--surface-light); bord .msg { max-width: 90%; display: flex; gap: 1rem; align-items: flex-start; } .msg.gm { align-self: flex-start; } .msg.player { align-self: flex-start; } - .msg-avatar { width: 40px; height: 40px; border-radius: 50%; margin-top: 3px; flex-shrink: 0; background-color: var(--border-dark); } body.light-theme .msg-avatar { background-color: var(--border-light); } .msg-content { display: flex; flex-direction: column; background-color: var(--surface-dark); padding: 0.75rem 1rem; border-radius: 8px; } body.light-theme .msg-content { background-color: var(--surface-light); } .msg.gm .msg-content { background-color: var(--gm-msg-bg-dark); } body.light-theme .msg.gm .msg-content { background-color: var(--gm-msg-bg-light); } - .msg .author { font-weight: 700; margin-bottom: 0.25rem; color: var(--text-primary-dark); } body.light-theme .msg .author { color: var(--text-primary-light); } .msg.gm .author { color: var(--accent-dark); } body.light-theme .msg.gm .author { color: var(--accent-light); } - .msg.ooc { font-style: italic; opacity: 0.8; } .msg.ooc .msg-content { background-color: transparent; } - .msg.system { align-self: center; background-color: var(--system-msg-bg-dark); color: var(--text-secondary-dark); padding: 0.5rem 1rem; border-radius: 1rem; font-size: 0.85rem; text-align: center; max-width: 70%; } body.light-theme .msg.system { background-color: var(--system-msg-bg-light); color: var(--text-secondary-light); } - .msg code { background-color: var(--bg-dark); padding: 0.1rem 0.3rem; border-radius: 4px; font-family: monospace; } body.light-theme .msg code { background-color: var(--bg-light); } .msg strong { font-weight: 700; } @@ -187,57 +141,34 @@ body.light-theme #gm-thinking-indicator { background-color: var(--surface-light) .spinner { width: 16px; height: 16px; border: 2px solid currentColor; border-top-color: transparent; border-radius: 50%; animation: spin 1s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } -/* --- 9. NEW: Command Preview --- */ -#command-preview { - display: none; /* Hidden by default */ - position: absolute; - bottom: 100%; /* Position it right above the input area */ - left: 1.5rem; - right: 1.5rem; - background-color: var(--bg-dark); - border: 1px solid var(--border-dark); - border-bottom: none; - border-radius: 8px 8px 0 0; - padding: 1rem; - max-height: 200px; - overflow-y: auto; - box-shadow: 0 -4px 12px rgba(0,0,0,0.2); -} -body.light-theme #command-preview { - background-color: var(--bg-light); - border-color: var(--border-light); +/* --- 9. Command Preview --- */ +#command-preview { display: none; position: absolute; bottom: 100%; left: 1.5rem; right: 1.5rem; background-color: var(--bg-dark); border: 1px solid var(--border-dark); border-bottom: none; border-radius: 8px 8px 0 0; padding: 1rem; max-height: 200px; overflow-y: auto; box-shadow: 0 -4px 12px rgba(0,0,0,0.2); } +body.light-theme #command-preview { background-color: var(--bg-light); border-color: var(--border-light); } +#command-preview h4 { margin-top: 0; margin-bottom: 0.75rem; font-size: 0.9rem; color: var(--text-secondary-dark); text-transform: uppercase; } +body.light-theme #command-preview h4 { color: var(--text-secondary-light); } +#command-preview ul { list-style-type: none; padding: 0; margin: 0; } +#command-preview li { padding: 0.5rem 0; font-size: 0.9rem; color: var(--text-secondary-dark); } +body.light-theme #command-preview li { color: var(--text-secondary-light); } +#command-preview li strong { color: var(--text-primary-dark); margin-right: 0.5rem; } +body.light-theme #command-preview li strong { color: var(--text-primary-light); } + +/* --- 10. Player List Turn Indicator --- */ +.turn-indicator { + margin-left: auto; /* Pushes the indicator to the far right */ + font-size: 1rem; + color: var(--success-green); + opacity: 0; /* Hidden by default */ + transition: opacity 0.3s; } -#command-preview h4 { - margin-top: 0; - margin-bottom: 0.75rem; - font-size: 0.9rem; - color: var(--text-secondary-dark); - text-transform: uppercase; -} -body.light-theme #command-preview h4 { - color: var(--text-secondary-light); +#player-list li.action-submitted .turn-indicator { + opacity: 1; /* Revealed when action is submitted */ } -#command-preview ul { - list-style-type: none; - padding: 0; - margin: 0; +#player-list li.action-submitted .player-name { + color: var(--text-primary-dark); /* Make the name more prominent */ } -#command-preview li { - padding: 0.5rem 0; - font-size: 0.9rem; - color: var(--text-secondary-dark); -} -body.light-theme #command-preview li { - color: var(--text-secondary-light); -} - -#command-preview li strong { - color: var(--text-primary-dark); - margin-right: 0.5rem; -} -body.light-theme #command-preview li strong { +body.light-theme #player-list li.action-submitted .player-name { color: var(--text-primary-light); } \ No newline at end of file