mirror of
https://github.com/Nighthawk42/VDM.git
synced 2026-08-30 04:30:21 +00:00
Divided up ui.js functions.
This commit is contained in:
@@ -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
@@ -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.")
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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'),
|
||||
};
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user