diff --git a/dDashboardServer/DashboardServer.cpp b/dDashboardServer/DashboardServer.cpp index e7f5e8a2..4cea853b 100644 --- a/dDashboardServer/DashboardServer.cpp +++ b/dDashboardServer/DashboardServer.cpp @@ -28,6 +28,8 @@ #include "WSRoutes.h" #include "AuthRoutes.h" #include "AuthMiddleware.h" +#include "DashboardAuthService.h" +#include "AuthTokenHandler.h" namespace Game { Logger* logger = nullptr; @@ -130,6 +132,12 @@ int main(int argc, char** argv) { return EXIT_FAILURE; } + // Set up WebSocket authentication callback using consolidated handler + Game::web.SetWSAuthCallback([](const std::string& token) -> bool { + auto result = AuthTokenHandler::ValidateToken(token); + return result.isValid; + }); + // Register global middleware Game::web.AddGlobalMiddleware(std::make_shared()); diff --git a/dDashboardServer/auth/AuthMiddleware.cpp b/dDashboardServer/auth/AuthMiddleware.cpp index a81bb874..9d7a4cfb 100644 --- a/dDashboardServer/auth/AuthMiddleware.cpp +++ b/dDashboardServer/auth/AuthMiddleware.cpp @@ -1,132 +1,6 @@ #include "AuthMiddleware.h" -#include "DashboardAuthService.h" -#include "Game.h" -#include "Logger.h" -#include -#include - -// Helper to extract cookie value from header -static std::string ExtractCookieValue(const std::string& cookieHeader, const std::string& cookieName) { - std::string searchStr = cookieName + "="; - size_t pos = cookieHeader.find(searchStr); - - if (pos == std::string::npos) { - return ""; - } - - size_t valueStart = pos + searchStr.length(); - size_t valueEnd = cookieHeader.find(";", valueStart); - - if (valueEnd == std::string::npos) { - valueEnd = cookieHeader.length(); - } - - std::string value = cookieHeader.substr(valueStart, valueEnd - valueStart); - - // URL decode the value - std::string decoded; - for (size_t i = 0; i < value.length(); ++i) { - if (value[i] == '%' && i + 2 < value.length()) { - std::string hex = value.substr(i + 1, 2); - char* endptr; - int charCode = static_cast(std::strtol(hex.c_str(), &endptr, 16)); - if (endptr - hex.c_str() == 2) { - decoded += static_cast(charCode); - i += 2; - continue; - } - } - decoded += value[i]; - } - - return decoded; -} - -std::string AuthMiddleware::ExtractTokenFromQueryString(const std::string& queryString) { - if (queryString.empty()) { - return ""; - } - - // Parse query string to find token parameter - // Expected format: "?token=eyJhbGc..." - std::string tokenPrefix = "token="; - size_t tokenPos = queryString.find(tokenPrefix); - - if (tokenPos == std::string::npos) { - return ""; - } - - // Extract token value (from "token=" to next "&" or end of string) - size_t valueStart = tokenPos + tokenPrefix.length(); - size_t valueEnd = queryString.find("&", valueStart); - - if (valueEnd == std::string::npos) { - valueEnd = queryString.length(); - } - - return queryString.substr(valueStart, valueEnd - valueStart); -} - -std::string AuthMiddleware::ExtractTokenFromCookies(const std::string& cookieHeader) { - if (cookieHeader.empty()) { - return ""; - } - - // Extract dashboardToken cookie value - return ExtractCookieValue(cookieHeader, "dashboardToken"); -} - -std::string AuthMiddleware::ExtractTokenFromAuthHeader(const std::string& authHeader) { - if (authHeader.empty()) { - return ""; - } - - // Check for "Bearer " format - if (authHeader.substr(0, 7) == "Bearer ") { - return authHeader.substr(7); - } - - // Check for "Token " format - if (authHeader.substr(0, 6) == "Token ") { - return authHeader.substr(6); - } - - // If no prefix, assume raw token - return authHeader; -} +#include "AuthTokenHandler.h" bool AuthMiddleware::Process(HTTPContext& context, HTTPReply& reply) { - // Try to extract token from various sources (in priority order) - std::string token = ExtractTokenFromQueryString(context.queryString); - - if (token.empty()) { - const std::string& cookieHeader = context.GetHeader("Cookie"); - token = ExtractTokenFromCookies(cookieHeader); - } - - if (token.empty()) { - const std::string& authHeader = context.GetHeader("Authorization"); - token = ExtractTokenFromAuthHeader(authHeader); - } - - // If we found a token, try to verify it - if (!token.empty()) { - std::string username; - uint8_t gmLevel{}; - - if (DashboardAuthService::VerifyToken(token, username, gmLevel)) { - context.isAuthenticated = true; - context.authenticatedUser = username; - context.gmLevel = gmLevel; - LOG_DEBUG("User %s authenticated via API token (GM level %d)", username.c_str(), gmLevel); - return true; - } else { - LOG_DEBUG("Invalid authentication token provided"); - return true; // Continue - let routes decide if auth is required - } - } - - // No token found - continue without authentication - // Routes can use RequireAuthMiddleware to enforce authentication - return true; + return AuthTokenHandler::ProcessHTTPContext(context, reply); } diff --git a/dDashboardServer/auth/AuthMiddleware.h b/dDashboardServer/auth/AuthMiddleware.h index b4c00ac5..27d2a0e0 100644 --- a/dDashboardServer/auth/AuthMiddleware.h +++ b/dDashboardServer/auth/AuthMiddleware.h @@ -23,12 +23,6 @@ public: bool Process(HTTPContext& context, HTTPReply& reply) override; std::string GetName() const override { return "AuthMiddleware"; } - -private: - // Extract token from various sources - static std::string ExtractTokenFromQueryString(const std::string& queryString); - static std::string ExtractTokenFromCookies(const std::string& cookieHeader); - static std::string ExtractTokenFromAuthHeader(const std::string& authHeader); }; #endif // !__AUTHMIDDLEWARE_H__ diff --git a/dDashboardServer/auth/AuthTokenHandler.cpp b/dDashboardServer/auth/AuthTokenHandler.cpp new file mode 100644 index 00000000..e49ee66e --- /dev/null +++ b/dDashboardServer/auth/AuthTokenHandler.cpp @@ -0,0 +1,186 @@ +#include "AuthTokenHandler.h" +#include "DashboardAuthService.h" +#include "Game.h" +#include "Logger.h" +#include "HTTPContext.h" +#include "Web.h" + +// Helper to extract cookie value from header +static std::string ExtractCookieValue(const std::string& cookieHeader, const std::string& cookieName) { + std::string searchStr = cookieName + "="; + size_t pos = cookieHeader.find(searchStr); + + if (pos == std::string::npos) { + return ""; + } + + size_t valueStart = pos + searchStr.length(); + size_t valueEnd = cookieHeader.find(";", valueStart); + + if (valueEnd == std::string::npos) { + valueEnd = cookieHeader.length(); + } + + std::string value = cookieHeader.substr(valueStart, valueEnd - valueStart); + + // URL decode the value + std::string decoded; + for (size_t i = 0; i < value.length(); ++i) { + if (value[i] == '%' && i + 2 < value.length()) { + std::string hex = value.substr(i + 1, 2); + char* endptr; + int charCode = static_cast(std::strtol(hex.c_str(), &endptr, 16)); + if (endptr - hex.c_str() == 2) { + decoded += static_cast(charCode); + i += 2; + continue; + } + } + decoded += value[i]; + } + + return decoded; +} + +std::string AuthTokenHandler::ExtractTokenFromQueryString(const std::string& queryString) { + if (queryString.empty()) { + return ""; + } + + // Parse query string to find token parameter + // Expected format: "?token=eyJhbGc..." + std::string tokenPrefix = "token="; + size_t tokenPos = queryString.find(tokenPrefix); + + if (tokenPos == std::string::npos) { + return ""; + } + + // Extract token value (from "token=" to next "&" or end of string) + size_t valueStart = tokenPos + tokenPrefix.length(); + size_t valueEnd = queryString.find("&", valueStart); + + if (valueEnd == std::string::npos) { + valueEnd = queryString.length(); + } + + return queryString.substr(valueStart, valueEnd - valueStart); +} + +std::string AuthTokenHandler::ExtractTokenFromCookieHeader(const std::string& cookieHeader) { + if (cookieHeader.empty()) { + return ""; + } + + // Extract dashboardToken cookie value + return ExtractCookieValue(cookieHeader, "dashboardToken"); +} + +std::string AuthTokenHandler::ExtractTokenFromAuthHeader(const std::string& authHeader) { + if (authHeader.empty()) { + return ""; + } + + // Check for "Bearer " format + if (authHeader.length() >= 7 && authHeader.substr(0, 7) == "Bearer ") { + return authHeader.substr(7); + } + + // Check for "Token " format + if (authHeader.length() >= 6 && authHeader.substr(0, 6) == "Token ") { + return authHeader.substr(6); + } + + // If no prefix, assume raw token + return authHeader; +} + +std::string AuthTokenHandler::ExtractToken( + const std::string& queryString, + const std::string& cookieHeader, + const std::string& authHeader +) { + // Try in priority order: query string, cookie, auth header + std::string token = ExtractTokenFromQueryString(queryString); + + if (!token.empty()) { + return token; + } + + token = ExtractTokenFromCookieHeader(cookieHeader); + + if (!token.empty()) { + return token; + } + + token = ExtractTokenFromAuthHeader(authHeader); + + return token; +} + +AuthTokenHandler::TokenValidationResult AuthTokenHandler::ValidateToken(const std::string& token) { + TokenValidationResult result; + + if (token.empty()) { + result.isValid = false; + result.errorMessage = "No token provided"; + return result; + } + + // Verify JWT token + std::string username; + uint8_t gmLevel = 0; + + if (!DashboardAuthService::VerifyToken(token, username, gmLevel)) { + result.isValid = false; + result.errorMessage = "Invalid or expired token"; + LOG_DEBUG("Token validation failed"); + return result; + } + + result.isValid = true; + result.username = username; + result.gmLevel = gmLevel; + LOG_DEBUG("Token validated successfully for user: %s (GM Level: %d)", username.c_str(), gmLevel); + return result; +} + +AuthTokenHandler::TokenValidationResult AuthTokenHandler::ExtractAndValidateToken( + const std::string& queryString, + const std::string& cookieHeader, + const std::string& authHeader +) { + TokenValidationResult result; + + // Extract token from any source + std::string token = ExtractToken(queryString, cookieHeader, authHeader); + + if (token.empty()) { + result.isValid = false; + result.errorMessage = "No authentication token found"; + return result; + } + + // Validate the token + return ValidateToken(token); +} + +bool AuthTokenHandler::ProcessHTTPContext(HTTPContext& context, HTTPReply& reply) { + // Extract and validate token from all available sources + const std::string& queryString = context.queryString; + const std::string& cookieHeader = context.GetHeader("Cookie"); + const std::string& authHeader = context.GetHeader("Authorization"); + + auto result = ExtractAndValidateToken(queryString, cookieHeader, authHeader); + + if (result.isValid) { + context.isAuthenticated = true; + context.authenticatedUser = result.username; + context.gmLevel = result.gmLevel; + LOG_DEBUG("User %s authenticated via API token (GM level %d)", result.username.c_str(), result.gmLevel); + return true; + } else { + LOG_DEBUG("Authentication token validation failed: %s", result.errorMessage.c_str()); + return true; // Continue - let routes decide if auth is required + } +} diff --git a/dDashboardServer/auth/AuthTokenHandler.h b/dDashboardServer/auth/AuthTokenHandler.h new file mode 100644 index 00000000..d4bf7881 --- /dev/null +++ b/dDashboardServer/auth/AuthTokenHandler.h @@ -0,0 +1,90 @@ +#pragma once + +#include +#include + +/** + * Centralized authentication token handler + * Consolidates token extraction from multiple sources and validation + * Used by both HTTP API routes and WebSocket connections + */ +class AuthTokenHandler { +public: + /** + * Result of token extraction and validation + */ + struct TokenValidationResult { + bool isValid{false}; + std::string username{}; + uint8_t gmLevel{0}; + std::string errorMessage{}; + }; + + /** + * Extract token from query string + * Expected format: "?token=eyJhbGc..." or "?token=xyz&other=abc" + * @param queryString The query string from the request + * @return The token value, or empty string if not found + */ + static std::string ExtractTokenFromQueryString(const std::string& queryString); + + /** + * Extract token from Cookie header + * Looks for "dashboardToken=" in the cookie string + * @param cookieHeader The Cookie header value + * @return The token value, or empty string if not found + */ + static std::string ExtractTokenFromCookieHeader(const std::string& cookieHeader); + + /** + * Extract token from Authorization header + * Supports "Bearer ", "Token ", or raw token + * @param authHeader The Authorization header value + * @return The token value, or empty string if not found + */ + static std::string ExtractTokenFromAuthHeader(const std::string& authHeader); + + /** + * Extract token from any available source + * Tries in priority order: query string, cookie, auth header + * @param queryString The query string + * @param cookieHeader The Cookie header + * @param authHeader The Authorization header + * @return The first token found, or empty string + */ + static std::string ExtractToken( + const std::string& queryString, + const std::string& cookieHeader, + const std::string& authHeader + ); + + /** + * Validate a token and extract user information + * Checks JWT signature, expiration, and user permissions + * @param token The JWT token + * @return TokenValidationResult with validity status and user info + */ + static TokenValidationResult ValidateToken(const std::string& token); + + /** + * Convenience method: Extract and validate token in one call + * @param queryString Query string from request + * @param cookieHeader Cookie header from request + * @param authHeader Authorization header from request + * @return TokenValidationResult with validity status and user info + */ + static TokenValidationResult ExtractAndValidateToken( + const std::string& queryString, + const std::string& cookieHeader, + const std::string& authHeader + ); + + /** + * Process authentication for HTTP middleware use + * Extracts and validates token from request, sets HTTPContext properties + * @param context HTTP request context (modified to include auth info) + * @param reply HTTP reply (not modified unless validation fails silently) + * @return true to continue middleware chain, false to stop + */ + static bool ProcessHTTPContext(class HTTPContext& context, class HTTPReply& reply); +}; diff --git a/dDashboardServer/auth/CMakeLists.txt b/dDashboardServer/auth/CMakeLists.txt index 8f7a4aec..86217181 100644 --- a/dDashboardServer/auth/CMakeLists.txt +++ b/dDashboardServer/auth/CMakeLists.txt @@ -2,6 +2,7 @@ set(DASHBOARDAUTH_SOURCES "JWTUtils.cpp" "DashboardAuthService.cpp" "AuthMiddleware.cpp" + "AuthTokenHandler.cpp" "RequireAuthMiddleware.cpp" ) diff --git a/dDashboardServer/routes/WSRoutes.cpp b/dDashboardServer/routes/WSRoutes.cpp index 673a5872..29b77565 100644 --- a/dDashboardServer/routes/WSRoutes.cpp +++ b/dDashboardServer/routes/WSRoutes.cpp @@ -13,8 +13,29 @@ void RegisterWSRoutes() { Game::web.RegisterWSSubscription("player_joined"); Game::web.RegisterWSSubscription("player_left"); - // dashboard_update: Broadcasts complete dashboard data every 2 seconds - // Other subscriptions can be triggered by events from the master server + // Account subscriptions + Game::web.RegisterWSSubscription("account_update"); + Game::web.RegisterWSSubscription("accounts_table_update"); + Game::web.RegisterWSSubscription("account_list_changed"); + + // Character subscriptions + Game::web.RegisterWSSubscription("character_update"); + Game::web.RegisterWSSubscription("characters_table_update"); + Game::web.RegisterWSSubscription("character_list_changed"); + + // Property subscriptions + Game::web.RegisterWSSubscription("property_update"); + Game::web.RegisterWSSubscription("properties_table_update"); + Game::web.RegisterWSSubscription("property_list_changed"); + + // Play Key subscriptions + Game::web.RegisterWSSubscription("play_key_update"); + Game::web.RegisterWSSubscription("play_keys_table_update"); + Game::web.RegisterWSSubscription("play_key_list_changed"); + + // Bug Report subscriptions + Game::web.RegisterWSSubscription("bug_report_update"); + Game::web.RegisterWSSubscription("bug_reports_table_update"); } void BroadcastDashboardUpdate() { @@ -33,3 +54,179 @@ void BroadcastDashboardUpdate() { // Broadcast to all connected WebSocket clients subscribed to "dashboard_update" Game::web.SendWSMessage("dashboard_update", data); } + +void BroadcastAccountUpdate(uint32_t accountId) { + try { + // Get updated account data + nlohmann::json accountData = Database::Get()->GetAccountById(accountId); + + // Only broadcast if account was found + if (!accountData.contains("error")) { + accountData["event"] = "account_update"; + accountData["accountId"] = accountId; + Game::web.SendWSMessage("account_update", accountData); + } + } catch (const std::exception& ex) { + LOG_DEBUG("Error broadcasting account update: %s", ex.what()); + } +} + +void BroadcastAccountsTableUpdate() { + try { + // Broadcast that the accounts table has been modified + // Clients should reload the current page of data + nlohmann::json message = { + {"event", "accounts_table_update"}, + {"message", "Accounts table has been updated"} + }; + Game::web.SendWSMessage("accounts_table_update", message); + } catch (const std::exception& ex) { + LOG_DEBUG("Error broadcasting accounts table update: %s", ex.what()); + } +} + +void BroadcastAccountListChanged() { + try { + // Broadcast that accounts list changed (new account created or deleted) + nlohmann::json message = { + {"event", "account_list_changed"}, + {"totalAccounts", Database::Get()->GetAccountCount()} + }; + Game::web.SendWSMessage("account_list_changed", message); + } catch (const std::exception& ex) { + LOG_DEBUG("Error broadcasting account list change: %s", ex.what()); + } +} + +void BroadcastCharacterUpdate(uint32_t characterId) { + try { + // Get updated character data and broadcast + nlohmann::json message = { + {"event", "character_update"}, + {"characterId", characterId} + }; + Game::web.SendWSMessage("character_update", message); + } catch (const std::exception& ex) { + LOG_DEBUG("Error broadcasting character update: %s", ex.what()); + } +} + +void BroadcastCharactersTableUpdate() { + try { + nlohmann::json message = { + {"event", "characters_table_update"}, + {"message", "Characters table has been updated"} + }; + Game::web.SendWSMessage("characters_table_update", message); + } catch (const std::exception& ex) { + LOG_DEBUG("Error broadcasting characters table update: %s", ex.what()); + } +} + +void BroadcastCharacterListChanged() { + try { + nlohmann::json message = { + {"event", "character_list_changed"}, + {"totalCharacters", Database::Get()->GetCharacterCount()} + }; + Game::web.SendWSMessage("character_list_changed", message); + } catch (const std::exception& ex) { + LOG_DEBUG("Error broadcasting character list change: %s", ex.what()); + } +} + +void BroadcastPropertyUpdate(uint32_t propertyId) { + try { + nlohmann::json message = { + {"event", "property_update"}, + {"propertyId", propertyId} + }; + Game::web.SendWSMessage("property_update", message); + } catch (const std::exception& ex) { + LOG_DEBUG("Error broadcasting property update: %s", ex.what()); + } +} + +void BroadcastPropertiesTableUpdate() { + try { + nlohmann::json message = { + {"event", "properties_table_update"}, + {"message", "Properties table has been updated"} + }; + Game::web.SendWSMessage("properties_table_update", message); + } catch (const std::exception& ex) { + LOG_DEBUG("Error broadcasting properties table update: %s", ex.what()); + } +} + +void BroadcastPropertyListChanged() { + try { + nlohmann::json message = { + {"event", "property_list_changed"}, + {"totalProperties", 0} // TODO: Get from database + }; + Game::web.SendWSMessage("property_list_changed", message); + } catch (const std::exception& ex) { + LOG_DEBUG("Error broadcasting property list change: %s", ex.what()); + } +} + +void BroadcastPlayKeyUpdate(uint32_t playKeyId) { + try { + nlohmann::json message = { + {"event", "play_key_update"}, + {"playKeyId", playKeyId} + }; + Game::web.SendWSMessage("play_key_update", message); + } catch (const std::exception& ex) { + LOG_DEBUG("Error broadcasting play key update: %s", ex.what()); + } +} + +void BroadcastPlayKeysTableUpdate() { + try { + nlohmann::json message = { + {"event", "play_keys_table_update"}, + {"message", "Play Keys table has been updated"} + }; + Game::web.SendWSMessage("play_keys_table_update", message); + } catch (const std::exception& ex) { + LOG_DEBUG("Error broadcasting play keys table update: %s", ex.what()); + } +} + +void BroadcastPlayKeyListChanged() { + try { + nlohmann::json message = { + {"event", "play_key_list_changed"}, + {"totalPlayKeys", 0} // TODO: Get from database + }; + Game::web.SendWSMessage("play_key_list_changed", message); + } catch (const std::exception& ex) { + LOG_DEBUG("Error broadcasting play key list change: %s", ex.what()); + } +} + +void BroadcastBugReportUpdate(uint32_t bugReportId) { + try { + nlohmann::json message = { + {"event", "bug_report_update"}, + {"bugReportId", bugReportId} + }; + Game::web.SendWSMessage("bug_report_update", message); + } catch (const std::exception& ex) { + LOG_DEBUG("Error broadcasting bug report update: %s", ex.what()); + } +} + +void BroadcastBugReportsTableUpdate() { + try { + nlohmann::json message = { + {"event", "bug_reports_table_update"}, + {"message", "Bug Reports table has been updated"} + }; + Game::web.SendWSMessage("bug_reports_table_update", message); + } catch (const std::exception& ex) { + LOG_DEBUG("Error broadcasting bug reports table update: %s", ex.what()); + } +} diff --git a/dDashboardServer/routes/WSRoutes.h b/dDashboardServer/routes/WSRoutes.h index 1e2f2352..c7ff3b6e 100644 --- a/dDashboardServer/routes/WSRoutes.h +++ b/dDashboardServer/routes/WSRoutes.h @@ -1,4 +1,31 @@ #pragma once +#include "json.hpp" +#include + void RegisterWSRoutes(); void BroadcastDashboardUpdate(); + +// Account broadcasts +void BroadcastAccountUpdate(uint32_t accountId); +void BroadcastAccountsTableUpdate(); +void BroadcastAccountListChanged(); + +// Character broadcasts +void BroadcastCharacterUpdate(uint32_t characterId); +void BroadcastCharactersTableUpdate(); +void BroadcastCharacterListChanged(); + +// Property broadcasts +void BroadcastPropertyUpdate(uint32_t propertyId); +void BroadcastPropertiesTableUpdate(); +void BroadcastPropertyListChanged(); + +// Play Key broadcasts +void BroadcastPlayKeyUpdate(uint32_t playKeyId); +void BroadcastPlayKeysTableUpdate(); +void BroadcastPlayKeyListChanged(); + +// Bug Report broadcasts +void BroadcastBugReportUpdate(uint32_t bugReportId); +void BroadcastBugReportsTableUpdate(); diff --git a/dDashboardServer/static/css/dashboard.css b/dDashboardServer/static/css/dashboard.css index ff397ebc..b057392a 100644 --- a/dDashboardServer/static/css/dashboard.css +++ b/dDashboardServer/static/css/dashboard.css @@ -1,381 +1,148 @@ -/* Minimal custom styling - mostly Bootstrap5 utilities */ +/* DarkflameServer Dashboard - Minimal Branding */ + +:root { + --primary-color: #667eea; + --secondary-color: #764ba2; + --dark-bg: #0f0f1e; + --card-bg: rgba(26, 26, 46, 0.8); +} body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; - margin: 0; - padding: 0; + background: linear-gradient(135deg, var(--dark-bg) 0%, #1a1a2e 100%); + display: grid; + grid-template-columns: 280px 1fr; + grid-template-rows: auto 1fr auto; + min-height: 100vh; } -body.d-flex.bg-dark.text-white { - background-color: #0d0d0d; - color: #fff; -} - -/* Sidebar adjustments */ +/* Grid Layout */ .navbar.flex-column { - box-shadow: 0.125rem 0 0.25rem rgba(0, 0, 0, 0.075); + grid-row: 1 / -1; + grid-column: 1; + background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%); + box-shadow: 2px 0 15px rgba(0, 0, 0, 0.3); + overflow-y: auto; } -.navbar.flex-column .navbar-nav { - width: 100%; +main { + grid-column: 2; + grid-row: 2; + display: flex; + flex-direction: column; } +.navbar:not(.flex-column) { + grid-column: 1 / -1; + grid-row: 1; + background: rgba(26, 26, 46, 0.95); + border-bottom: 1px solid rgba(102, 126, 234, 0.2); +} + +footer { + grid-column: 1 / -1; + grid-row: 3; + background: rgba(26, 26, 46, 0.95); + border-top: 1px solid rgba(102, 126, 234, 0.2); +} + +/* Sidebar Navigation */ .navbar.flex-column .nav-link { - padding: 0.75rem 1.25rem; border-left: 3px solid transparent; transition: all 0.3s ease; } .navbar.flex-column .nav-link:hover { - background-color: rgba(255, 255, 255, 0.1); - border-left-color: #667eea; - padding-left: 1.5rem; + background-color: rgba(102, 126, 234, 0.15); + border-left-color: var(--primary-color); } .navbar.flex-column .nav-link.active { - background-color: rgba(255, 255, 255, 0.1); - border-left-color: #667eea; + background-color: rgba(102, 126, 234, 0.25); + border-left-color: var(--primary-color); + color: var(--primary-color); } -main { - display: flex; - flex-direction: column; - padding: 0; - min-height: 100vh; -} - -/* Responsive design */ -@media (max-width: 991.98px) { - body { - display: block !important; - } - - main { - margin-left: 0 !important; - } - - .navbar.flex-column { - width: 100% !important; - height: auto !important; - position: relative !important; - top: auto !important; - start: auto !important; - } -} - -.navbar { - box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); -} - -.username { - font-weight: 600; - color: #667eea; - font-size: 1.1em; -} - -.logout-btn { - padding: 10px 20px; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: white; - border: none; - border-radius: 5px; - cursor: pointer; - font-weight: 600; - transition: opacity 0.3s; -} - -.logout-btn:hover { - opacity: 0.9; -} - -.grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); - gap: 20px; - margin-bottom: 20px; -} - -.stat { - display: flex; - justify-content: space-between; - align-items: center; - padding: 10px 0; - border-bottom: 1px solid #eee; -} - -.stat:last-child { - border-bottom: none; -} - -.stat-label { - color: #666; - font-weight: 500; -} - -.stat-value { - color: #333; - font-weight: bold; - font-size: 1.2em; -} - -.status { - display: inline-block; - padding: 5px 15px; - border-radius: 20px; - font-size: 0.9em; - font-weight: bold; -} - -.status.online { - background: #4caf50; - color: white; -} - -.status.offline { - background: #f44336; - color: white; -} - -.world-list { - max-height: 300px; - overflow-y: auto; -} - -.world-item { - padding: 15px; - background: #f5f5f5; - border-radius: 5px; - margin-bottom: 10px; -} - -.world-item h3 { - color: #333; - margin-bottom: 8px; -} - -.world-detail { - color: #666; - font-size: 0.9em; - margin: 3px 0; -} - -.loading { - text-align: center; - padding: 20px; - color: #666; -} - -/* Dark theme for data tables and containers */ - -/* Container margin for sidebar layout */ -.account-view-container, -.accounts-container, -.characters-container, -.play-keys-container, -.properties-container, -.bug-reports-container { - margin-left: 280px; - padding: 20px; -} - -/* Table card styling */ -.table-card { - background-color: #1a1a1a; - border: 1px solid #333; - border-radius: 0.5rem; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); -} - -.table-header { - background-color: #222; - padding: 1.5rem; - border-bottom: 1px solid #333; - border-radius: 0.5rem 0.5rem 0 0; -} - -.table-header h2 { - margin: 0; - color: #fff; -} - -.table-body { - padding: 1.5rem; -} - -/* Bootstrap card overrides for dark theme */ +/* Cards */ .card { - background-color: #1a1a1a; - border: 1px solid #333; - border-radius: 0.5rem; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); - margin-bottom: 1.5rem; + background: var(--card-bg); + border: 1px solid rgba(102, 126, 234, 0.2); + transition: all 0.3s ease; +} + +.card:hover { + border-color: rgba(102, 126, 234, 0.4); + box-shadow: 0 8px 20px rgba(102, 126, 234, 0.15); } .card-header { - background-color: #222; - padding: 1.5rem; - border-bottom: 1px solid #333; - border-radius: 0.5rem 0.5rem 0 0; + background: linear-gradient(135deg, rgba(102, 126, 234, 0.2) 0%, rgba(118, 75, 162, 0.2) 100%); + border-bottom: 1px solid rgba(102, 126, 234, 0.3); } -.card-header h2 { - margin: 0; - color: #fff; -} - -.card-body { - padding: 1.5rem; -} - -/* Table styling */ +/* Tables */ .table-dark { - color: #fff; -} - -.table-dark thead { - background-color: #2a2a2a; + border-color: rgba(102, 126, 234, 0.2); } .table-dark thead th { - border-bottom: 2px solid #444; - color: #aaa; - font-weight: 600; - text-transform: uppercase; - font-size: 0.875rem; - padding: 1rem; -} - -.table-dark tbody td { - padding: 0.875rem 1rem; - border-bottom: 1px solid #333; - vertical-align: middle; + background: rgba(102, 126, 234, 0.15); + border-bottom: 2px solid rgba(102, 126, 234, 0.3); } .table-dark tbody tr:hover { - background-color: #252525; -} - -/* DataTables customization */ -.dataTables_wrapper { - color: #fff; -} - -.dataTables_wrapper .dataTables_filter input { - background-color: #2a2a2a; - color: #fff; - border: 1px solid #444; - border-radius: 0.25rem; - padding: 0.4rem 0.6rem; -} - -.dataTables_wrapper .dataTables_filter input::placeholder { - color: #888; -} - -.dataTables_wrapper .dataTables_info { - color: #aaa; - padding: 1rem; + background-color: rgba(102, 126, 234, 0.1); } +/* DataTables */ +.dataTables_wrapper .dataTables_filter input, .dataTables_wrapper .dataTables_paginate .paginate_button { - background: #2a2a2a; - color: #fff; - border: 1px solid #444; - margin: 0 2px; - padding: 0.4rem 0.8rem; - border-radius: 0.25rem; - cursor: pointer; - transition: all 0.2s; -} - -.dataTables_wrapper .dataTables_paginate .paginate_button:hover { - background: #3a3a3a; - border: 1px solid #555; + background-color: rgba(42, 42, 58, 0.8); + border: 1px solid rgba(102, 126, 234, 0.3); } .dataTables_wrapper .dataTables_paginate .paginate_button.current { - background: #0d6efd; - border: 1px solid #0d6efd; + background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); + border: none; } -.dataTables_wrapper .dataTables_paginate .paginate_button.disabled { - opacity: 0.5; - cursor: not-allowed; +/* Buttons */ +.btn-primary { + background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); + border: none; } -.dataTables_wrapper .dataTables_length select { - background-color: #2a2a2a; - color: #fff; - border: 1px solid #444; - padding: 0.4rem 0.6rem; - border-radius: 0.25rem; +.btn-primary:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); } -/* Detail grid layout */ -.detail-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); - gap: 1.5rem; +.logout-btn { + background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); } -.detail-item { - background-color: #0a0a0a; - padding: 1rem; - border-radius: 0.25rem; - border-left: 3px solid #0d6efd; +/* Custom Badge Styles */ +.badge-banned { + background-color: #dc3545; } -.detail-label { - color: #999; - font-size: 0.875rem; - text-transform: uppercase; - letter-spacing: 0.5px; - margin-bottom: 0.5rem; +.badge-locked { + background-color: #fd7e14; } -.detail-value { - color: #fff; - font-size: 1rem; - font-weight: 500; -} - -/* Badge styling */ -.badge { - display: inline-block; - padding: 0.35rem 0.6rem; - font-size: 0.8rem; - font-weight: 500; - border-radius: 0.25rem; +.badge-gm { + background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); } .badge-active { background-color: #28a745; - color: #fff; } .badge-inactive { background-color: #6c757d; - color: #fff; -} - -.badge-banned { - background-color: #dc3545; - color: #fff; -} - -.badge-locked { - background-color: #ffc107; - color: #000; -} - -.badge-gm { - background-color: #17a2b8; - color: #fff; } .badge-approved { background-color: #28a745; - color: #fff; } .badge-pending { @@ -383,113 +150,136 @@ main { color: #000; } -/* Button styling */ +.badge-primary { + background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); +} + +/* Status Indicators */ +.status.online::before, +.status.offline::before { + content: ''; + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: 4px; +} + +.status.online::before { + background: #4caf50; +} + +.status.offline::before { + background: #f44336; +} + +/* Detail Grid */ +.detail-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 15px; + margin-bottom: 20px; +} + +.detail-item { + border-left: 3px solid var(--primary-color); + padding-left: 12px; +} + +.detail-label { + font-size: 0.875rem; + color: #999; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 5px; +} + +.detail-value { + font-size: 1rem; + color: #fff; +} + .button-group { display: flex; - gap: 0.5rem; - margin-top: 1.5rem; -} - -.btn { - padding: 0.5rem 1rem; - border-radius: 0.25rem; - border: none; - cursor: pointer; - font-weight: 500; - transition: all 0.2s; -} - -.btn-sm { - padding: 0.25rem 0.5rem; - font-size: 0.75rem; -} - -.btn-primary { - background-color: #0d6efd; - color: #fff; -} - -.btn-primary:hover { - background-color: #0b5ed7; -} - -.btn-secondary { - background-color: #6c757d; - color: #fff; -} - -.btn-secondary:hover { - background-color: #5c636a; -} - -.btn-danger { - background-color: #dc3545; - color: #fff; -} - -.btn-danger:hover { - background-color: #c82333; -} - -.btn-info { - background-color: #17a2b8; - color: #fff; -} - -.btn-info:hover { - background-color: #138496; -} - -.btn-warning { - background-color: #ffc107; - color: #000; -} - -.btn-warning:hover { - background-color: #e0a800; -} - -/* Action buttons */ -.account-actions { - display: flex; - gap: 0.5rem; -} - -/* Utility classes */ -.text-muted { - color: #999 !important; + gap: 10px; + flex-wrap: wrap; + margin-top: 20px; } .back-link { - color: #0d6efd; + color: var(--primary-color); text-decoration: none; - margin-bottom: 1rem; + font-weight: 600; + transition: all 0.3s; display: inline-block; + margin-bottom: 20px; } .back-link:hover { - text-decoration: underline; + color: var(--secondary-color); } -.report-preview { - max-width: 200px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; +/* Container Wrappers - Use Bootstrap spacing */ +.accounts-container, +.characters-container, +.bug-reports-container, +.properties-container, +.play-keys-container, +.account-view-container { + width: 100%; } -.search-section { - margin-bottom: 1.5rem; +/* Responsive */ +@media (max-width: 991.98px) { + body { + grid-template-columns: 1fr; + } + + .navbar.flex-column { + grid-column: 1 / -1; + grid-row: 1; + flex-direction: row; + flex-wrap: wrap; + overflow-y: visible; + } + + .navbar.flex-column .nav-link { + border-left: none; + border-bottom: 2px solid transparent; + } + + .navbar.flex-column .nav-link:hover, + .navbar.flex-column .nav-link.active { + border-bottom-color: var(--primary-color); + } + + main { + grid-column: 1; + grid-row: 2; + } + + footer { + grid-column: 1; + grid-row: 3; + } + + .detail-grid { + grid-template-columns: 1fr; + } + + .table-dark { + font-size: 12px; + min-width: 100%; + } + + .table-dark th, + .table-dark td { + white-space: nowrap; + padding: 8px 6px; + } } -.search-input { - background-color: #2a2a2a; - border: 1px solid #444; - color: #fff; - padding: 0.5rem; - border-radius: 0.25rem; +@media (max-width: 575.98px) { + .navbar.flex-column .nav-link { + font-size: 11px; + } } - -.search-input::placeholder { - color: #888; -} \ No newline at end of file diff --git a/dDashboardServer/static/css/login.css b/dDashboardServer/static/css/login.css index 3c2f7b9a..ae24a43e 100644 --- a/dDashboardServer/static/css/login.css +++ b/dDashboardServer/static/css/login.css @@ -1,30 +1,296 @@ -/* Custom styling for login page on top of Bootstrap5 */ +/* Modern Login Page Styling */ -body { - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body.d-flex.bg-dark.text-white { + background: linear-gradient(135deg, #0f0f1e 0%, #1a1a2e 50%, #16213e 100%); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + min-height: 100vh; } -.card { - border-radius: 0.5rem; - box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2) !important; +/* Hide elements not needed on login */ +body .navbar, +body > footer { + display: none !important; } -.btn-primary { +body .container-fluid { + padding: 0 !important; +} + +.login-container { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + padding: 20px; +} + +.login-wrapper { + display: grid; + grid-template-columns: 1fr 1fr; + width: 100%; + max-width: 1100px; + min-height: 600px; + background: white; + border-radius: 16px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + overflow: hidden; +} + +/* Left side - Branding */ +.login-left { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + padding: 60px 40px; + display: flex; + flex-direction: column; + justify-content: space-between; + color: white; +} + +.login-branding { + text-align: center; +} + +.login-branding .logo { + font-size: 64px; + margin-bottom: 20px; +} + +.login-branding h1 { + font-size: 32px; + font-weight: 700; + margin-bottom: 8px; + letter-spacing: -0.5px; +} + +.login-branding p { + font-size: 14px; + opacity: 0.9; +} + +.login-info { + text-align: center; +} + +.login-info h3 { + font-size: 24px; + font-weight: 600; + margin-bottom: 12px; +} + +.login-info p { + font-size: 14px; + opacity: 0.85; + line-height: 1.6; +} + +/* Right side - Form */ +.login-right { + padding: 60px 40px; + display: flex; + align-items: center; + justify-content: center; +} + +.login-form-wrapper { + width: 100%; +} + +.login-form-wrapper h2 { + font-size: 28px; + font-weight: 700; + color: #1a1a2e; + margin-bottom: 30px; +} + +.login-form { + display: flex; + flex-direction: column; + gap: 20px; +} + +.form-group { + position: relative; + display: flex; + flex-direction: column; +} + +.form-group label { + font-size: 13px; + font-weight: 600; + color: #333; + margin-bottom: 8px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.form-group input { + padding: 12px 16px; + font-size: 14px; + border: 2px solid #e0e0e0; + border-radius: 8px; + transition: all 0.3s ease; + font-family: inherit; + color: #1a1a2e; +} + +.form-group input::placeholder { + color: #999; +} + +.form-group input:focus { + outline: none; + border-color: #667eea; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); +} + +.form-focus { + height: 2px; + background: linear-gradient(90deg, transparent, #667eea, transparent); + border-radius: 2px; + margin-top: -2px; + opacity: 0; + transition: opacity 0.3s ease; +} + +.form-group input:focus ~ .form-focus { + opacity: 1; +} + +.form-checkbox { + display: flex; + align-items: center; + gap: 8px; + margin: 10px 0 20px 0; +} + +.form-checkbox input[type="checkbox"] { + width: 18px; + height: 18px; + cursor: pointer; + accent-color: #667eea; +} + +.form-checkbox label { + font-size: 13px; + color: #666; + cursor: pointer; + margin: 0; + text-transform: none; + letter-spacing: normal; +} + +.btn-login { + padding: 12px 24px; + font-size: 15px; + font-weight: 600; + color: white; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border: none; + border-radius: 8px; + cursor: pointer; + transition: all 0.3s ease; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + margin-top: 10px; } -.btn-primary:hover { - background: linear-gradient(135deg, #5568d3 0%, #6a3f93 100%); +.btn-login:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4); } -.form-control:focus { - border-color: #667eea; - box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.25); +.btn-login:active { + transform: translateY(0); } -h1 { - color: #333; - font-weight: 600; +.btn-login:disabled { + opacity: 0.7; + cursor: not-allowed; +} + +.spinner { + width: 16px; + height: 16px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-top-color: white; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.alert-box { + padding: 12px 16px; + margin-bottom: 20px; + border-radius: 8px; + font-size: 13px; + display: none; +} + +.alert-box.alert-danger { + background-color: #fee; + color: #c33; + border: 1px solid #fcc; +} + +.alert-box.alert-success { + background-color: #efe; + color: #3c3; + border: 1px solid #cfc; +} + +.login-footer { + text-align: center; + margin-top: 30px; + padding-top: 20px; + border-top: 1px solid #e0e0e0; +} + +.login-footer p { + font-size: 12px; + color: #999; +} + +/* Responsive Design */ +@media (max-width: 768px) { + .login-wrapper { + grid-template-columns: 1fr; + min-height: auto; + } + + .login-left { + padding: 40px 30px; + justify-content: center; + min-height: 250px; + } + + .login-right { + padding: 40px 30px; + } + + .login-branding .logo { + font-size: 48px; + } + + .login-branding h1 { + font-size: 24px; + } + + .login-form-wrapper h2 { + font-size: 24px; + } + + .form-group input { + font-size: 16px; + } } diff --git a/dDashboardServer/static/js/dashboard.js b/dDashboardServer/static/js/dashboard.js index f619f633..0a282af5 100644 --- a/dDashboardServer/static/js/dashboard.js +++ b/dDashboardServer/static/js/dashboard.js @@ -129,6 +129,14 @@ function connectWebSocket() { event: 'subscribe', subscription: 'dashboard_update' })); + + // Mark connection as ready for other handlers + wsConnectionReady = true; + + // Initialize real-time table manager + if (typeof realtimeManager !== 'undefined') { + realtimeManager.Initialize(); + } document.getElementById('connection-status')?.remove(); }; @@ -147,6 +155,12 @@ function connectWebSocket() { if (data.event === 'dashboard_update') { updateDashboard(data); } + + // Route to generic realtime handler for all table updates + if (typeof realtimeManager !== 'undefined') { + realtimeManager.HandleMessage(data); + } + } catch (error) { console.error('Error parsing WebSocket message:', error); } diff --git a/dDashboardServer/static/js/realtime.js b/dDashboardServer/static/js/realtime.js new file mode 100644 index 00000000..9638e197 --- /dev/null +++ b/dDashboardServer/static/js/realtime.js @@ -0,0 +1,339 @@ +/** + * Generic Real-time WebSocket Updates for Dashboard Tables + * Provides reactive, non-blocking updates for all admin tables + */ + +const realtimeManager = { + tables: {}, + currentEntityId: null, + wsReady: false, + + /** + * Initialize real-time updates for a DataTable + * @param {string} entityType - Type of entity (account, character, property, etc.) + * @param {jQuery} tableElement - jQuery DataTable element + */ + RegisterTable(entityType, tableElement) { + if (!tableElement || !tableElement.DataTable) { + console.warn(`Invalid table element for entity type: ${entityType}`); + return; + } + + this.tables[entityType] = { + element: tableElement, + table: tableElement.DataTable(), + lastUpdate: Date.now() + }; + + console.log(`Registered real-time table for entity type: ${entityType}`); + }, + + /** + * Initialize WebSocket listeners for all entity types + */ + Initialize() { + if (!ws || ws.readyState !== WebSocket.OPEN) { + console.warn('WebSocket not ready for realtime initialization'); + return; + } + + this.wsReady = true; + console.log('Initialized real-time WebSocket listeners'); + this.SubscribeToAll(); + }, + + /** + * Subscribe to all registered entity types + */ + SubscribeToAll() { + const subscriptions = [ + 'account_update', + 'accounts_table_update', + 'account_list_changed', + 'character_update', + 'characters_table_update', + 'character_list_changed', + 'property_update', + 'properties_table_update', + 'property_list_changed', + 'play_key_update', + 'play_keys_table_update', + 'play_key_list_changed', + 'bug_report_update', + 'bug_reports_table_update' + ]; + + for (const subscription of subscriptions) { + if (!ws || ws.readyState !== WebSocket.OPEN) break; + + ws.send(JSON.stringify({ + event: 'subscribe', + subscription: subscription + })); + } + + console.log(`Subscribed to ${subscriptions.length} real-time events`); + }, + + /** + * Handle incoming WebSocket messages + */ + HandleMessage(data) { + if (!data.event) return; + + // Route to appropriate handler based on event type + if (data.event.includes('account')) { + this.HandleAccountEvent(data); + } else if (data.event.includes('character')) { + this.HandleCharacterEvent(data); + } else if (data.event.includes('property')) { + this.HandlePropertyEvent(data); + } else if (data.event.includes('play_key')) { + this.HandlePlayKeyEvent(data); + } else if (data.event.includes('bug_report')) { + this.HandleBugReportEvent(data); + } + }, + + /** + * Handle account-related events + */ + HandleAccountEvent(data) { + if (data.event === 'account_update') { + this.UpdateRow('account', data.accountId || data.id, data); + // Also update detail panel if viewing this account + this.UpdateDetailPanel('account', data); + } else if (data.event === 'accounts_table_update') { + this.ReloadTable('account'); + } else if (data.event === 'account_list_changed') { + this.UpdateListCount('account', data.totalAccounts); + this.ReloadTable('account', true); + } + }, + + /** + * Handle character-related events + */ + HandleCharacterEvent(data) { + if (data.event === 'character_update') { + this.UpdateRow('character', data.characterId || data.id, data); + } else if (data.event === 'characters_table_update') { + this.ReloadTable('character'); + } else if (data.event === 'character_list_changed') { + this.UpdateListCount('character', data.totalCharacters); + this.ReloadTable('character', true); + } + }, + + /** + * Handle property-related events + */ + HandlePropertyEvent(data) { + if (data.event === 'property_update') { + this.UpdateRow('property', data.propertyId || data.id, data); + } else if (data.event === 'properties_table_update') { + this.ReloadTable('property'); + } else if (data.event === 'property_list_changed') { + this.UpdateListCount('property', data.totalProperties); + this.ReloadTable('property', true); + } + }, + + /** + * Handle play key-related events + */ + HandlePlayKeyEvent(data) { + if (data.event === 'play_key_update') { + this.UpdateRow('play_key', data.playKeyId || data.id, data); + } else if (data.event === 'play_keys_table_update') { + this.ReloadTable('play_key'); + } else if (data.event === 'play_key_list_changed') { + this.UpdateListCount('play_key', data.totalPlayKeys); + this.ReloadTable('play_key', true); + } + }, + + /** + * Handle bug report-related events + */ + HandleBugReportEvent(data) { + if (data.event === 'bug_report_update') { + this.UpdateRow('bug_report', data.bugReportId || data.id, data); + } else if (data.event === 'bug_reports_table_update') { + this.ReloadTable('bug_report'); + } + }, + + /** + * Update a single row in a DataTable + */ + UpdateRow(entityType, entityId, data) { + if (!this.tables[entityType]) { + console.debug(`No table registered for entity type: ${entityType}`); + return; + } + + try { + const table = this.tables[entityType].table; + const rows = table.rows().nodes(); + + // Find and invalidate the matching row + let found = false; + for (let row of rows) { + const rowData = table.row(row).data(); + if (rowData && (rowData.id === entityId || rowData.accountId === entityId || + rowData.characterId === entityId || rowData.propertyId === entityId || + rowData.playKeyId === entityId)) { + table.row(row).invalidate().draw(false); + found = true; + break; + } + } + + if (found) { + this.ShowToast('Updated', `${entityType} data has been refreshed`, 'info'); + } + } catch (e) { + console.debug(`Error updating row for ${entityType}:`, e); + } + }, + + /** + * Reload a table without page change + */ + ReloadTable(entityType, resetPage = false) { + if (!this.tables[entityType]) { + console.debug(`No table registered for entity type: ${entityType}`); + return; + } + + try { + const table = this.tables[entityType].table; + + if (resetPage) { + table.page(0).ajax.reload(); + } else { + table.ajax.reload(null, false); + } + + this.ShowToast('Updated', `${entityType} list has been refreshed`, 'info'); + } catch (e) { + console.error(`Error reloading table for ${entityType}:`, e); + } + }, + + /** + * Update list count display + */ + UpdateListCount(entityType, count) { + try { + const selector = `.${entityType}-count`; + const countEl = document.querySelector(selector); + if (countEl) { + countEl.textContent = count; + } + } catch (e) { + console.debug(`Error updating count for ${entityType}:`, e); + } + }, + + /** + * Set current entity being viewed for detail updates + */ + SetCurrentEntity(entityType, entityId) { + this.currentEntity = { type: entityType, id: entityId }; + console.log(`Viewing ${entityType} ID: ${entityId}`); + }, + + /** + * Clear current entity + */ + ClearCurrentEntity() { + this.currentEntity = null; + }, + + /** + * Update detail panel for current entity + */ + UpdateDetailPanel(entityType, data) { + if (!this.currentEntity || this.currentEntity.type !== entityType || + this.currentEntity.id !== (data.id || data.accountId)) { + return; + } + + try { + // Update all elements with data-field attributes + for (const [key, value] of Object.entries(data)) { + const selectors = [ + `.detail-${key}`, + `[data-field="${key}"]`, + `.${entityType}-${key}` + ]; + + for (const selector of selectors) { + const elements = document.querySelectorAll(selector); + for (const el of elements) { + this.UpdateElement(el, key, value); + } + } + } + + this.ShowToast('Updated', `${entityType} data refreshed`, 'info'); + } catch (e) { + console.error(`Error updating detail panel:`, e); + } + }, + + /** + * Update an individual element with new value + */ + UpdateElement(element, fieldName, value) { + if (!element) return; + + try { + // Handle different field types + if (fieldName.includes('banned') || fieldName.includes('banned') || fieldName.includes('locked')) { + element.textContent = value ? 'Yes' : 'No'; + element.className = `badge ${value ? 'bg-danger' : 'bg-success'}`; + } else if (fieldName.includes('count') || fieldName.includes('level')) { + element.textContent = value || '-'; + } else if (fieldName.includes('date') || fieldName.includes('created') || fieldName.includes('updated')) { + if (value) { + element.textContent = new Date(value * 1000).toLocaleString(); + } + } else { + element.textContent = value || '-'; + } + } catch (e) { + console.debug(`Error updating element for field ${fieldName}:`, e); + } + }, + + /** + * Show toast notification + */ + ShowToast(title, message, type = 'info') { + try { + const toastEl = document.createElement('div'); + toastEl.className = `alert alert-${type === 'error' ? 'danger' : type} alert-dismissible fade show`; + toastEl.setAttribute('role', 'alert'); + toastEl.style.cssText = 'position: fixed; bottom: 20px; right: 20px; z-index: 9999; min-width: 300px;'; + + toastEl.innerHTML = ` + ${title}
${message} + + `; + + document.body.appendChild(toastEl); + + setTimeout(() => { + toastEl.remove(); + }, 4000); + } catch (e) { + console.log(`[${type}] ${title}: ${message}`); + } + } +}; + +// Export to global scope +window.realtimeManager = realtimeManager; diff --git a/dDashboardServer/templates/account-view.jinja2 b/dDashboardServer/templates/account-view.jinja2 index 3f926413..dae90c84 100644 --- a/dDashboardServer/templates/account-view.jinja2 +++ b/dDashboardServer/templates/account-view.jinja2 @@ -11,7 +11,7 @@
-

Account #{{ account.id }} - {{ account.name }}

+

Account #{{ account.id }} -

View account details and manage settings

@@ -23,7 +23,7 @@
Username
-
{{ account.name }}
+
@@ -35,9 +35,9 @@
GM Level
{% if account.gm_level > 0 %} - GM {{ account.gm_level }} + {% else %} - User + {% endif %}
@@ -46,9 +46,9 @@
Ban Status
{% if account.banned %} - BANNED + {% else %} - Active + {% endif %}
@@ -57,9 +57,9 @@
Lock Status
{% if account.locked %} - LOCKED + {% else %} - Unlocked + {% endif %}
@@ -67,11 +67,13 @@
Mute Expires
- {% if account.mute_expire > 0 %} - {{ account.mute_expire }} - {% else %} - Not muted - {% endif %} +
@@ -100,7 +102,18 @@ {% endblock %} {% block scripts %} + diff --git a/dDashboardServer/templates/accounts.jinja2 b/dDashboardServer/templates/accounts.jinja2 index 3c8b9620..9bcd5ee0 100644 --- a/dDashboardServer/templates/accounts.jinja2 +++ b/dDashboardServer/templates/accounts.jinja2 @@ -7,12 +7,12 @@ {% block content %}
-
-
-

Accounts

-

View and manage user accounts

+
+
+

Accounts

+

View and manage user accounts - total

-
+
@@ -39,10 +39,11 @@ {% endblock %} {% block scripts %} + + + + + {% block scripts %}{% endblock %} diff --git a/dDashboardServer/templates/bug_reports.jinja2 b/dDashboardServer/templates/bug_reports.jinja2 index 14fbc253..01abf8dc 100644 --- a/dDashboardServer/templates/bug_reports.jinja2 +++ b/dDashboardServer/templates/bug_reports.jinja2 @@ -6,12 +6,13 @@ {% block content %}
-
-
-

Bug Reports

-

View and manage bug reports from players

-
-
+
+
+
+

Bug Reports 0

+

View and manage bug reports from players

+
+
@@ -27,16 +28,18 @@
+
{% endblock %} {% block scripts %} + {% endblock %} diff --git a/dDashboardServer/templates/login.jinja2 b/dDashboardServer/templates/login.jinja2 index 2851ef26..1e357bcc 100644 --- a/dDashboardServer/templates/login.jinja2 +++ b/dDashboardServer/templates/login.jinja2 @@ -2,50 +2,62 @@ {% block title %}Dashboard Login - DarkflameServer{% endblock %} -{% block extra_css %} +{% block css %} {% endblock %} {% block content %} -
-
-
-
-
-
-

🎮 DarkflameServer

- - - -
-
- - -
- -
- - -
- -
- - -
- - -
-
+ {% endblock %} {% block scripts %} diff --git a/dDashboardServer/templates/play_keys.jinja2 b/dDashboardServer/templates/play_keys.jinja2 index 7fe0a134..69413183 100644 --- a/dDashboardServer/templates/play_keys.jinja2 +++ b/dDashboardServer/templates/play_keys.jinja2 @@ -6,12 +6,13 @@ {% block content %}
-
-
-

Play Keys

-

View and manage play keys

-
-
+
+
+
+

Play Keys 0

+

View and manage play keys

+
+
@@ -27,16 +28,18 @@
+
{% endblock %} {% block scripts %} +