From 2bb4dd723c78bdb7e6b45b3acfad7b7a9ab8144e Mon Sep 17 00:00:00 2001 From: Alexander Zielonka Date: Wed, 7 Jan 2026 02:41:37 +0100 Subject: [PATCH] ja --- ActivityLog.jsx | 172 ++++++ App.jsx | 156 +++++ Dashboard.jsx | 164 ++++++ UserContext.jsx | 61 ++ UserManagement.jsx | 102 ++++ ZomboidConfigEditor.jsx | 326 +++++++++++ auth.js | 211 +++++++ discord.js | 82 +++ discordBot.js | 353 ++++++++++++ gsm-backend/services/rcon.js | 12 +- gsm-frontend/src/components/ServerCard.jsx | 24 +- temp_Dashboard.jsx | 168 ++++++ temp_ServerCard.jsx | 208 +++++++ temp_ServerDetail.jsx | 640 +++++++++++++++++++++ zomboid_funcs.js | 86 +++ 15 files changed, 2753 insertions(+), 12 deletions(-) create mode 100644 ActivityLog.jsx create mode 100644 App.jsx create mode 100644 Dashboard.jsx create mode 100644 UserContext.jsx create mode 100644 UserManagement.jsx create mode 100644 ZomboidConfigEditor.jsx create mode 100644 auth.js create mode 100644 discord.js create mode 100644 discordBot.js create mode 100644 temp_Dashboard.jsx create mode 100644 temp_ServerCard.jsx create mode 100644 temp_ServerDetail.jsx create mode 100644 zomboid_funcs.js diff --git a/ActivityLog.jsx b/ActivityLog.jsx new file mode 100644 index 0000000..8aaddd2 --- /dev/null +++ b/ActivityLog.jsx @@ -0,0 +1,172 @@ +import { useState, useEffect } from 'react' +import { useUser } from '../context/UserContext' +import { getActivityLog } from '../api' + +const actionLabels = { + server_start: 'Server gestartet', + server_stop: 'Server gestoppt', + server_restart: 'Server neugestartet', + rcon_command: 'RCON Befehl', + autoshutdown_config: 'Auto-Shutdown geändert', + zomboid_config: 'Config geändert', + factorio_world_create: 'Welt erstellt', + factorio_world_delete: 'Welt gelöscht' +} + +const actionIcons = { + server_start: '▶️', + server_stop: '⏹️', + server_restart: '🔄', + rcon_command: '💻', + autoshutdown_config: '⏱️', + zomboid_config: '📝', + factorio_world_create: '🌍', + factorio_world_delete: '🗑️' +} + +const serverLabels = { + minecraft: 'Minecraft', + factorio: 'Factorio', + zomboid: 'Project Zomboid', + vrising: 'V Rising' +} + +function getAvatarUrl(discordId, avatar) { + if (!discordId || !avatar) return null + return `https://cdn.discordapp.com/avatars/${discordId}/${avatar}.png?size=32` +} + +function getDiscordProfileUrl(discordId) { + return `https://discord.com/users/${discordId}` +} + +export default function ActivityLog({ onClose }) { + const { token } = useUser() + const [logs, setLogs] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + + useEffect(() => { + const fetchLogs = async () => { + try { + const data = await getActivityLog(token, 100) + setLogs(data) + setError('') + } catch (err) { + setError('Fehler beim Laden des Activity Logs') + } finally { + setLoading(false) + } + } + fetchLogs() + }, [token]) + + const formatDate = (dateStr) => { + const date = new Date(dateStr + 'Z') + const now = new Date() + const diff = now - date + + if (diff < 60000) return 'Gerade eben' + if (diff < 3600000) return Math.floor(diff / 60000) + ' Min' + if (diff < 86400000) return Math.floor(diff / 3600000) + ' Std' + + return date.toLocaleDateString('de-DE', { + day: '2-digit', + month: '2-digit', + hour: '2-digit', + minute: '2-digit' + }) + } + + return ( +
+
e.stopPropagation()}> +
+

Activity Log

+ +
+ +
+ {error && ( +
{error}
+ )} + + {loading ? ( +
Laden...
+ ) : logs.length === 0 ? ( +
Noch keine Aktivitäten
+ ) : ( +
+ {logs.map((log) => { + const avatarUrl = getAvatarUrl(log.discord_id, log.avatar) + const profileUrl = log.discord_id ? getDiscordProfileUrl(log.discord_id) : null + + return ( +
+ {/* Avatar or Action Icon */} + {avatarUrl ? ( + + + + ) : ( +
+ + {log.username?.charAt(0)?.toUpperCase()} + +
+ )} + +
+
+ {profileUrl ? ( + + {log.username} + + ) : ( + {log.username} + )} + + {actionIcons[log.action] || '📋'} {actionLabels[log.action] || log.action} + + {log.target && ( + + {serverLabels[log.target] || log.target} + + )} +
+ {log.details && ( +
+ {log.details} +
+ )} +
+ +
+ {formatDate(log.created_at)} +
+
+ ) + })} +
+ )} +
+
+
+ ) +} diff --git a/App.jsx b/App.jsx new file mode 100644 index 0000000..6991efa --- /dev/null +++ b/App.jsx @@ -0,0 +1,156 @@ +import { useState, useEffect } from 'react' +import { BrowserRouter, Routes, Route, Navigate, useSearchParams, useNavigate } from 'react-router-dom' +import { UserProvider } from './context/UserContext' +import Dashboard from './pages/Dashboard' +import ServerDetail from './pages/ServerDetail' + +// OAuth Callback Handler +function AuthCallback({ onLogin }) { + const [searchParams] = useSearchParams() + const navigate = useNavigate() + const [error, setError] = useState(null) + + useEffect(() => { + const token = searchParams.get('token') + const errorParam = searchParams.get('error') + + if (errorParam) { + const errorMessages = { + 'discord_denied': 'Discord-Anmeldung abgebrochen', + 'no_code': 'Kein Autorisierungscode erhalten', + 'not_in_guild': 'Du bist nicht Mitglied des Discord-Servers', + 'oauth_failed': 'Anmeldung fehlgeschlagen' + } + setError(errorMessages[errorParam] || 'Unbekannter Fehler') + setTimeout(() => navigate('/'), 3000) + return + } + + if (token) { + onLogin(token) + navigate('/') + } else { + navigate('/') + } + }, [searchParams, onLogin, navigate]) + + if (error) { + return ( +
+
+
{error}
+
Weiterleitung...
+
+
+ ) + } + + return ( +
+
Anmeldung wird verarbeitet...
+
+ ) +} + +// Login page for unauthenticated users +function LoginPage() { + const [searchParams] = useSearchParams() + const error = searchParams.get('error') + + const errorMessages = { + 'discord_denied': 'Discord-Anmeldung abgebrochen', + 'no_code': 'Kein Autorisierungscode erhalten', + 'not_in_guild': 'Du bist nicht Mitglied des Discord-Servers', + 'oauth_failed': 'Anmeldung fehlgeschlagen' + } + + const handleDiscordLogin = () => { + window.location.href = '/api/auth/discord' + } + + return ( +
+
+ Logo +

Gameserver Management

+

Melde dich mit Discord an, um fortzufahren

+ + {error && ( +
+ {errorMessages[error] || 'Anmeldung fehlgeschlagen'} +
+ )} + + + +

+ Nur für Mitglieder des Discord-Servers +

+
+
+ ) +} + +export default function App() { + const [token, setToken] = useState(localStorage.getItem('gsm_token')) + + const handleLogin = (newToken) => { + localStorage.setItem('gsm_token', newToken) + setToken(newToken) + } + + const handleLogout = () => { + localStorage.removeItem('gsm_token') + setToken(null) + } + + return ( + + + + + ) : ( + + ) + } + /> + + ) : ( + + ) + } + /> + } + /> + } + /> + } + /> + } /> + + + + ) +} diff --git a/Dashboard.jsx b/Dashboard.jsx new file mode 100644 index 0000000..ff2ff6c --- /dev/null +++ b/Dashboard.jsx @@ -0,0 +1,164 @@ +import { useState, useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { getServers } from '../api' +import { useUser } from '../context/UserContext' +import ServerCard from '../components/ServerCard' +import UserManagement from '../components/UserManagement' +import ActivityLog from '../components/ActivityLog' + +export default function Dashboard({ onLogout }) { + const navigate = useNavigate() + const { user, token, loading: userLoading, isSuperadmin, role, avatarUrl } = useUser() + const [servers, setServers] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [showUserMgmt, setShowUserMgmt] = useState(false) + const [showActivityLog, setShowActivityLog] = useState(false) + + const fetchServers = async () => { + try { + const data = await getServers(token) + setServers(data) + setError('') + } catch (err) { + if (err.message.includes('401') || err.message.includes('403')) { + onLogout() + } else { + setError('Verbindung zum Server fehlgeschlagen') + } + } finally { + setLoading(false) + } + } + + useEffect(() => { + if (!userLoading && token) { + fetchServers() + const interval = setInterval(fetchServers, 10000) + return () => clearInterval(interval) + } + }, [token, userLoading]) + + const roleLabels = { + user: 'Viewer', + moderator: 'Operator', + superadmin: 'Admin' + } + + if (userLoading) { + return ( +
+
Laden...
+
+ ) + } + + const onlineCount = servers.filter(s => s.running).length + document.title = 'Dashboard | Zeasy GSM' + const totalPlayers = servers.reduce((sum, s) => sum + (s.players?.online || 0), 0) + + return ( +
+ {/* Header */} +
+
+
+
+ + Logo + Logo + + Gameserver Management +
+
+ + {onlineCount}/{servers.length} online + + | + + {totalPlayers} Spieler + +
+
+ {/* User info with Discord avatar */} +
+ {avatarUrl && ( + Avatar + )} +
+
{user?.username}
+
{roleLabels[role]}
+
+
+ + {isSuperadmin && ( + <> + + + + )} + +
+
+
+
+ + {/* Main Content */} +
+ {error && ( +
+ {error} +
+ )} + {loading ? ( +
+
Server werden geladen...
+
+ ) : ( +
+ {servers.map((server, index) => ( +
+ navigate('/server/' + server.id)} + isAuthenticated={true} + /> +
+ ))} +
+ )} +
+ + {/* Modals */} + {showUserMgmt && ( + setShowUserMgmt(false)} /> + )} + {showActivityLog && ( + setShowActivityLog(false)} /> + )} +
+ ) +} diff --git a/UserContext.jsx b/UserContext.jsx new file mode 100644 index 0000000..86287cd --- /dev/null +++ b/UserContext.jsx @@ -0,0 +1,61 @@ +import { createContext, useContext, useState, useEffect } from 'react' +import { getMe } from '../api' + +const UserContext = createContext(null) + +export function UserProvider({ children, token }) { + const [user, setUser] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + if (!token) { + setUser(null) + setLoading(false) + return + } + + getMe(token) + .then(data => { + setUser(data) + setLoading(false) + }) + .catch(() => { + // Token invalid, clear it + localStorage.removeItem('gsm_token') + setUser(null) + setLoading(false) + }) + }, [token]) + + // Get Discord avatar URL + const getAvatarUrl = () => { + if (!user?.discordId || !user?.avatar) { + return null + } + return `https://cdn.discordapp.com/avatars/${user.discordId}/${user.avatar}.png?size=64` + } + + const value = { + user, + token, + loading, + role: user?.role || 'user', + isModerator: ['moderator', 'superadmin'].includes(user?.role), + isSuperadmin: user?.role === 'superadmin', + avatarUrl: getAvatarUrl() + } + + return ( + + {children} + + ) +} + +export function useUser() { + const context = useContext(UserContext) + if (!context) { + throw new Error('useUser must be used within UserProvider') + } + return context +} diff --git a/UserManagement.jsx b/UserManagement.jsx new file mode 100644 index 0000000..cf2f285 --- /dev/null +++ b/UserManagement.jsx @@ -0,0 +1,102 @@ +import { useState, useEffect } from 'react' +import { useUser } from '../context/UserContext' +import { getUsers } from '../api' + +export default function UserManagement({ onClose }) { + const { token } = useUser() + const [users, setUsers] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + + useEffect(() => { + const fetchUsers = async () => { + try { + const data = await getUsers(token) + setUsers(data) + setError('') + } catch (err) { + setError('Fehler beim Laden der Benutzer') + } finally { + setLoading(false) + } + } + fetchUsers() + }, [token]) + + const roleLabels = { + user: 'Viewer', + moderator: 'Operator', + superadmin: 'Admin' + } + + const roleColors = { + user: 'text-neutral-400', + moderator: 'text-blue-400', + superadmin: 'text-yellow-400' + } + + const getAvatarUrl = (user) => { + if (!user.discord_id || !user.avatar) return null + return `https://cdn.discordapp.com/avatars/${user.discord_id}/${user.avatar}.png?size=64` + } + + return ( +
+
e.stopPropagation()}> +
+

Benutzerliste

+ +
+ +
+ {error && ( +
{error}
+ )} + +

+ Benutzer, die sich über Discord angemeldet haben. Rollen werden durch Discord-Rollen bestimmt. +

+ + {loading ? ( +
Laden...
+ ) : users.length === 0 ? ( +
Noch keine Benutzer angemeldet
+ ) : ( +
+ {users.map((user) => ( +
+ {getAvatarUrl(user) ? ( + + ) : ( +
+ + {user.username?.charAt(0)?.toUpperCase()} + +
+ )} + +
+
{user.username}
+
+ {user.discord_id} +
+
+ + + {roleLabels[user.role]} + +
+ ))} +
+ )} +
+
+
+ ) +} diff --git a/ZomboidConfigEditor.jsx b/ZomboidConfigEditor.jsx new file mode 100644 index 0000000..493bac0 --- /dev/null +++ b/ZomboidConfigEditor.jsx @@ -0,0 +1,326 @@ +import { useState, useEffect, useRef } from 'react' +import { FileText, Save, RefreshCw, AlertTriangle, Check, X, ChevronDown } from 'lucide-react' +import { getZomboidConfigs, getZomboidConfig, saveZomboidConfig } from '../api' + +export default function ZomboidConfigEditor({ token }) { + const [files, setFiles] = useState([]) + const [selectedFile, setSelectedFile] = useState(null) + const [content, setContent] = useState('') + const [originalContent, setOriginalContent] = useState('') + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(null) + const [hasChanges, setHasChanges] = useState(false) + const textareaRef = useRef(null) + const highlightRef = useRef(null) + + // Load file list + useEffect(() => { + loadFiles() + }, [token]) + + // Track changes + useEffect(() => { + setHasChanges(content !== originalContent) + }, [content, originalContent]) + + // Sync scroll between textarea and highlight div + const handleScroll = () => { + if (highlightRef.current && textareaRef.current) { + highlightRef.current.scrollTop = textareaRef.current.scrollTop + highlightRef.current.scrollLeft = textareaRef.current.scrollLeft + } + } + + async function loadFiles() { + setLoading(true) + setError(null) + try { + const data = await getZomboidConfigs(token) + setFiles(data.files || []) + if (data.files?.length > 0 && !selectedFile) { + loadFile(data.files[0].filename) + } + } catch (err) { + setError('Fehler beim Laden der Config-Dateien: ' + err.message) + } finally { + setLoading(false) + } + } + + async function loadFile(filename) { + setLoading(true) + setError(null) + setSuccess(null) + try { + const data = await getZomboidConfig(token, filename) + setSelectedFile(filename) + setContent(data.content) + setOriginalContent(data.content) + } catch (err) { + setError('Fehler beim Laden: ' + err.message) + } finally { + setLoading(false) + } + } + + async function handleSave() { + if (!selectedFile || !hasChanges) return + + setSaving(true) + setError(null) + setSuccess(null) + try { + await saveZomboidConfig(token, selectedFile, content) + setOriginalContent(content) + setSuccess('Config gespeichert! Server-Neustart erforderlich für Änderungen.') + setTimeout(() => setSuccess(null), 5000) + } catch (err) { + setError('Fehler beim Speichern: ' + err.message) + } finally { + setSaving(false) + } + } + + function handleDiscard() { + setContent(originalContent) + setError(null) + setSuccess(null) + } + + function getFileDescription(filename) { + const descriptions = { + 'Project.ini': 'Server-Einstellungen (PVP, Spieler, Netzwerk)', + 'Project_SandboxVars.lua': 'Gameplay-Einstellungen (Zombies, Loot, Schwierigkeit)', + 'Project_spawnpoints.lua': 'Spawn-Punkte für neue Spieler', + 'Project_spawnregions.lua': 'Spawn-Regionen Konfiguration' + } + return descriptions[filename] || filename + } + + // Highlight syntax based on file type + function highlightSyntax(text, filename) { + if (!text) return '' + + const isLua = filename?.endsWith('.lua') + const lines = text.split('\n') + + return lines.map((line, i) => { + let highlighted = line + .replace(/&/g, '&') + .replace(//g, '>') + + if (isLua) { + // Lua: -- comments + if (line.trim().startsWith('--')) { + highlighted = `${highlighted}` + } else if (line.includes('--')) { + const idx = line.indexOf('--') + const code = highlighted.substring(0, idx) + const comment = highlighted.substring(idx) + highlighted = `${code}${comment}` + } + // Highlight true/false/nil + highlighted = highlighted + .replace(/\b(true|false|nil)\b/g, '$1') + // Highlight numbers + highlighted = highlighted + .replace(/\b(\d+\.?\d*)\b/g, '$1') + } else { + // INI: # comments + if (line.trim().startsWith('#')) { + highlighted = `${highlighted}` + } + // Highlight key=value + else if (line.includes('=')) { + const idx = line.indexOf('=') + const key = highlighted.substring(0, idx) + const value = highlighted.substring(idx + 1) + highlighted = `${key}=${value}` + } + } + + return highlighted + }).join('\n') + } + + if (loading && files.length === 0) { + return ( +
+ + Lade Config-Dateien... +
+ ) + } + + return ( +
+ {/* File selector */} +
+
+ + +
+ + +
+ + {/* File description */} + {selectedFile && ( +
+ + {getFileDescription(selectedFile)} +
+ )} + + {/* Error/Success messages */} + {error && ( +
+ + {error} +
+ )} + + {success && ( +
+ + {success} +
+ )} + + {/* Editor with syntax highlighting */} +
+ {/* Highlighted background layer */} +