Add OpenTTD and Terraria support, improve config editors
- Add OpenTTD server integration (config editor, server card, API) - Add Terraria server integration (config editor, API) - Add legends to all config editors for syntax highlighting - Simplify UserManagement: remove edit/delete buttons, add Discord avatars - Add auto-logout on 401/403 API errors - Update save button styling with visible borders 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
BIN
gsm-frontend/public/openttd.png
Normal file
BIN
gsm-frontend/public/openttd.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
BIN
gsm-frontend/public/terraria.png
Normal file
BIN
gsm-frontend/public/terraria.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
@@ -9,6 +9,13 @@ async function fetchAPI(endpoint, options = {}) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Auto-logout on auth errors (invalid/expired token)
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
localStorage.removeItem('gsm_token')
|
||||||
|
window.location.href = '/'
|
||||||
|
throw new Error('Session expired')
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json().catch(() => ({ message: 'Request failed' }))
|
const error = await response.json().catch(() => ({ message: 'Request failed' }))
|
||||||
throw new Error(error.message || `HTTP ${response.status}`)
|
throw new Error(error.message || `HTTP ${response.status}`)
|
||||||
@@ -243,6 +250,36 @@ export async function savePalworldConfig(token, filename, content) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Terraria Config Management
|
||||||
|
export async function getTerrariaConfig(token) {
|
||||||
|
return fetchAPI('/servers/terraria/config', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveTerrariaConfig(token, content) {
|
||||||
|
return fetchAPI('/servers/terraria/config', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
body: JSON.stringify({ content }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenTTD Config Management
|
||||||
|
export async function getOpenTTDConfig(token) {
|
||||||
|
return fetchAPI('/servers/openttd/config', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveOpenTTDConfig(token, content) {
|
||||||
|
return fetchAPI('/servers/openttd/config', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
body: JSON.stringify({ content }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Activity Log
|
// Activity Log
|
||||||
export async function getActivityLog(token, limit = 100) {
|
export async function getActivityLog(token, limit = 100) {
|
||||||
return fetchAPI(`/servers/activity-log?limit=${limit}`, {
|
return fetchAPI(`/servers/activity-log?limit=${limit}`, {
|
||||||
|
|||||||
244
gsm-frontend/src/components/OpenTTDConfigEditor.jsx
Normal file
244
gsm-frontend/src/components/OpenTTDConfigEditor.jsx
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { FileText, Save, RefreshCw, AlertTriangle, Check, X } from 'lucide-react'
|
||||||
|
import { getOpenTTDConfig, saveOpenTTDConfig } from '../api'
|
||||||
|
|
||||||
|
export default function OpenTTDConfigEditor({ token }) {
|
||||||
|
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)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadConfig()
|
||||||
|
}, [token])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setHasChanges(content !== originalContent)
|
||||||
|
}, [content, originalContent])
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (highlightRef.current && textareaRef.current) {
|
||||||
|
highlightRef.current.scrollTop = textareaRef.current.scrollTop
|
||||||
|
highlightRef.current.scrollLeft = textareaRef.current.scrollLeft
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadConfig() {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const data = await getOpenTTDConfig(token)
|
||||||
|
setContent(data.content)
|
||||||
|
setOriginalContent(data.content)
|
||||||
|
} catch (err) {
|
||||||
|
setError('Fehler beim Laden der Config: ' + err.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!hasChanges) return
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
setSuccess(null)
|
||||||
|
try {
|
||||||
|
await saveOpenTTDConfig(token, content)
|
||||||
|
setOriginalContent(content)
|
||||||
|
setSuccess('Config gespeichert! Server-Neustart erforderlich.')
|
||||||
|
setTimeout(() => setSuccess(null), 5000)
|
||||||
|
} catch (err) {
|
||||||
|
setError('Fehler beim Speichern: ' + err.message)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDiscard() {
|
||||||
|
setContent(originalContent)
|
||||||
|
setError(null)
|
||||||
|
setSuccess(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightSyntax(text) {
|
||||||
|
if (!text) return ''
|
||||||
|
|
||||||
|
const lines = text.split('\n')
|
||||||
|
|
||||||
|
return lines.map((line) => {
|
||||||
|
let highlighted = line
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
|
||||||
|
// Section headers [section]
|
||||||
|
if (line.trim().startsWith('[') && line.trim().endsWith(']')) {
|
||||||
|
highlighted = `<span class="text-purple-400 font-bold">${highlighted}</span>`
|
||||||
|
}
|
||||||
|
// Comments (;)
|
||||||
|
else if (line.trim().startsWith(';')) {
|
||||||
|
highlighted = `<span class="text-emerald-500">${highlighted}</span>`
|
||||||
|
}
|
||||||
|
// key = value
|
||||||
|
else if (line.includes('=')) {
|
||||||
|
const idx = line.indexOf('=')
|
||||||
|
const key = highlighted.substring(0, idx)
|
||||||
|
const value = highlighted.substring(idx + 1)
|
||||||
|
|
||||||
|
// Color numbers, true/false, and quoted strings
|
||||||
|
let coloredValue = value
|
||||||
|
.replace(/\b(true|false)\b/gi, '<span class="text-orange-400">$1</span>')
|
||||||
|
.replace(/\b(\d+)\b/g, '<span class="text-cyan-400">$1</span>')
|
||||||
|
|
||||||
|
highlighted = `<span class="text-blue-400">${key}</span>=<span class="text-amber-300">${coloredValue}</span>`
|
||||||
|
}
|
||||||
|
|
||||||
|
return highlighted
|
||||||
|
}).join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center p-8">
|
||||||
|
<RefreshCw className="w-6 h-6 animate-spin text-gray-400" />
|
||||||
|
<span className="ml-2 text-gray-400">Lade Config...</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||||
|
<FileText className="w-4 h-4" />
|
||||||
|
<span>openttd.cfg - Server-Einstellungen</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={loadConfig}
|
||||||
|
disabled={loading}
|
||||||
|
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors"
|
||||||
|
title="Neu laden"
|
||||||
|
>
|
||||||
|
<RefreshCw className={"w-5 h-5 " + (loading ? 'animate-spin' : '')} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error/Success messages */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400">
|
||||||
|
<AlertTriangle className="w-5 h-5 flex-shrink-0" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-green-500/10 border border-green-500/30 rounded-lg text-green-400">
|
||||||
|
<Check className="w-5 h-5 flex-shrink-0" />
|
||||||
|
<span>{success}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Editor */}
|
||||||
|
<div className="relative h-[500px] rounded-lg overflow-hidden border border-gray-700">
|
||||||
|
<pre
|
||||||
|
ref={highlightRef}
|
||||||
|
className="absolute inset-0 p-4 m-0 text-sm font-mono bg-gray-900 text-gray-100 overflow-auto pointer-events-none whitespace-pre-wrap break-words"
|
||||||
|
style={{ wordBreak: 'break-word' }}
|
||||||
|
aria-hidden="true"
|
||||||
|
dangerouslySetInnerHTML={{ __html: highlightSyntax(content) + '\n' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
className="absolute inset-0 w-full h-full p-4 text-sm font-mono bg-transparent text-transparent caret-white resize-none focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-inset"
|
||||||
|
spellCheck={false}
|
||||||
|
disabled={loading}
|
||||||
|
style={{ caretColor: 'white' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{hasChanges && (
|
||||||
|
<div className="absolute top-2 right-2 px-2 py-1 bg-yellow-500/20 text-yellow-400 text-xs rounded z-10">
|
||||||
|
Ungespeichert
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center justify-end gap-3">
|
||||||
|
{hasChanges && (
|
||||||
|
<button
|
||||||
|
onClick={handleDiscard}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
Verwerfen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!hasChanges || saving}
|
||||||
|
className={"flex items-center gap-2 px-4 py-2 rounded-lg transition-colors " +
|
||||||
|
(hasChanges && !saving
|
||||||
|
? 'bg-green-600 hover:bg-green-500 text-white border-2 border-green-400'
|
||||||
|
: 'bg-gray-700 text-gray-500 cursor-not-allowed border-2 border-gray-600'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||||
|
Speichern...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className="w-4 h-4" />
|
||||||
|
Speichern
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="mt-4 p-4 bg-gray-800/50 rounded-lg border border-gray-700">
|
||||||
|
<h4 className="text-sm font-medium text-gray-300 mb-2">Legende</h4>
|
||||||
|
<div className="flex flex-wrap gap-4 text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-3 h-3 rounded bg-purple-400"></span>
|
||||||
|
<span className="text-gray-400">Sektion</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-3 h-3 rounded bg-emerald-500"></span>
|
||||||
|
<span className="text-gray-400">Kommentare</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-3 h-3 rounded bg-blue-400"></span>
|
||||||
|
<span className="text-gray-400">Einstellung</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-3 h-3 rounded bg-amber-300"></span>
|
||||||
|
<span className="text-gray-400">Wert</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-3 h-3 rounded bg-orange-400"></span>
|
||||||
|
<span className="text-gray-400">Boolean</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-3 h-3 rounded bg-cyan-400"></span>
|
||||||
|
<span className="text-gray-400">Zahlen</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 mt-3">Änderungen werden erst nach Server-Neustart aktiv. Ein Backup wird automatisch erstellt.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -285,8 +285,8 @@ export default function PalworldConfigEditor({ token }) {
|
|||||||
disabled={!hasChanges || saving}
|
disabled={!hasChanges || saving}
|
||||||
className={"flex items-center gap-2 px-4 py-2 rounded-lg transition-colors " +
|
className={"flex items-center gap-2 px-4 py-2 rounded-lg transition-colors " +
|
||||||
(hasChanges && !saving
|
(hasChanges && !saving
|
||||||
? 'bg-green-600 hover:bg-green-500 text-white'
|
? 'bg-green-600 hover:bg-green-500 text-white border-2 border-green-400'
|
||||||
: 'bg-gray-700 text-gray-500 cursor-not-allowed'
|
: 'bg-gray-700 text-gray-500 cursor-not-allowed border-2 border-gray-600'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{saving ? (
|
{saving ? (
|
||||||
|
|||||||
@@ -35,6 +35,20 @@ const serverInfo = {
|
|||||||
links: [
|
links: [
|
||||||
{ label: 'Steam', url: 'https://store.steampowered.com/app/1623730/Palworld/' }
|
{ label: 'Steam', url: 'https://store.steampowered.com/app/1623730/Palworld/' }
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
terraria: {
|
||||||
|
address: 'terraria.zeasy.dev:7777',
|
||||||
|
logo: '/terraria.png',
|
||||||
|
links: [
|
||||||
|
{ label: 'Steam', url: 'https://store.steampowered.com/app/105600/Terraria/' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
openttd: {
|
||||||
|
address: 'openttd.zeasy.dev:3979',
|
||||||
|
logo: '/openttd.png',
|
||||||
|
links: [
|
||||||
|
{ label: 'Steam', url: 'https://store.steampowered.com/app/1536610/OpenTTD/' }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +59,8 @@ const getServerInfo = (serverName) => {
|
|||||||
if (name.includes('vrising') || name.includes('v rising')) return serverInfo.vrising
|
if (name.includes('vrising') || name.includes('v rising')) return serverInfo.vrising
|
||||||
if (name.includes('zomboid')) return serverInfo.zomboid
|
if (name.includes('zomboid')) return serverInfo.zomboid
|
||||||
if (name.includes('palworld')) return serverInfo.palworld
|
if (name.includes('palworld')) return serverInfo.palworld
|
||||||
|
if (name.includes('terraria')) return serverInfo.terraria
|
||||||
|
if (name.includes('openttd')) return serverInfo.openttd
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
230
gsm-frontend/src/components/TerrariaConfigEditor.jsx
Normal file
230
gsm-frontend/src/components/TerrariaConfigEditor.jsx
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { FileText, Save, RefreshCw, AlertTriangle, Check, X } from 'lucide-react'
|
||||||
|
import { getTerrariaConfig, saveTerrariaConfig } from '../api'
|
||||||
|
|
||||||
|
export default function TerrariaConfigEditor({ token }) {
|
||||||
|
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)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadConfig()
|
||||||
|
}, [token])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setHasChanges(content !== originalContent)
|
||||||
|
}, [content, originalContent])
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (highlightRef.current && textareaRef.current) {
|
||||||
|
highlightRef.current.scrollTop = textareaRef.current.scrollTop
|
||||||
|
highlightRef.current.scrollLeft = textareaRef.current.scrollLeft
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadConfig() {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const data = await getTerrariaConfig(token)
|
||||||
|
setContent(data.content)
|
||||||
|
setOriginalContent(data.content)
|
||||||
|
} catch (err) {
|
||||||
|
setError('Fehler beim Laden der Config: ' + err.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!hasChanges) return
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
setSuccess(null)
|
||||||
|
try {
|
||||||
|
await saveTerrariaConfig(token, content)
|
||||||
|
setOriginalContent(content)
|
||||||
|
setSuccess('Config gespeichert! Server-Neustart erforderlich.')
|
||||||
|
setTimeout(() => setSuccess(null), 5000)
|
||||||
|
} catch (err) {
|
||||||
|
setError('Fehler beim Speichern: ' + err.message)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDiscard() {
|
||||||
|
setContent(originalContent)
|
||||||
|
setError(null)
|
||||||
|
setSuccess(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightSyntax(text) {
|
||||||
|
if (!text) return ''
|
||||||
|
|
||||||
|
const lines = text.split('\n')
|
||||||
|
|
||||||
|
return lines.map((line) => {
|
||||||
|
let highlighted = line
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
|
||||||
|
// Comments (#)
|
||||||
|
if (line.trim().startsWith('#')) {
|
||||||
|
highlighted = `<span class="text-emerald-500">${highlighted}</span>`
|
||||||
|
}
|
||||||
|
// key=value
|
||||||
|
else if (line.includes('=')) {
|
||||||
|
const idx = line.indexOf('=')
|
||||||
|
const key = highlighted.substring(0, idx)
|
||||||
|
const value = highlighted.substring(idx + 1)
|
||||||
|
|
||||||
|
let coloredValue = value
|
||||||
|
.replace(/\b(\d+)\b/g, '<span class="text-cyan-400">$1</span>')
|
||||||
|
|
||||||
|
highlighted = `<span class="text-blue-400">${key}</span>=<span class="text-amber-300">${coloredValue}</span>`
|
||||||
|
}
|
||||||
|
|
||||||
|
return highlighted
|
||||||
|
}).join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center p-8">
|
||||||
|
<RefreshCw className="w-6 h-6 animate-spin text-gray-400" />
|
||||||
|
<span className="ml-2 text-gray-400">Lade Config...</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||||
|
<FileText className="w-4 h-4" />
|
||||||
|
<span>serverconfig.txt - Server-Einstellungen</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={loadConfig}
|
||||||
|
disabled={loading}
|
||||||
|
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors"
|
||||||
|
title="Neu laden"
|
||||||
|
>
|
||||||
|
<RefreshCw className={"w-5 h-5 " + (loading ? 'animate-spin' : '')} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error/Success messages */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400">
|
||||||
|
<AlertTriangle className="w-5 h-5 flex-shrink-0" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-green-500/10 border border-green-500/30 rounded-lg text-green-400">
|
||||||
|
<Check className="w-5 h-5 flex-shrink-0" />
|
||||||
|
<span>{success}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Editor */}
|
||||||
|
<div className="relative h-[500px] rounded-lg overflow-hidden border border-gray-700">
|
||||||
|
<pre
|
||||||
|
ref={highlightRef}
|
||||||
|
className="absolute inset-0 p-4 m-0 text-sm font-mono bg-gray-900 text-gray-100 overflow-auto pointer-events-none whitespace-pre-wrap break-words"
|
||||||
|
style={{ wordBreak: 'break-word' }}
|
||||||
|
aria-hidden="true"
|
||||||
|
dangerouslySetInnerHTML={{ __html: highlightSyntax(content) + '\n' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
className="absolute inset-0 w-full h-full p-4 text-sm font-mono bg-transparent text-transparent caret-white resize-none focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-inset"
|
||||||
|
spellCheck={false}
|
||||||
|
disabled={loading}
|
||||||
|
style={{ caretColor: 'white' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{hasChanges && (
|
||||||
|
<div className="absolute top-2 right-2 px-2 py-1 bg-yellow-500/20 text-yellow-400 text-xs rounded z-10">
|
||||||
|
Ungespeichert
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center justify-end gap-3">
|
||||||
|
{hasChanges && (
|
||||||
|
<button
|
||||||
|
onClick={handleDiscard}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
Verwerfen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!hasChanges || saving}
|
||||||
|
className={"flex items-center gap-2 px-4 py-2 rounded-lg transition-colors " +
|
||||||
|
(hasChanges && !saving
|
||||||
|
? 'bg-green-600 hover:bg-green-500 text-white border-2 border-green-400'
|
||||||
|
: 'bg-gray-700 text-gray-500 cursor-not-allowed border-2 border-gray-600'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||||
|
Speichern...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className="w-4 h-4" />
|
||||||
|
Speichern
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="mt-4 p-4 bg-gray-800/50 rounded-lg border border-gray-700">
|
||||||
|
<h4 className="text-sm font-medium text-gray-300 mb-2">Legende</h4>
|
||||||
|
<div className="flex flex-wrap gap-4 text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-3 h-3 rounded bg-emerald-500"></span>
|
||||||
|
<span className="text-gray-400">Kommentare</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-3 h-3 rounded bg-blue-400"></span>
|
||||||
|
<span className="text-gray-400">Einstellung</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-3 h-3 rounded bg-amber-300"></span>
|
||||||
|
<span className="text-gray-400">Wert</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-3 h-3 rounded bg-cyan-400"></span>
|
||||||
|
<span className="text-gray-400">Zahlen</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 mt-3">Änderungen werden erst nach Server-Neustart aktiv. Ein Backup wird automatisch erstellt.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,21 +1,12 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useUser } from '../context/UserContext'
|
import { useUser } from '../context/UserContext'
|
||||||
import { getUsers, createUser, updateUserRole, updateUserPassword, deleteUser } from '../api'
|
import { getUsers } from '../api'
|
||||||
|
|
||||||
export default function UserManagement({ onClose }) {
|
export default function UserManagement({ onClose }) {
|
||||||
const { token } = useUser()
|
const { token } = useUser()
|
||||||
const [users, setUsers] = useState([])
|
const [users, setUsers] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [showAddUser, setShowAddUser] = useState(false)
|
|
||||||
const [editingUser, setEditingUser] = useState(null)
|
|
||||||
|
|
||||||
// Form state
|
|
||||||
const [username, setUsername] = useState('')
|
|
||||||
const [password, setPassword] = useState('')
|
|
||||||
const [role, setRole] = useState('user')
|
|
||||||
const [formLoading, setFormLoading] = useState(false)
|
|
||||||
const [formError, setFormError] = useState('')
|
|
||||||
|
|
||||||
const fetchUsers = async () => {
|
const fetchUsers = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -33,71 +24,14 @@ export default function UserManagement({ onClose }) {
|
|||||||
fetchUsers()
|
fetchUsers()
|
||||||
}, [token])
|
}, [token])
|
||||||
|
|
||||||
const resetForm = () => {
|
const getAvatarUrl = (user) => {
|
||||||
setUsername('')
|
const discordId = user.discord_id || user.discordId
|
||||||
setPassword('')
|
if (user.avatar && discordId) {
|
||||||
setRole('user')
|
return `https://cdn.discordapp.com/avatars/${discordId}/${user.avatar}.png?size=64`
|
||||||
setFormError('')
|
|
||||||
setShowAddUser(false)
|
|
||||||
setEditingUser(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAddUser = async (e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
setFormError('')
|
|
||||||
setFormLoading(true)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await createUser(token, { username, password, role })
|
|
||||||
await fetchUsers()
|
|
||||||
resetForm()
|
|
||||||
} catch (err) {
|
|
||||||
setFormError(err.message || 'Failed to create user')
|
|
||||||
} finally {
|
|
||||||
setFormLoading(false)
|
|
||||||
}
|
}
|
||||||
}
|
// Default Discord avatar
|
||||||
|
const defaultIndex = discordId ? parseInt(discordId) % 5 : 0
|
||||||
const handleUpdateUser = async (e) => {
|
return `https://cdn.discordapp.com/embed/avatars/${defaultIndex}.png`
|
||||||
e.preventDefault()
|
|
||||||
setFormError('')
|
|
||||||
setFormLoading(true)
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Update role if changed
|
|
||||||
if (role !== editingUser.role) {
|
|
||||||
await updateUserRole(token, editingUser.id, role)
|
|
||||||
}
|
|
||||||
// Update password if provided
|
|
||||||
if (password) {
|
|
||||||
await updateUserPassword(token, editingUser.id, password)
|
|
||||||
}
|
|
||||||
await fetchUsers()
|
|
||||||
resetForm()
|
|
||||||
} catch (err) {
|
|
||||||
setFormError(err.message || 'Failed to update user')
|
|
||||||
} finally {
|
|
||||||
setFormLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDeleteUser = async (userId) => {
|
|
||||||
if (!confirm('Are you sure you want to delete this user?')) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
await deleteUser(token, userId)
|
|
||||||
await fetchUsers()
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message || 'Failed to delete user')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const startEdit = (user) => {
|
|
||||||
setEditingUser(user)
|
|
||||||
setUsername(user.username)
|
|
||||||
setRole(user.role)
|
|
||||||
setPassword('')
|
|
||||||
setShowAddUser(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const roleLabels = {
|
const roleLabels = {
|
||||||
@@ -106,6 +40,12 @@ export default function UserManagement({ onClose }) {
|
|||||||
superadmin: 'Admin'
|
superadmin: 'Admin'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const roleColors = {
|
||||||
|
user: 'text-gray-400',
|
||||||
|
moderator: 'text-blue-400',
|
||||||
|
superadmin: 'text-amber-400'
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-backdrop fade-in" onClick={onClose}>
|
<div className="modal-backdrop fade-in" onClick={onClose}>
|
||||||
<div className="modal fade-in-scale" style={{ maxWidth: '32rem' }} onClick={(e) => e.stopPropagation()}>
|
<div className="modal fade-in-scale" style={{ maxWidth: '32rem' }} onClick={(e) => e.stopPropagation()}>
|
||||||
@@ -125,109 +65,36 @@ export default function UserManagement({ onClose }) {
|
|||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-center py-4 text-neutral-400">Loading users...</div>
|
<div className="text-center py-4 text-neutral-400">Loading users...</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2 mb-4">
|
<div className="space-y-2">
|
||||||
{users.map((user) => (
|
{users.map((user) => (
|
||||||
<div key={user.id} className="card p-3 flex items-center justify-between">
|
<div key={user.id} className="card p-3 flex items-center gap-3">
|
||||||
<div>
|
<img
|
||||||
<div className="text-white font-medium">{user.username}</div>
|
src={getAvatarUrl(user)}
|
||||||
<div className="text-xs text-neutral-500">{roleLabels[user.role]}</div>
|
alt={user.username}
|
||||||
</div>
|
className="w-10 h-10 rounded-full"
|
||||||
<div className="flex gap-2">
|
/>
|
||||||
<button
|
<div className="flex-1 min-w-0">
|
||||||
onClick={() => startEdit(user)}
|
<div className="text-white font-medium truncate">{user.username}</div>
|
||||||
className="btn btn-ghost text-sm"
|
<div className="flex items-center gap-2 text-xs">
|
||||||
>
|
<span className={roleColors[user.role] || 'text-neutral-500'}>
|
||||||
Edit
|
{roleLabels[user.role]}
|
||||||
</button>
|
</span>
|
||||||
<button
|
{(user.discord_id || user.discordId) && (
|
||||||
onClick={() => handleDeleteUser(user.id)}
|
<span className="text-neutral-600">ID: {user.discord_id || user.discordId}</span>
|
||||||
className="btn btn-ghost text-sm text-red-400"
|
)}
|
||||||
>
|
</div>
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Add/Edit Form */}
|
{/* Info about Discord management */}
|
||||||
{(showAddUser || editingUser) ? (
|
<div className="mt-4 p-3 bg-neutral-800/50 rounded-lg border border-neutral-700">
|
||||||
<form onSubmit={editingUser ? handleUpdateUser : handleAddUser} className="space-y-4">
|
<p className="text-xs text-neutral-400">
|
||||||
<div className="border-t border-neutral-800 pt-4">
|
Benutzer und Rollen werden über Discord verwaltet.
|
||||||
<h3 className="text-sm font-medium text-neutral-300 mb-3">
|
</p>
|
||||||
{editingUser ? 'Edit User' : 'Add New User'}
|
</div>
|
||||||
</h3>
|
|
||||||
|
|
||||||
{formError && (
|
|
||||||
<div className="alert alert-error mb-4">{formError}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!editingUser && (
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">Username</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={username}
|
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
|
||||||
className="input"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">
|
|
||||||
{editingUser ? 'New Password (leave empty to keep current)' : 'Password'}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
className="input"
|
|
||||||
required={!editingUser}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">Role</label>
|
|
||||||
<select
|
|
||||||
value={role}
|
|
||||||
onChange={(e) => setRole(e.target.value)}
|
|
||||||
className="select w-full"
|
|
||||||
>
|
|
||||||
<option value="user">Viewer</option>
|
|
||||||
<option value="moderator">Operator</option>
|
|
||||||
<option value="superadmin">Admin</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={formLoading}
|
|
||||||
className="btn btn-primary flex-1"
|
|
||||||
>
|
|
||||||
{formLoading ? 'Saving...' : (editingUser ? 'Update User' : 'Add User')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={resetForm}
|
|
||||||
className="btn btn-secondary"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={() => setShowAddUser(true)}
|
|
||||||
className="btn btn-primary w-full"
|
|
||||||
>
|
|
||||||
Add User
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -267,8 +267,8 @@ export default function ZomboidConfigEditor({ token }) {
|
|||||||
disabled={!hasChanges || saving}
|
disabled={!hasChanges || saving}
|
||||||
className={"flex items-center gap-2 px-4 py-2 rounded-lg transition-colors " +
|
className={"flex items-center gap-2 px-4 py-2 rounded-lg transition-colors " +
|
||||||
(hasChanges && !saving
|
(hasChanges && !saving
|
||||||
? 'bg-green-600 hover:bg-green-500 text-white'
|
? 'bg-green-600 hover:bg-green-500 text-white border-2 border-green-400'
|
||||||
: 'bg-gray-700 text-gray-500 cursor-not-allowed'
|
: 'bg-gray-700 text-gray-500 cursor-not-allowed border-2 border-gray-600'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{saving ? (
|
{saving ? (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { getServers } from '../api'
|
import { getServers, getAllDisplaySettings } from '../api'
|
||||||
import { useUser } from '../context/UserContext'
|
import { useUser } from '../context/UserContext'
|
||||||
import ServerCard from '../components/ServerCard'
|
import ServerCard from '../components/ServerCard'
|
||||||
import UserManagement from '../components/UserManagement'
|
import UserManagement from '../components/UserManagement'
|
||||||
@@ -11,6 +11,7 @@ export default function Dashboard({ onLogin, onLogout }) {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { user, token, loading: userLoading, isSuperadmin, role } = useUser()
|
const { user, token, loading: userLoading, isSuperadmin, role } = useUser()
|
||||||
const [servers, setServers] = useState([])
|
const [servers, setServers] = useState([])
|
||||||
|
const [displaySettings, setDisplaySettings] = useState({})
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [showUserMgmt, setShowUserMgmt] = useState(false)
|
const [showUserMgmt, setShowUserMgmt] = useState(false)
|
||||||
@@ -22,8 +23,12 @@ export default function Dashboard({ onLogin, onLogout }) {
|
|||||||
|
|
||||||
const fetchServers = async () => {
|
const fetchServers = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await getServers(token)
|
const [data, settings] = await Promise.all([
|
||||||
|
getServers(token),
|
||||||
|
getAllDisplaySettings(token)
|
||||||
|
])
|
||||||
setServers(data)
|
setServers(data)
|
||||||
|
setDisplaySettings(settings)
|
||||||
setError('')
|
setError('')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.message.includes('401') || err.message.includes('403')) {
|
if (err.message.includes('401') || err.message.includes('403')) {
|
||||||
@@ -225,6 +230,7 @@ export default function Dashboard({ onLogin, onLogout }) {
|
|||||||
server={server}
|
server={server}
|
||||||
onClick={() => navigate('/server/' + server.id)}
|
onClick={() => navigate('/server/' + server.id)}
|
||||||
isAuthenticated={isAuthenticated}
|
isAuthenticated={isAuthenticated}
|
||||||
|
displaySettings={displaySettings[server.id]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import MetricsChart from '../components/MetricsChart'
|
|||||||
import FactorioWorldManager from '../components/FactorioWorldManager'
|
import FactorioWorldManager from '../components/FactorioWorldManager'
|
||||||
import PalworldConfigEditor from '../components/PalworldConfigEditor'
|
import PalworldConfigEditor from '../components/PalworldConfigEditor'
|
||||||
import ZomboidConfigEditor from '../components/ZomboidConfigEditor'
|
import ZomboidConfigEditor from '../components/ZomboidConfigEditor'
|
||||||
|
import TerrariaConfigEditor from '../components/TerrariaConfigEditor'
|
||||||
|
import OpenTTDConfigEditor from '../components/OpenTTDConfigEditor'
|
||||||
|
|
||||||
const getServerLogo = (serverName) => {
|
const getServerLogo = (serverName) => {
|
||||||
const name = serverName.toLowerCase()
|
const name = serverName.toLowerCase()
|
||||||
@@ -14,6 +16,8 @@ const getServerLogo = (serverName) => {
|
|||||||
if (name.includes("vrising") || name.includes("v rising")) return "/vrising.png"
|
if (name.includes("vrising") || name.includes("v rising")) return "/vrising.png"
|
||||||
if (name.includes("zomboid")) return "/zomboid.png"
|
if (name.includes("zomboid")) return "/zomboid.png"
|
||||||
if (name.includes("palworld")) return "/palworld.png"
|
if (name.includes("palworld")) return "/palworld.png"
|
||||||
|
if (name.includes("terraria")) return "/terraria.png"
|
||||||
|
if (name.includes("openttd")) return "/openttd.png"
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
export default function ServerDetail() {
|
export default function ServerDetail() {
|
||||||
@@ -278,9 +282,15 @@ const formatUptime = (seconds) => {
|
|||||||
...(isModerator && server?.type === 'palworld' ? [
|
...(isModerator && server?.type === 'palworld' ? [
|
||||||
{ id: 'config', label: 'Config' },
|
{ id: 'config', label: 'Config' },
|
||||||
] : []),
|
] : []),
|
||||||
|
...(isModerator && server?.type === 'terraria' ? [
|
||||||
|
{ id: 'config', label: 'Config' },
|
||||||
|
] : []),
|
||||||
...(isModerator && server?.type === 'zomboid' ? [
|
...(isModerator && server?.type === 'zomboid' ? [
|
||||||
{ id: 'zomboid-config', label: 'Config' },
|
{ id: 'zomboid-config', label: 'Config' },
|
||||||
] : []),
|
] : []),
|
||||||
|
...(isModerator && server?.type === 'openttd' ? [
|
||||||
|
{ id: 'openttd-config', label: 'Config' },
|
||||||
|
] : []),
|
||||||
...(isModerator ? [
|
...(isModerator ? [
|
||||||
{ id: 'settings', label: 'Einstellungen' },
|
{ id: 'settings', label: 'Einstellungen' },
|
||||||
] : []),
|
] : []),
|
||||||
@@ -600,6 +610,13 @@ const formatUptime = (seconds) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Config Tab - Terraria only */}
|
||||||
|
{activeTab === 'config' && isModerator && server.type === 'terraria' && (
|
||||||
|
<div className="tab-content">
|
||||||
|
<TerrariaConfigEditor token={token} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Config Tab - Zomboid only */}
|
{/* Config Tab - Zomboid only */}
|
||||||
{activeTab === 'zomboid-config' && isModerator && server.type === 'zomboid' && (
|
{activeTab === 'zomboid-config' && isModerator && server.type === 'zomboid' && (
|
||||||
<div className="tab-content">
|
<div className="tab-content">
|
||||||
@@ -607,6 +624,13 @@ const formatUptime = (seconds) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Config Tab - OpenTTD only */}
|
||||||
|
{activeTab === 'openttd-config' && isModerator && server.type === 'openttd' && (
|
||||||
|
<div className="tab-content">
|
||||||
|
<OpenTTDConfigEditor token={token} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Settings Tab */}
|
{/* Settings Tab */}
|
||||||
{activeTab === 'settings' && isModerator && (
|
{activeTab === 'settings' && isModerator && (
|
||||||
<div className="space-y-4 tab-content">
|
<div className="space-y-4 tab-content">
|
||||||
|
|||||||
Reference in New Issue
Block a user