Initial commit: Q3A RCON Dashboard

This commit is contained in:
Nicolas 2026-05-09 18:47:15 -03:00
commit 75c70967cf
13 changed files with 6288 additions and 0 deletions

19
.gitignore vendored Normal file
View File

@ -0,0 +1,19 @@
# Dependencies
node_modules/
# Build outputs
dist/
out/
# Logs
*.log
# OS files
.DS_Store
Thumbs.db
# Electron
*.pem
# Config with sensitive data
servers.json

78
README.md Normal file
View File

@ -0,0 +1,78 @@
# Q3A RCON Dashboard
A desktop application for administering Quake 3 Arena servers via RCON protocol. Built with Electron.
## Features
- Multi-server management with tabbed interface
- Quick-action buttons for common commands (Status, Kick, Say, Map, Ban, etc.)
- Three switchable themes: Terminal, Dark, Light
- Server configurations persisted to JSON
- Auto-open saved servers on startup
## Requirements
- Node.js 18+
- npm 9+
## Installation
```bash
npm install
```
## Building
### Linux (AppImage)
```bash
npm run build:linux
```
Output: `dist/Q3A RCON Dashboard-1.0.0.AppImage`
### Windows (Portable EXE)
On a Windows machine:
```cmd
npm run build:win
```
Output: `dist/Q3A RCON Dashboard-1.0.0.exe`
### Build Both
```bash
npm run build
```
## Running
```bash
npm start
```
## RCON Commands
| Command | Description |
|---------|-------------|
| `status` | Player list with scores and pings |
| `serverinfo` | Server settings (gametype, fraglimit, etc.) |
| `systeminfo` | System info (OS, CPU) |
| `kick <name>` | Kick player (or "all", "allbots") |
| `kicknum <num>` | Kick by slot number |
| `say <msg>` | Broadcast to all players |
| `map <name>` | Change map (e.g., q3dm1) |
| `banaddr <ip>` | Ban IP address |
| `listbans` | List all bans |
| `rehashbans` | Reload bans from file |
| `flushbans` | Delete all bans |
See the in-app Reference button for full command list.
## Tested Servers
- ioquake3
- Q3ALight
- Other Q3A-compatible engines

157
index.html Normal file
View File

@ -0,0 +1,157 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Q3A RCON Dashboard</title>
<link rel="stylesheet" href="styles/terminal.css" id="theme-stylesheet">
</head>
<body>
<div class="app">
<header class="toolbar">
<div class="toolbar-left">
<button id="btn-add-server" class="toolbar-btn">+ Add Server</button>
<button id="btn-servers" class="toolbar-btn">Servers</button>
</div>
<div class="toolbar-center">
<button class="theme-btn" data-theme="terminal">Terminal</button>
<button class="theme-btn" data-theme="dark">Dark</button>
<button class="theme-btn" data-theme="light">Light</button>
</div>
<div class="toolbar-right">
<button class="toolbar-btn" id="btn-reference">Reference</button>
<button class="toolbar-btn" id="btn-about">About</button>
</div>
</header>
<main class="main-content">
<div class="tabs-bar" id="tabs-bar">
<span class="tabs-placeholder" id="tabs-placeholder">No servers configured. Click "+ Add Server" to begin.</span>
</div>
<div class="server-content" id="server-content">
<div class="welcome-message">
<h1>Q3A RCON Dashboard</h1>
<p>Select or add a server to begin.</p>
</div>
</div>
</main>
<footer class="status-bar" id="status-bar">
<span>Ready</span>
</footer>
</div>
<div class="modal" id="modal-add-server">
<div class="modal-content">
<h2>Add / Edit Server</h2>
<form id="form-server">
<input type="hidden" id="server-id">
<div class="form-group">
<label for="server-name">Name</label>
<input type="text" id="server-name" placeholder="My Server" required>
</div>
<div class="form-group">
<label for="server-address">Address</label>
<input type="text" id="server-address" placeholder="192.168.1.100" required>
</div>
<div class="form-group">
<label for="server-port">Port</label>
<input type="number" id="server-port" value="27960" min="1" max="65535" required>
</div>
<div class="form-group">
<label for="server-password">RCON Password</label>
<input type="password" id="server-password" placeholder="********" required>
</div>
<div class="form-actions">
<button type="button" class="btn-cancel" id="btn-cancel-server">Cancel</button>
<button type="submit" class="btn-save">Save</button>
</div>
</form>
</div>
</div>
<div class="modal" id="modal-servers">
<div class="modal-content modal-large">
<h2>Manage Servers</h2>
<div id="server-list" class="server-list"></div>
<div class="form-actions">
<button type="button" class="btn-cancel" id="btn-close-servers">Close</button>
</div>
</div>
</div>
<div class="modal" id="modal-about">
<div class="modal-content">
<h2>About Q3A RCON Dashboard</h2>
<p>Version 1.0.0</p>
<p>A minimalist administration tool for Quake 3 Arena servers.</p>
<p>Supports: ioquake3, Q3ALight, and compatible engines.</p>
<div class="form-actions">
<button type="button" class="btn-cancel" id="btn-close-about">Close</button>
</div>
</div>
</div>
<div class="modal" id="modal-reference">
<div class="modal-content" style="max-width:600px;max-height:80vh;overflow-y:auto;">
<h2>Q3A RCON Command Reference</h2>
<h3>Server Management</h3>
<table class="ref-table">
<tr><td><code>map &lt;mapname&gt;</code></td><td>Change map (e.g., q3dm1)</td></tr>
<tr><td><code>devmap &lt;mapname&gt;</code></td><td>Change map with cheats enabled</td></tr>
<tr><td><code>map_restart &lt;sec&gt;</code></td><td>Restart map (default 5 sec)</td></tr>
<tr><td><code>killserver</code></td><td>Shutdown the server</td></tr>
</table>
<h3>Player Management</h3>
<table class="ref-table">
<tr><td><code>kick &lt;name&gt;</code></td><td>Kick player by name (or "all", "allbots")</td></tr>
<tr><td><code>kicknum &lt;num&gt;</code></td><td>Kick by slot number</td></tr>
<tr><td><code>kickall</code></td><td>Kick all players</td></tr>
<tr><td><code>kickbots</code></td><td>Kick all bots</td></tr>
</table>
<h3>Banning</h3>
<table class="ref-table">
<tr><td><code>banaddr &lt;ip[/subnet]&gt;</code></td><td>Ban IP or CIDR range</td></tr>
<tr><td><code>exceptaddr &lt;ip[/subnet]&gt;</code></td><td>Add ban exception</td></tr>
<tr><td><code>bandel &lt;range|num&gt;</code></td><td>Delete ban by range or number</td></tr>
<tr><td><code>exceptdel &lt;range|num&gt;</code></td><td>Delete exception</td></tr>
<tr><td><code>listbans</code></td><td>List all bans</td></tr>
<tr><td><code>rehashbans</code></td><td>Reload bans from file</td></tr>
<tr><td><code>flushbans</code></td><td>Delete all bans</td></tr>
</table>
<h3>Information</h3>
<table class="ref-table">
<tr><td><code>status</code></td><td>Player list with scores and pings</td></tr>
<tr><td><code>serverinfo</code></td><td>Server settings (gametype, fraglimit, etc.)</td></tr>
<tr><td><code>systeminfo</code></td><td>System info (OS, CPU)</td></tr>
<tr><td><code>dumpuser &lt;name&gt;</code></td><td>Player userinfo</td></tr>
</table>
<h3>Messaging</h3>
<table class="ref-table">
<tr><td><code>say &lt;message&gt;</code></td><td>Broadcast to all players</td></tr>
<tr><td><code>tell &lt;num&gt; &lt;msg&gt;</code></td><td>Message single player</td></tr>
<tr><td><code>sayto &lt;name&gt; &lt;text&gt;</code></td><td>Message player by name</td></tr>
</table>
<div class="form-actions">
<button type="button" class="btn-cancel" id="btn-close-reference">Close</button>
</div>
</div>
</div>
<div class="modal" id="modal-input">
<div class="modal-content">
<h2 id="modal-input-title">Enter Value</h2>
<div class="form-group">
<input type="text" id="modal-input-field" placeholder="">
</div>
<div class="form-actions">
<button type="button" class="btn-cancel" id="btn-cancel-input">Cancel</button>
<button type="button" class="btn-save" id="btn-submit-input">OK</button>
</div>
</div>
</div>
<script src="js/renderer.js"></script>
</body>
</html>

420
js/renderer.js Normal file
View File

@ -0,0 +1,420 @@
const MAX_HISTORY = 50;
let servers = [];
let activeTabs = {};
let tabCounter = 0;
let currentTheme = 'terminal';
const themes = {
terminal: 'styles/terminal.css',
dark: 'styles/dark.css',
light: 'styles/light.css'
};
document.addEventListener('DOMContentLoaded', async () => {
servers = await window.rcon.getServers();
setupEventListeners();
setupThemeButtons();
renderServerList();
servers.forEach(s => openServer(s.id));
});
let inputModalCallback = null;
function setupEventListeners() {
document.getElementById('btn-add-server').addEventListener('click', showAddServerModal);
document.getElementById('btn-servers').addEventListener('click', showServersModal);
document.getElementById('btn-cancel-server').addEventListener('click', hideAddServerModal);
document.getElementById('btn-close-servers').addEventListener('click', hideServersModal);
document.getElementById('btn-close-about').addEventListener('click', hideAboutModal);
document.getElementById('btn-about').addEventListener('click', showAboutModal);
document.getElementById('btn-close-reference').addEventListener('click', hideReferenceModal);
document.getElementById('btn-reference').addEventListener('click', showReferenceModal);
document.getElementById('form-server').addEventListener('submit', saveServer);
document.getElementById('btn-cancel-input').addEventListener('click', hideInputModal);
document.getElementById('btn-submit-input').addEventListener('click', submitInputModal);
document.getElementById('modal-input-field').addEventListener('keydown', (e) => {
if (e.key === 'Enter') submitInputModal();
if (e.key === 'Escape') hideInputModal();
});
}
function setupThemeButtons() {
document.querySelectorAll('.theme-btn').forEach(btn => {
btn.addEventListener('click', () => {
setTheme(btn.dataset.theme);
});
});
setTheme(currentTheme);
}
function setTheme(theme) {
currentTheme = theme;
document.getElementById('theme-stylesheet').href = themes[theme];
document.querySelectorAll('.theme-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.theme === theme);
});
}
function showAddServerModal(server = null) {
const modal = document.getElementById('modal-add-server');
document.getElementById('server-id').value = server?.id || '';
document.getElementById('server-name').value = server?.name || '';
document.getElementById('server-address').value = server?.address || '';
document.getElementById('server-port').value = server?.port || 27960;
document.getElementById('server-password').value = server?.password || '';
modal.classList.add('active');
}
function hideAddServerModal() {
document.getElementById('modal-add-server').classList.remove('active');
}
function showServersModal() {
renderServerList();
document.getElementById('modal-servers').classList.add('active');
}
function hideServersModal() {
document.getElementById('modal-servers').classList.remove('active');
}
function showAboutModal() {
document.getElementById('modal-about').classList.add('active');
}
function hideAboutModal() {
document.getElementById('modal-about').classList.remove('active');
}
function showReferenceModal() {
document.getElementById('modal-reference').classList.add('active');
}
function hideReferenceModal() {
document.getElementById('modal-reference').classList.remove('active');
}
function showInputModal(title, placeholder, callback) {
inputModalCallback = callback;
document.getElementById('modal-input-title').textContent = title;
document.getElementById('modal-input-field').placeholder = placeholder;
document.getElementById('modal-input-field').value = '';
document.getElementById('modal-input').classList.add('active');
document.getElementById('modal-input-field').focus();
}
function hideInputModal() {
document.getElementById('modal-input').classList.remove('active');
inputModalCallback = null;
}
function submitInputModal() {
const value = document.getElementById('modal-input-field').value;
document.getElementById('modal-input').classList.remove('active');
if (inputModalCallback) {
const cb = inputModalCallback;
inputModalCallback = null;
cb(value);
}
}
async function saveServer(e) {
e.preventDefault();
const server = {
id: document.getElementById('server-id').value || undefined,
name: document.getElementById('server-name').value,
address: document.getElementById('server-address').value,
port: parseInt(document.getElementById('server-port').value),
password: document.getElementById('server-password').value
};
servers = await window.rcon.saveServer(server);
hideAddServerModal();
renderServerList();
const saved = servers.find(s => s.name === server.name && s.address === server.address);
if (saved && !activeTabs[saved.id]) {
openServer(saved.id);
}
}
async function deleteServer(id) {
if (confirm('Delete this server?')) {
servers = await window.rcon.deleteServer(id);
closeTab(id);
renderServerList();
}
}
function renderServerList() {
const list = document.getElementById('server-list');
if (servers.length === 0) {
list.innerHTML = '<p class="no-servers">No servers configured.</p>';
return;
}
list.innerHTML = servers.map(s => `
<div class="server-item">
<div class="server-info">
<strong>${escapeHtml(s.name)}</strong>
<span>${escapeHtml(s.address)}:${s.port}</span>
</div>
<div class="server-actions">
<button onclick="openServer('${s.id}')">Open</button>
<button onclick="showAddServerModal(servers.find(x => x.id === '${s.id}'))">Edit</button>
<button onclick="deleteServer('${s.id}')" class="btn-danger">Delete</button>
</div>
</div>
`).join('');
}
function openServer(id) {
const server = servers.find(s => s.id === id);
if (!server) return;
if (!activeTabs[id]) {
activeTabs[id] = createTab(server);
tabCounter++;
}
switchToTab(id);
hideServersModal();
}
function createTab(server) {
const id = server.id;
const tab = {
id,
name: server.name,
server,
history: [],
historyIndex: -1
};
const tabEl = document.createElement('div');
tabEl.className = 'tab';
tabEl.id = `tab-${id}`;
tabEl.innerHTML = `
<span class="tab-name">${escapeHtml(server.name)}</span>
<span class="tab-close" data-id="${id}">×</span>
`;
tabEl.addEventListener('click', () => switchToTab(id));
const contentEl = document.createElement('div');
contentEl.className = 'tab-content';
contentEl.id = `content-${id}`;
contentEl.innerHTML = `
<div class="server-header">
<h2>${escapeHtml(server.name)}</h2>
<span class="server-address">${escapeHtml(server.address)}:${server.port}</span>
</div>
<div class="quick-actions">
<span class="qa-buttons">
<button class="qa-btn" data-cmd="status">Status</button>
<button class="qa-btn" data-cmd="serverinfo">Info</button>
<button class="qa-btn" data-cmd="systeminfo">Sys Info</button>
<button class="qa-btn" data-cmd="kick">Kick</button>
<button class="qa-btn" data-cmd="say">Say</button>
<button class="qa-btn" data-cmd="map">Map</button>
<button class="qa-btn" data-cmd="banaddr">Ban</button>
</span>
<button class="qa-btn qa-clear" data-id="${id}">Clear</button>
</div>
<div class="output" id="output-${id}"></div>
<div class="command-area">
<input type="text" class="cmd-input" id="cmd-${id}" placeholder="Enter command...">
<button class="cmd-send" data-id="${id}">Send</button>
</div>
`;
document.getElementById('tabs-bar').appendChild(tabEl);
document.getElementById('server-content').appendChild(contentEl);
const placeholder = document.getElementById('tabs-placeholder');
if (placeholder) placeholder.style.display = 'none';
tabEl.querySelector('.tab-close').addEventListener('click', (e) => {
e.stopPropagation();
closeTab(id);
});
contentEl.querySelector('.cmd-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
sendCommand(id);
} else if (e.key === 'ArrowUp') {
navigateHistory(id, -1);
} else if (e.key === 'ArrowDown') {
navigateHistory(id, 1);
}
});
contentEl.querySelector('.cmd-send').addEventListener('click', () => sendCommand(id));
contentEl.querySelectorAll('.qa-btn[data-cmd]').forEach(btn => {
btn.addEventListener('click', () => handleQuickAction(id, btn.dataset.cmd));
});
contentEl.querySelector('.qa-clear').addEventListener('click', () => {
const output = document.getElementById(`output-${id}`);
if (output) output.innerHTML = '';
});
return tab;
}
function switchToTab(id) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
const tabEl = document.getElementById(`tab-${id}`);
const contentEl = document.getElementById(`content-${id}`);
if (tabEl && contentEl) {
tabEl.classList.add('active');
contentEl.classList.add('active');
contentEl.querySelector('.cmd-input')?.focus();
}
}
function closeTab(id) {
const tabEl = document.getElementById(`tab-${id}`);
const contentEl = document.getElementById(`content-${id}`);
if (tabEl) tabEl.remove();
if (contentEl) contentEl.remove();
delete activeTabs[id];
const remaining = Object.keys(activeTabs);
if (remaining.length > 0) {
switchToTab(remaining[remaining.length - 1]);
} else {
document.getElementById('server-content').innerHTML = `
<div class="welcome-message">
<h1>Q3A RCON Dashboard</h1>
<p>Select or add a server to begin.</p>
</div>
`;
const placeholder = document.getElementById('tabs-placeholder');
if (placeholder) placeholder.style.display = '';
}
}
async function sendCommand(tabId) {
const input = document.getElementById(`cmd-${tabId}`);
const output = document.getElementById(`output-${tabId}`);
const command = input.value.trim();
if (!command || !activeTabs[tabId]) return;
const tab = activeTabs[tabId];
tab.history.push(command);
if (tab.history.length > MAX_HISTORY) tab.history.shift();
tab.historyIndex = tab.history.length;
input.value = '';
appendOutput(tabId, `> ${command}`, 'command');
const result = await window.rcon.send({
address: tab.server.address,
port: tab.server.port,
password: tab.server.password,
command
});
if (result.success) {
appendOutput(tabId, result.response || '(no response)', 'response');
} else {
appendOutput(tabId, `Error: ${result.error}`, 'error');
}
}
async function handleQuickAction(tabId, action) {
const tab = activeTabs[tabId];
if (!tab) return;
let command = action;
let promptTitle = null;
let promptPlaceholder = null;
switch (action) {
case 'kick':
promptTitle = 'Kick Player';
promptPlaceholder = 'Player name or slot number';
command = 'kick ';
break;
case 'say':
promptTitle = 'Broadcast Message';
promptPlaceholder = 'Message to send to all players';
command = 'say ';
break;
case 'map':
promptTitle = 'Change Map';
promptPlaceholder = 'Map name (e.g., q3dm1)';
command = 'map ';
break;
case 'banaddr':
promptTitle = 'Ban IP Address';
promptPlaceholder = 'IP or IP/subnet (e.g., 192.168.1.100)';
command = 'banaddr ';
break;
case 'dumpuser':
promptTitle = 'Dump User Info';
promptPlaceholder = 'Player name or slot number';
command = 'dumpuser ';
break;
default:
break;
}
if (promptTitle) {
showInputModal(promptTitle, promptPlaceholder, (value) => {
if (!value) return;
const fullCommand = command + value;
document.getElementById(`cmd-${tabId}`).value = fullCommand;
sendCommand(tabId);
});
return;
}
document.getElementById(`cmd-${tabId}`).value = command;
await sendCommand(tabId);
}
function navigateHistory(tabId, direction) {
const tab = activeTabs[tabId];
if (!tab || tab.history.length === 0) return;
tab.historyIndex += direction;
if (tab.historyIndex < 0) tab.historyIndex = 0;
if (tab.historyIndex >= tab.history.length) {
tab.historyIndex = tab.history.length;
document.getElementById(`cmd-${tabId}`).value = '';
return;
}
document.getElementById(`cmd-${tabId}`).value = tab.history[tab.historyIndex];
}
function appendOutput(tabId, text, type = '') {
const output = document.getElementById(`output-${tabId}`);
if (!output) return;
const lines = text.split('\n');
lines.forEach(line => {
const div = document.createElement('div');
div.className = `output-line ${type}`;
div.textContent = line;
output.appendChild(div);
});
output.scrollTop = output.scrollHeight;
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
window.servers = servers;
window.openServer = openServer;
window.deleteServer = deleteServer;
window.showAddServerModal = showAddServerModal;

123
main.js Normal file
View File

@ -0,0 +1,123 @@
const { app, BrowserWindow, ipcMain } = require('electron');
const path = require('path');
const fs = require('fs');
const dgram = require('dgram');
let mainWindow;
let servers = [];
const configPath = path.join(app.getPath('userData'), 'servers.json');
function loadConfig() {
try {
if (fs.existsSync(configPath)) {
const data = fs.readFileSync(configPath, 'utf8');
servers = JSON.parse(data).servers || [];
}
} catch (e) {
servers = [];
}
}
function saveConfig() {
fs.writeFileSync(configPath, JSON.stringify({ servers }, null, 2));
}
function createWindow() {
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false
}
});
mainWindow.loadFile('index.html');
}
app.whenReady().then(() => {
loadConfig();
createWindow();
});
ipcMain.handle('get-servers', () => servers);
ipcMain.handle('save-server', (event, server) => {
const index = servers.findIndex(s => s.id === server.id);
if (index >= 0) {
servers[index] = server;
} else {
server.id = Date.now().toString();
servers.push(server);
}
saveConfig();
return servers;
});
ipcMain.handle('delete-server', (event, id) => {
servers = servers.filter(s => s.id !== id);
saveConfig();
return servers;
});
ipcMain.handle('rcon-send', async (event, { address, port, password, command }) => {
const msgStr = 'rcon ' + password + ' ' + command + '\n';
const packet = Buffer.alloc(4 + Buffer.byteLength(msgStr));
packet.writeUInt32LE(0xFFFFFFFF, 0);
packet.write(msgStr, 4);
return new Promise((resolve) => {
const socket = dgram.createSocket('udp4');
let responseBuffer = '';
let done = false;
const finish = (success, response) => {
if (!done) {
done = true;
clearTimeout(timeout);
socket.removeAllListeners();
try { socket.close(); } catch (e) {}
resolve({ success, response: success ? response : null, error: success ? null : response });
}
};
const timeout = setTimeout(() => {
finish(false, 'Timeout waiting for response');
}, 5000);
socket.on('message', (msg) => {
const buf = Buffer.from(msg);
let start = 0;
if (buf.length >= 4 && buf[0] === 0xFF && buf[1] === 0xFF && buf[2] === 0xFF && buf[3] === 0xFF) {
start = 4;
}
let data = buf.slice(start).toString('ascii');
if (data.startsWith('print')) {
data = data.slice(5);
if (data.charCodeAt(0) === 10) data = data.slice(1);
}
responseBuffer += data;
if (responseBuffer.length > 0) {
finish(true, responseBuffer.trim());
}
});
socket.on('error', (err) => {
finish(false, err.message);
});
socket.on('close', () => {
if (!done) {
finish(true, responseBuffer.trim());
}
});
socket.send(packet, 0, packet.length, port, address, (err) => {
if (err) {
finish(false, err.message);
}
});
});
});

4103
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

33
package.json Normal file
View File

@ -0,0 +1,33 @@
{
"name": "q3a-admin",
"version": "1.0.0",
"description": "Quake 3 Arena RCON Dashboard",
"main": "main.js",
"scripts": {
"start": "electron .",
"dev": "electron . --enable-logging",
"build:linux": "electron-builder --linux AppImage",
"build:win": "electron-builder --win portable",
"build": "electron-builder --linux AppImage --win portable"
},
"author": "",
"license": "MIT",
"devDependencies": {
"electron": "^28.0.0",
"electron-builder": "^24.0.0"
},
"build": {
"appId": "com.q3a.admin",
"productName": "Q3A RCON Dashboard",
"icon": "quake3logo.png",
"linux": {
"target": "AppImage"
},
"win": {
"target": "portable"
},
"files": [
"**/*"
]
}
}

8
preload.js Normal file
View File

@ -0,0 +1,8 @@
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('rcon', {
getServers: () => ipcRenderer.invoke('get-servers'),
saveServer: (server) => ipcRenderer.invoke('save-server', server),
deleteServer: (id) => ipcRenderer.invoke('delete-server', id),
send: (data) => ipcRenderer.invoke('rcon-send', data)
});

BIN
quake.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

BIN
quake3logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

454
styles/dark.css Normal file
View File

@ -0,0 +1,454 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg-primary: #1a1a1e;
--bg-secondary: #222228;
--bg-tertiary: #2a2a32;
--text-primary: #e0e0e4;
--text-secondary: #a0a0a8;
--text-muted: #707078;
--accent: #6c9bff;
--border: #383840;
--error: #ff6c6c;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
height: 100vh;
overflow: hidden;
}
.app {
display: flex;
flex-direction: column;
height: 100vh;
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 20px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
}
.toolbar-left, .toolbar-center, .toolbar-right {
display: flex;
gap: 8px;
}
.toolbar-btn {
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border);
padding: 8px 16px;
cursor: pointer;
font-family: inherit;
font-size: 13px;
border-radius: 4px;
}
.toolbar-btn:hover {
background: var(--border);
}
.theme-btn {
background: var(--bg-tertiary);
color: var(--text-muted);
border: 1px solid var(--border);
padding: 8px 16px;
cursor: pointer;
font-family: inherit;
font-size: 13px;
border-radius: 4px;
}
.theme-btn.active {
color: var(--accent);
border-color: var(--accent);
}
.main-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.tabs-bar {
display: flex;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
padding: 4px 4px 0;
min-height: 40px;
overflow-x: auto;
}
.tabs-placeholder {
color: var(--text-muted);
padding: 10px 20px;
font-size: 13px;
}
.tab {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 20px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-bottom: none;
cursor: pointer;
font-size: 13px;
margin-right: 2px;
border-radius: 6px 6px 0 0;
}
.tab.active {
background: var(--bg-primary);
border-color: var(--accent);
color: var(--accent);
}
.tab-close {
cursor: pointer;
opacity: 0.5;
font-size: 16px;
line-height: 1;
}
.tab-close:hover {
opacity: 1;
}
.server-content {
flex: 1;
overflow: hidden;
}
.tab-content {
display: none;
flex-direction: column;
height: 100%;
}
.tab-content.active {
display: flex;
}
.welcome-message {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-muted);
}
.welcome-message h1 {
font-size: 28px;
margin-bottom: 8px;
color: var(--text-secondary);
}
.server-header {
padding: 20px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
}
.server-header h2 {
font-size: 18px;
margin-bottom: 4px;
}
.server-header .server-address {
color: var(--text-muted);
font-size: 13px;
}
.quick-actions {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
}
.qa-buttons {
display: flex;
gap: 10px;
}
.qa-btn {
background: var(--bg-tertiary);
color: var(--text-secondary);
border: 1px solid var(--border);
padding: 8px 20px;
cursor: pointer;
font-family: inherit;
font-size: 13px;
border-radius: 4px;
}
.qa-btn:hover {
color: var(--accent);
border-color: var(--accent);
}
.output {
flex: 1;
overflow-y: auto;
padding: 20px;
background: var(--bg-primary);
font-size: 14px;
line-height: 1.6;
}
.output-line {
white-space: pre-wrap;
word-break: break-all;
margin-bottom: 4px;
}
.output-line.command {
color: var(--accent);
}
.output-line.response {
color: var(--text-primary);
}
.output-line.error {
color: var(--error);
}
.command-area {
display: flex;
gap: 10px;
padding: 16px 20px;
background: var(--bg-secondary);
border-top: 1px solid var(--border);
}
.cmd-input {
flex: 1;
background: var(--bg-primary);
color: var(--text-primary);
border: 1px solid var(--border);
padding: 10px 14px;
font-family: inherit;
font-size: 14px;
border-radius: 4px;
}
.cmd-input:focus {
outline: none;
border-color: var(--accent);
}
.cmd-send {
background: var(--accent);
color: #fff;
border: none;
padding: 10px 28px;
cursor: pointer;
font-family: inherit;
font-size: 14px;
border-radius: 4px;
}
.cmd-send:hover {
opacity: 0.9;
}
.status-bar {
padding: 8px 20px;
background: var(--bg-secondary);
border-top: 1px solid var(--border);
font-size: 12px;
color: var(--text-muted);
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal.active {
display: flex;
}
.modal-content {
background: var(--bg-secondary);
border: 1px solid var(--border);
padding: 28px;
min-width: 420px;
max-width: 90vw;
border-radius: 8px;
}
.modal-content h2 {
margin-bottom: 20px;
font-size: 18px;
color: var(--text-primary);
}
.modal-content h3 {
margin-top: 20px;
margin-bottom: 10px;
font-size: 15px;
color: var(--text-primary);
}
.ref-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 12px;
}
.ref-table td {
padding: 6px 10px;
text-align: left;
font-size: 14px;
}
.ref-table td:first-child {
width: 45%;
}
.ref-table code {
background: var(--bg-tertiary);
padding: 3px 8px;
border-radius: 4px;
font-family: monospace;
}
.modal-large {
min-width: 620px;
}
.form-group {
margin-bottom: 18px;
}
.form-group label {
display: block;
margin-bottom: 6px;
font-size: 13px;
color: var(--text-secondary);
}
.form-group input {
width: 100%;
background: var(--bg-primary);
color: var(--text-primary);
border: 1px solid var(--border);
padding: 10px 14px;
font-family: inherit;
font-size: 14px;
border-radius: 4px;
}
.form-group input:focus {
outline: none;
border-color: var(--accent);
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 28px;
}
.btn-cancel, .btn-save {
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border);
padding: 10px 28px;
cursor: pointer;
font-family: inherit;
font-size: 14px;
border-radius: 4px;
}
.btn-save {
background: var(--accent);
color: #fff;
border: none;
}
.server-list {
max-height: 420px;
overflow-y: auto;
}
.server-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
margin-bottom: 10px;
border-radius: 6px;
}
.server-info strong {
display: block;
margin-bottom: 4px;
}
.server-info span {
color: var(--text-muted);
font-size: 12px;
}
.server-actions {
display: flex;
gap: 8px;
}
.server-actions button {
background: var(--bg-primary);
color: var(--text-secondary);
border: 1px solid var(--border);
padding: 8px 14px;
cursor: pointer;
font-family: inherit;
font-size: 12px;
border-radius: 4px;
}
.server-actions button:hover {
color: var(--accent);
border-color: var(--accent);
}
.btn-danger {
color: var(--error) !important;
border-color: var(--error) !important;
}
.no-servers {
text-align: center;
color: var(--text-muted);
padding: 40px;
}

456
styles/light.css Normal file
View File

@ -0,0 +1,456 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg-primary: #f5f5f7;
--bg-secondary: #ffffff;
--bg-tertiary: #eaeaec;
--text-primary: #1a1a1e;
--text-secondary: #6b6b70;
--text-muted: #a0a0a5;
--accent: #0066cc;
--border: #d0d0d4;
--error: #cc3300;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
height: 100vh;
overflow: hidden;
}
.app {
display: flex;
flex-direction: column;
height: 100vh;
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 20px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
}
.toolbar-left, .toolbar-center, .toolbar-right {
display: flex;
gap: 8px;
}
.toolbar-btn {
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border);
padding: 8px 16px;
cursor: pointer;
font-family: inherit;
font-size: 13px;
border-radius: 6px;
}
.toolbar-btn:hover {
background: var(--border);
}
.theme-btn {
background: var(--bg-tertiary);
color: var(--text-muted);
border: 1px solid var(--border);
padding: 8px 16px;
cursor: pointer;
font-family: inherit;
font-size: 13px;
border-radius: 6px;
}
.theme-btn.active {
color: var(--accent);
border-color: var(--accent);
}
.main-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.tabs-bar {
display: flex;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
padding: 4px 4px 0;
min-height: 40px;
overflow-x: auto;
}
.tabs-placeholder {
color: var(--text-muted);
padding: 10px 20px;
font-size: 13px;
}
.tab {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 20px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-bottom: none;
cursor: pointer;
font-size: 13px;
margin-right: 2px;
border-radius: 6px 6px 0 0;
}
.tab.active {
background: var(--bg-primary);
border-color: var(--text-primary);
color: var(--accent);
}
.tab-close {
cursor: pointer;
opacity: 0.4;
font-size: 16px;
line-height: 1;
}
.tab-close:hover {
opacity: 1;
}
.server-content {
flex: 1;
overflow: hidden;
}
.tab-content {
display: none;
flex-direction: column;
height: 100%;
}
.tab-content.active {
display: flex;
}
.welcome-message {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-muted);
}
.welcome-message h1 {
font-size: 28px;
margin-bottom: 8px;
color: var(--text-secondary);
}
.server-header {
padding: 20px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
}
.server-header h2 {
font-size: 18px;
margin-bottom: 4px;
}
.server-header .server-address {
color: var(--text-muted);
font-size: 13px;
}
.quick-actions {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
}
.qa-buttons {
display: flex;
gap: 10px;
}
.qa-btn {
background: var(--bg-tertiary);
color: var(--text-secondary);
border: 1px solid var(--border);
padding: 8px 20px;
cursor: pointer;
font-family: inherit;
font-size: 13px;
border-radius: 6px;
}
.qa-btn:hover {
color: var(--accent);
border-color: var(--accent);
}
.output {
flex: 1;
overflow-y: auto;
padding: 20px;
background: var(--bg-primary);
font-size: 14px;
line-height: 1.6;
}
.output-line {
white-space: pre-wrap;
word-break: break-all;
margin-bottom: 4px;
}
.output-line.command {
color: var(--accent);
}
.output-line.response {
color: var(--text-primary);
}
.output-line.error {
color: var(--error);
}
.command-area {
display: flex;
gap: 10px;
padding: 16px 20px;
background: var(--bg-secondary);
border-top: 1px solid var(--border);
}
.cmd-input {
flex: 1;
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border);
padding: 10px 14px;
font-family: inherit;
font-size: 14px;
border-radius: 6px;
}
.cmd-input:focus {
outline: none;
border-color: var(--accent);
}
.cmd-send {
background: var(--accent);
color: #fff;
border: none;
padding: 10px 28px;
cursor: pointer;
font-family: inherit;
font-size: 14px;
border-radius: 6px;
}
.cmd-send:hover {
opacity: 0.9;
}
.status-bar {
padding: 8px 20px;
background: var(--bg-secondary);
border-top: 1px solid var(--border);
font-size: 12px;
color: var(--text-muted);
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.4);
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal.active {
display: flex;
}
.modal-content {
background: var(--bg-secondary);
border: 1px solid var(--border);
padding: 28px;
min-width: 420px;
max-width: 90vw;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
}
.modal-content h2 {
margin-bottom: 20px;
font-size: 18px;
color: var(--text-primary);
}
.modal-content h3 {
margin-top: 20px;
margin-bottom: 10px;
font-size: 15px;
color: var(--text-primary);
}
.ref-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 12px;
}
.ref-table td {
padding: 6px 10px;
text-align: left;
font-size: 14px;
}
.ref-table td:first-child {
width: 45%;
}
.ref-table code {
background: var(--bg-tertiary);
padding: 3px 8px;
border-radius: 4px;
font-family: monospace;
}
.modal-large {
min-width: 620px;
}
.form-group {
margin-bottom: 18px;
}
.form-group label {
display: block;
margin-bottom: 6px;
font-size: 13px;
color: var(--text-secondary);
}
.form-group input {
width: 100%;
background: var(--bg-primary);
color: var(--text-primary);
border: 1px solid var(--border);
padding: 10px 14px;
font-family: inherit;
font-size: 14px;
border-radius: 6px;
}
.form-group input:focus {
outline: none;
border-color: var(--accent);
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 28px;
}
.btn-cancel, .btn-save {
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border);
padding: 10px 28px;
cursor: pointer;
font-family: inherit;
font-size: 14px;
border-radius: 6px;
}
.btn-save {
background: var(--accent);
color: #fff;
border: none;
}
.server-list {
max-height: 420px;
overflow-y: auto;
}
.server-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
margin-bottom: 10px;
border-radius: 8px;
}
.server-info strong {
display: block;
margin-bottom: 4px;
}
.server-info span {
color: var(--text-muted);
font-size: 12px;
}
.server-actions {
display: flex;
gap: 8px;
}
.server-actions button {
background: var(--bg-secondary);
color: var(--text-secondary);
border: 1px solid var(--border);
padding: 8px 14px;
cursor: pointer;
font-family: inherit;
font-size: 12px;
border-radius: 6px;
}
.server-actions button:hover {
color: var(--accent);
border-color: var(--accent);
}
.btn-danger {
color: var(--error) !important;
border-color: var(--error) !important;
}
.no-servers {
text-align: center;
color: var(--text-muted);
padding: 40px;
}

437
styles/terminal.css Normal file
View File

@ -0,0 +1,437 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg-primary: #0a0a0a;
--bg-secondary: #0f0f0f;
--bg-tertiary: #141414;
--text-primary: #33ff33;
--text-secondary: #22aa22;
--text-muted: #1a801a;
--accent: #44ff44;
--border: #226622;
--error: #ff4444;
}
body {
font-family: 'Courier New', Courier, monospace;
background: var(--bg-primary);
color: var(--text-primary);
height: 100vh;
overflow: hidden;
}
.app {
display: flex;
flex-direction: column;
height: 100vh;
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 16px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
}
.toolbar-left, .toolbar-center, .toolbar-right {
display: flex;
gap: 8px;
}
.toolbar-btn {
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border);
padding: 6px 12px;
cursor: pointer;
font-family: inherit;
font-size: 12px;
}
.toolbar-btn:hover {
background: var(--border);
}
.theme-btn {
background: var(--bg-tertiary);
color: var(--text-muted);
border: 1px solid var(--border);
padding: 6px 12px;
cursor: pointer;
font-family: inherit;
font-size: 12px;
}
.theme-btn.active {
color: var(--accent);
border-color: var(--accent);
}
.main-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.tabs-bar {
display: flex;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
padding: 4px 4px 0;
min-height: 36px;
overflow-x: auto;
}
.tabs-placeholder {
color: var(--text-muted);
padding: 8px 16px;
font-size: 12px;
}
.tab {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-bottom: none;
cursor: pointer;
font-size: 12px;
margin-right: 2px;
}
.tab.active {
background: var(--bg-primary);
border-color: var(--accent);
color: var(--accent);
}
.tab-close {
cursor: pointer;
opacity: 0.6;
}
.tab-close:hover {
opacity: 1;
}
.server-content {
flex: 1;
overflow: hidden;
}
.tab-content {
display: none;
flex-direction: column;
height: 100%;
}
.tab-content.active {
display: flex;
}
.welcome-message {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-muted);
}
.welcome-message h1 {
font-size: 24px;
margin-bottom: 8px;
color: var(--text-secondary);
}
.server-header {
padding: 16px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
}
.server-header h2 {
font-size: 16px;
margin-bottom: 4px;
}
.server-header .server-address {
color: var(--text-muted);
font-size: 12px;
}
.quick-actions {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
}
.qa-buttons {
display: flex;
gap: 8px;
}
.qa-btn {
background: var(--bg-tertiary);
color: var(--text-secondary);
border: 1px solid var(--border);
padding: 6px 16px;
cursor: pointer;
font-family: inherit;
font-size: 12px;
}
.qa-btn:hover {
color: var(--accent);
border-color: var(--accent);
}
.output {
flex: 1;
overflow-y: auto;
padding: 16px;
background: var(--bg-primary);
font-size: 13px;
line-height: 1.5;
}
.output-line {
white-space: pre-wrap;
word-break: break-all;
}
.output-line.command {
color: var(--accent);
}
.output-line.response {
color: var(--text-primary);
}
.output-line.error {
color: var(--error);
}
.command-area {
display: flex;
gap: 8px;
padding: 12px 16px;
background: var(--bg-secondary);
border-top: 1px solid var(--border);
}
.cmd-input {
flex: 1;
background: var(--bg-primary);
color: var(--text-primary);
border: 1px solid var(--border);
padding: 8px 12px;
font-family: inherit;
font-size: 13px;
}
.cmd-input:focus {
outline: none;
border-color: var(--accent);
}
.cmd-send {
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border);
padding: 8px 24px;
cursor: pointer;
font-family: inherit;
}
.cmd-send:hover {
background: var(--border);
}
.status-bar {
padding: 6px 16px;
background: var(--bg-secondary);
border-top: 1px solid var(--border);
font-size: 11px;
color: var(--text-muted);
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal.active {
display: flex;
}
.modal-content {
background: var(--bg-secondary);
border: 1px solid var(--border);
padding: 24px;
min-width: 400px;
max-width: 90vw;
}
.modal-content h2 {
margin-bottom: 16px;
font-size: 16px;
color: var(--text-primary);
}
.modal-content h3 {
margin-top: 16px;
margin-bottom: 8px;
font-size: 14px;
color: var(--text-primary);
}
.ref-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 8px;
}
.ref-table td {
padding: 4px 8px;
text-align: left;
font-size: 13px;
}
.ref-table td:first-child {
width: 45%;
}
.ref-table code {
background: var(--bg-tertiary);
padding: 2px 6px;
border-radius: 3px;
font-family: monospace;
}
.modal-large {
min-width: 600px;
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
margin-bottom: 4px;
font-size: 12px;
color: var(--text-secondary);
}
.form-group input {
width: 100%;
background: var(--bg-primary);
color: var(--text-primary);
border: 1px solid var(--border);
padding: 8px 12px;
font-family: inherit;
font-size: 13px;
}
.form-group input:focus {
outline: none;
border-color: var(--accent);
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 24px;
}
.btn-cancel, .btn-save {
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border);
padding: 8px 24px;
cursor: pointer;
font-family: inherit;
}
.btn-save {
border-color: var(--accent);
color: var(--accent);
}
.server-list {
max-height: 400px;
overflow-y: auto;
}
.server-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
margin-bottom: 8px;
}
.server-info strong {
display: block;
margin-bottom: 4px;
}
.server-info span {
color: var(--text-muted);
font-size: 12px;
}
.server-actions {
display: flex;
gap: 8px;
}
.server-actions button {
background: var(--bg-primary);
color: var(--text-secondary);
border: 1px solid var(--border);
padding: 6px 12px;
cursor: pointer;
font-family: inherit;
font-size: 12px;
}
.server-actions button:hover {
color: var(--accent);
border-color: var(--accent);
}
.btn-danger {
color: var(--error) !important;
border-color: var(--error) !important;
}
.no-servers {
text-align: center;
color: var(--text-muted);
padding: 32px;
}