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 */} +