Divided up ui.js functions.

This commit is contained in:
Nighthawk
2025-09-05 02:02:26 -04:00
parent ec6f1065bf
commit 944b4bdc8a
8 changed files with 431 additions and 346 deletions
+59 -1
View File
@@ -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:
+25 -31
View File
@@ -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.")
if self.db.delete_session(token):
logger.info(f"Session token {token[:8]}... deleted from database.")
+67
View File
@@ -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);
}
});
}
+52
View File
@@ -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 = '<h4>Commands</h4><ul>';
for (const [cmd, desc] of Object.entries(COMMANDS)) {
// Show commands that start with what the user has typed
if (cmd.startsWith(typedCmd)) {
html += `<li><strong>${cmd}</strong>: ${desc}</li>`;
}
}
html += '</ul>';
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);
});
}
+59
View File
@@ -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'),
};
+76
View File
@@ -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,
};
}
+63 -215
View File
@@ -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 = '<h4>Commands</h4><ul>';
for (const [cmd, desc] of Object.entries(COMMANDS)) {
if (cmd.startsWith(typedCmd)) {
html += `<li><strong>${cmd}</strong>: ${desc}</li>`;
}
}
html += '</ul>';
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;
}
+30 -99
View File
@@ -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);
}