Spaces:
Sleeping
Sleeping
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>Global Chat Admin</title> | |
| <style> | |
| :root { | |
| color-scheme: dark; | |
| --bg: #07100f; | |
| --panel: #0d1b1a; | |
| --panel2: #102321; | |
| --text: #eefcf7; | |
| --muted: #93aaa3; | |
| --green: #13a65b; | |
| --blue: #2476c8; | |
| --yellow: #f5c542; | |
| --red: #df3f3f; | |
| --line: #21423d; | |
| } | |
| * { box-sizing: border-box; } | |
| body { | |
| margin: 0; | |
| font-family: Inter, Segoe UI, Arial, sans-serif; | |
| background: linear-gradient(145deg, #0d5e32 0%, #062d25 48%, #04100f 100%); | |
| color: var(--text); | |
| min-height: 100vh; | |
| display: grid; | |
| grid-template-columns: 280px 1fr; | |
| } | |
| .login-screen { | |
| position: fixed; | |
| inset: 0; | |
| z-index: 20; | |
| background: linear-gradient(145deg, #0d5e32 0%, #062d25 50%, #04100f 100%); | |
| display: grid; | |
| place-items: center; | |
| padding: 18px; | |
| } | |
| .login-card { | |
| width: min(420px, 100%); | |
| background: rgba(9, 24, 22, 0.94); | |
| border: 1px solid var(--line); | |
| border-radius: 12px; | |
| padding: 22px; | |
| display: grid; | |
| gap: 14px; | |
| box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35); | |
| } | |
| .hidden { display: none; } | |
| aside { | |
| background: rgba(5, 16, 15, 0.92); | |
| border-right: 1px solid var(--line); | |
| padding: 18px; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 14px; | |
| } | |
| main { | |
| display: grid; | |
| grid-template-rows: auto 1fr auto; | |
| min-width: 0; | |
| height: 100vh; | |
| } | |
| h1, h2 { margin: 0; } | |
| h1 { font-size: 24px; } | |
| h2 { font-size: 15px; color: var(--muted); } | |
| label { font-size: 13px; color: var(--muted); } | |
| input, button { | |
| width: 100%; | |
| border: 1px solid var(--line); | |
| border-radius: 8px; | |
| background: var(--panel2); | |
| color: var(--text); | |
| padding: 11px 12px; | |
| font-size: 14px; | |
| outline: none; | |
| } | |
| button { | |
| cursor: pointer; | |
| font-weight: 800; | |
| border: 0; | |
| transition: transform 120ms ease, opacity 120ms ease; | |
| } | |
| button:hover { transform: translateY(-1px); } | |
| .green { background: var(--green); } | |
| .red { background: var(--red); } | |
| .dark { background: #182927; } | |
| .stack { display: grid; gap: 8px; } | |
| .stats { | |
| display: grid; | |
| gap: 8px; | |
| margin-top: auto; | |
| } | |
| .stat { | |
| background: var(--panel); | |
| border: 1px solid var(--line); | |
| border-radius: 8px; | |
| padding: 10px; | |
| display: grid; | |
| gap: 4px; | |
| } | |
| .stat strong { font-size: 14px; } | |
| .stat span { color: var(--muted); font-size: 12px; } | |
| header { | |
| padding: 18px 22px; | |
| border-bottom: 1px solid var(--line); | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| gap: 16px; | |
| background: rgba(8, 20, 18, 0.7); | |
| } | |
| .header-tools { | |
| display: flex; | |
| align-items: center; | |
| justify-content: flex-end; | |
| gap: 12px; | |
| flex-wrap: wrap; | |
| } | |
| .translate-controls { | |
| display: grid; | |
| grid-template-columns: repeat(2, minmax(92px, 1fr)); | |
| gap: 8px; | |
| } | |
| .translate-controls button { width: auto; padding: 9px 12px; } | |
| .blue { background: var(--blue); } | |
| button:disabled { cursor: default; opacity: 0.45; transform: none; } | |
| #translateStatus { | |
| color: #b9d9ff; | |
| font-size: 12px; | |
| font-weight: 800; | |
| min-width: 150px; | |
| text-align: right; | |
| } | |
| #status { | |
| color: var(--muted); | |
| font-weight: 700; | |
| white-space: nowrap; | |
| } | |
| #messages { | |
| overflow: auto; | |
| scroll-behavior: smooth; | |
| overscroll-behavior: contain; | |
| padding: 22px; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 10px; | |
| } | |
| .bubble { | |
| max-width: min(720px, 82%); | |
| background: #f4f8f6; | |
| color: #07110f; | |
| border: 1px solid #cad8d2; | |
| border-radius: 10px; | |
| padding: 10px 12px; | |
| line-height: 1.35; | |
| overflow-wrap: anywhere; | |
| } | |
| .bubble.admin { | |
| background: #f5c542; | |
| color: #241a00; | |
| margin-left: auto; | |
| border-color: #d9a900; | |
| } | |
| .name { | |
| font-size: 12px; | |
| color: #08733f; | |
| font-weight: 900; | |
| margin-bottom: 4px; | |
| } | |
| .bubble.admin .name { color: #5c4000; } | |
| .composer { | |
| padding: 16px 22px; | |
| display: grid; | |
| grid-template-columns: 1fr 130px; | |
| gap: 10px; | |
| border-top: 1px solid var(--line); | |
| background: rgba(8, 20, 18, 0.86); | |
| } | |
| @media (max-width: 820px) { | |
| body { grid-template-columns: 1fr; } | |
| aside { border-right: 0; border-bottom: 1px solid var(--line); } | |
| main { height: auto; min-height: 70vh; } | |
| header { align-items: flex-start; flex-direction: column; } | |
| .header-tools { width: 100%; justify-content: space-between; } | |
| #translateStatus { text-align: left; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="loginScreen" class="login-screen"> | |
| <div class="login-card"> | |
| <h1>👑 Admin Login</h1> | |
| <h2>Enter the admin password to manage Global Chat.</h2> | |
| <input id="password" name="global-chat-admin-secret" type="password" placeholder="Enter admin password" autocomplete="off" spellcheck="false"> | |
| <button id="connect" class="green">Enter Admin Panel</button> | |
| <div id="loginError" style="color:#ffb199;font-weight:700;"></div> | |
| </div> | |
| </div> | |
| <aside> | |
| <div class="stack"> | |
| <h1>👑 Admin</h1> | |
| <h2>Global Chat Control</h2> | |
| </div> | |
| <div class="stack"> | |
| <label>Room</label> | |
| <input id="room" value="global"> | |
| </div> | |
| <div class="stack"> | |
| <label>Settings</label> | |
| <button id="pause" class="green">Pause Chat</button> | |
| <button id="clear" class="dark">Clear Chat History</button> | |
| </div> | |
| <div class="stats"> | |
| <div class="stat"><strong id="cpu">Process CPU: --</strong><span>Measured from this Node process</span></div> | |
| <div class="stat"><strong id="ram">Process RAM: --</strong><span>Real RAM used by the chat server</span></div> | |
| <div class="stat"><strong id="disk">App Storage: --</strong><span>Size of deployed app files only</span></div> | |
| <div class="stat"><strong id="online">Online: --</strong><span>Active players in this room</span></div> | |
| </div> | |
| </aside> | |
| <main> | |
| <header> | |
| <div> | |
| <h1>Messages</h1> | |
| <h2>Admin messages appear in Unity as 👑Admin</h2> | |
| </div> | |
| <div class="header-tools"> | |
| <div> | |
| <div class="translate-controls"> | |
| <button id="translate" class="blue">Translate</button> | |
| <button id="original" class="dark" disabled>Original</button> | |
| </div> | |
| <div id="translateStatus"></div> | |
| </div> | |
| <div id="status">Disconnected</div> | |
| </div> | |
| </header> | |
| <section id="messages"></section> | |
| <div class="composer"> | |
| <input id="message" maxlength="600" placeholder="Write as Admin..."> | |
| <button id="send" class="green">Send</button> | |
| </div> | |
| </main> | |
| <script> | |
| const ADMIN_NAME = "\uD83D\uDC51Admin"; | |
| const wsUrl = `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/ws`; | |
| let ws = null; | |
| let paused = false; | |
| let currentRoom = "global"; | |
| let heartbeatTimer = null; | |
| let pendingAdminPassword = ""; | |
| let adminAuthenticated = false; | |
| let translationEnabled = false; | |
| let translationGeneration = 0; | |
| let targetLanguage = ""; | |
| let messageViews = []; | |
| const $ = (id) => document.getElementById(id); | |
| function send(payload) { | |
| if (!ws || ws.readyState !== WebSocket.OPEN) return; | |
| ws.send(JSON.stringify(payload)); | |
| } | |
| function admin(action, extra = {}) { | |
| const payload = { | |
| type: "admin", | |
| action, | |
| room: $("room").value || "global", | |
| ...extra | |
| }; | |
| if (!adminAuthenticated) payload.password = pendingAdminPassword; | |
| send(payload); | |
| } | |
| function addMessage(name, message, isAdmin = false, filtered = false) { | |
| const bubble = document.createElement("div"); | |
| bubble.className = `bubble ${isAdmin ? "admin" : ""}`; | |
| bubble.innerHTML = `<div class="name"></div><div class="text"></div>`; | |
| bubble.querySelector(".name").textContent = isAdmin ? ADMIN_NAME : (name || "Player"); | |
| const textElement = bubble.querySelector(".text"); | |
| textElement.textContent = message || ""; | |
| $("messages").appendChild(bubble); | |
| $("messages").scrollTop = $("messages").scrollHeight; | |
| const view = { | |
| element: bubble, | |
| textElement, | |
| originalText: message || "", | |
| translatedText: "", | |
| translatedLanguage: "", | |
| filtered: !!filtered, | |
| translating: false | |
| }; | |
| messageViews.push(view); | |
| if (translationEnabled && !view.filtered) { | |
| translateMessageView(view, translationGeneration); | |
| } | |
| } | |
| function validLanguageCode(value) { | |
| const code = String(value || "").toLowerCase().replace("_", "-").split(",")[0]; | |
| return /^[a-z]{2,3}(?:-[a-z]{2,4})?$/.test(code) ? code : ""; | |
| } | |
| async function detectTargetLanguage() { | |
| const browserLanguage = validLanguageCode(navigator.languages?.[0] || navigator.language); | |
| if (browserLanguage) return browserLanguage; | |
| try { | |
| const response = await fetch("/locale"); | |
| const data = await response.json(); | |
| return validLanguageCode(data.language) || "en"; | |
| } catch { | |
| return "en"; | |
| } | |
| } | |
| function updateTranslationButtons() { | |
| $("translate").disabled = translationEnabled; | |
| $("original").disabled = !translationEnabled; | |
| } | |
| async function translateMessageView(view, generation) { | |
| if (!translationEnabled || generation !== translationGeneration || view.filtered || view.translating) return; | |
| if (view.translatedText && view.translatedLanguage === targetLanguage) { | |
| view.textElement.textContent = view.translatedText; | |
| return; | |
| } | |
| view.translating = true; | |
| try { | |
| const response = await fetch("/translate", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ | |
| text: view.originalText, | |
| targetLanguage, | |
| mode: "free", | |
| provider: "google" | |
| }) | |
| }); | |
| const data = await response.json(); | |
| if (!response.ok || !data.ok) throw new Error(data.code || "translation_failed"); | |
| view.translatedText = data.translatedText; | |
| view.translatedLanguage = targetLanguage; | |
| if (translationEnabled && generation === translationGeneration) { | |
| view.textElement.textContent = view.translatedText; | |
| } | |
| } catch (error) { | |
| $("translateStatus").textContent = "Server could not translate."; | |
| if (error.message === "invalid_api_key") { | |
| console.error("Translation API key is invalid or missing."); | |
| } else { | |
| console.error("Translation server failed:", error.message); | |
| } | |
| } finally { | |
| view.translating = false; | |
| } | |
| } | |
| async function enableTranslation() { | |
| if (translationEnabled) return; | |
| translationEnabled = true; | |
| translationGeneration++; | |
| const generation = translationGeneration; | |
| updateTranslationButtons(); | |
| $("translateStatus").textContent = "Detecting language..."; | |
| if (!targetLanguage) targetLanguage = await detectTargetLanguage(); | |
| if (!translationEnabled || generation !== translationGeneration) return; | |
| $("translateStatus").textContent = `Translating to ${targetLanguage.toUpperCase()}...`; | |
| for (const view of [...messageViews]) { | |
| if (!translationEnabled || generation !== translationGeneration) return; | |
| if (!view.filtered) await translateMessageView(view, generation); | |
| } | |
| if (translationEnabled && generation === translationGeneration) { | |
| $("translateStatus").textContent = `Translated to ${targetLanguage.toUpperCase()}`; | |
| } | |
| } | |
| function showOriginalMessages() { | |
| translationEnabled = false; | |
| translationGeneration++; | |
| messageViews.forEach((view) => { view.textElement.textContent = view.originalText; }); | |
| $("translateStatus").textContent = "Showing original messages"; | |
| updateTranslationButtons(); | |
| } | |
| function updateStats(status) { | |
| if (!status) return; | |
| $("online").textContent = `Online: ${status.clients ?? "--"}`; | |
| const stats = status.stats; | |
| if (!stats) return; | |
| $("cpu").textContent = `Process CPU: ${stats.cpu.percent}%`; | |
| $("ram").textContent = `Process RAM: ${stats.memory.rssText}`; | |
| $("disk").textContent = `App Storage: ${stats.storage.usedText}`; | |
| } | |
| function updatePauseButton() { | |
| $("pause").textContent = paused ? "Resume Chat" : "Pause Chat"; | |
| $("pause").className = paused ? "red" : "green"; | |
| } | |
| function connectAdmin() { | |
| $("loginError").textContent = ""; | |
| const enteredPassword = $("password").value; | |
| if (!enteredPassword) { | |
| $("loginError").textContent = "Enter the admin password."; | |
| return; | |
| } | |
| pendingAdminPassword = enteredPassword; | |
| $("password").value = ""; | |
| adminAuthenticated = false; | |
| ws = new WebSocket(wsUrl); | |
| $("status").textContent = "Connecting..."; | |
| ws.onopen = () => { | |
| $("status").textContent = "Connected"; | |
| admin("auth"); | |
| clearInterval(heartbeatTimer); | |
| heartbeatTimer = setInterval(() => send({ type: "ping" }), 4000); | |
| }; | |
| ws.onclose = () => { | |
| $("status").textContent = "Disconnected"; | |
| $("loginScreen").classList.remove("hidden"); | |
| pendingAdminPassword = ""; | |
| adminAuthenticated = false; | |
| clearInterval(heartbeatTimer); | |
| }; | |
| ws.onmessage = (event) => { | |
| const data = JSON.parse(event.data); | |
| if (data.type === "message") { | |
| addMessage(data.nickname, data.message, !!data.admin, !!data.filtered); | |
| } | |
| if (data.type === "history") { | |
| $("messages").innerHTML = ""; | |
| messageViews = []; | |
| (data.messages || []).forEach((msg) => addMessage(msg.nickname, msg.message, !!msg.admin, !!msg.filtered)); | |
| } | |
| if (data.type === "adminReady") { | |
| adminAuthenticated = true; | |
| pendingAdminPassword = ""; | |
| $("password").value = ""; | |
| $("loginScreen").classList.add("hidden"); | |
| $("loginError").textContent = ""; | |
| paused = !!data.paused; | |
| updatePauseButton(); | |
| updateStats(data); | |
| currentRoom = $("room").value || "global"; | |
| send({ | |
| type: "join", | |
| nickname: ADMIN_NAME, | |
| room: currentRoom, | |
| roomLimit: 0, | |
| language: validLanguageCode(navigator.languages?.[0] || navigator.language) || "en" | |
| }); | |
| } | |
| if (data.type === "serverStatus") { | |
| paused = !!data.paused; | |
| updatePauseButton(); | |
| updateStats(data); | |
| } | |
| if (data.type === "roomInfo") { | |
| $("online").textContent = `Online: ${data.online}`; | |
| } | |
| if (data.type === "error") { | |
| if (data.message === "Wrong admin password.") { | |
| $("loginError").textContent = "Wrong admin password."; | |
| pendingAdminPassword = ""; | |
| adminAuthenticated = false; | |
| ws.close(); | |
| } else { | |
| addMessage("Error", data.message, false); | |
| } | |
| } | |
| }; | |
| } | |
| $("connect").onclick = connectAdmin; | |
| $("password").addEventListener("keydown", (event) => { | |
| if (event.key === "Enter") connectAdmin(); | |
| }); | |
| $("pause").onclick = () => admin(paused ? "resume" : "pause"); | |
| $("clear").onclick = () => admin("clear"); | |
| $("translate").onclick = enableTranslation; | |
| $("original").onclick = showOriginalMessages; | |
| $("send").onclick = () => { | |
| const text = $("message").value.trim(); | |
| if (!text) return; | |
| admin("message", { message: text }); | |
| $("message").value = ""; | |
| }; | |
| $("message").addEventListener("keydown", (event) => { | |
| if (event.key === "Enter") $("send").click(); | |
| }); | |
| setInterval(async () => { | |
| try { | |
| const response = await fetch("/health"); | |
| updateStats(await response.json()); | |
| } catch { | |
| } | |
| }, 2500); | |
| </script> | |
| </body> | |
| </html> | |