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
';
+ for (const [cmd, desc] of Object.entries(COMMANDS)) {
+ // Show commands that start with what the user has typed
+ if (cmd.startsWith(typedCmd)) {
+ html += `- ${cmd}: ${desc}
`;
+ }
+ }
+ html += '
';
+ 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
';
- for (const [cmd, desc] of Object.entries(COMMANDS)) {
- if (cmd.startsWith(typedCmd)) {
- html += `- ${cmd}: ${desc}
`;
- }
- }
- html += '
';
- 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