palworld added
This commit is contained in:
175
gsm-frontend/src/components/ActivityLog.jsx
Normal file
175
gsm-frontend/src/components/ActivityLog.jsx
Normal file
@@ -0,0 +1,175 @@
|
||||
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',
|
||||
palworld_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: '📝',
|
||||
palworld_config: '📝',
|
||||
factorio_world_create: '🌍',
|
||||
factorio_world_delete: '🗑️'
|
||||
}
|
||||
|
||||
const serverLabels = {
|
||||
minecraft: 'Minecraft',
|
||||
factorio: 'Factorio',
|
||||
zomboid: 'Project Zomboid',
|
||||
vrising: 'V Rising',
|
||||
palworld: 'Palworld'
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="modal-backdrop fade-in" onClick={onClose}>
|
||||
<div className="modal fade-in-scale" style={{ maxWidth: '42rem' }} onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2 className="modal-title">Activity Log</h2>
|
||||
<button onClick={onClose} className="btn btn-ghost">
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body" style={{ maxHeight: '60vh', overflowY: 'auto' }}>
|
||||
{error && (
|
||||
<div className="alert alert-error mb-4">{error}</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-4 text-neutral-400">Laden...</div>
|
||||
) : logs.length === 0 ? (
|
||||
<div className="text-center py-4 text-neutral-500">Noch keine Aktivitäten</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{logs.map((log) => {
|
||||
const avatarUrl = getAvatarUrl(log.discord_id, log.avatar)
|
||||
const profileUrl = log.discord_id ? getDiscordProfileUrl(log.discord_id) : null
|
||||
|
||||
return (
|
||||
<div key={log.id} className="card p-3 flex items-start gap-3">
|
||||
{/* Avatar or Action Icon */}
|
||||
{avatarUrl ? (
|
||||
<a
|
||||
href={profileUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt=""
|
||||
className="w-8 h-8 rounded-full hover:ring-2 hover:ring-blue-500 transition-all"
|
||||
/>
|
||||
</a>
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-neutral-700 flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-neutral-400 text-xs">
|
||||
{log.username?.charAt(0)?.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{profileUrl ? (
|
||||
<a
|
||||
href={profileUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-white font-medium hover:text-blue-400 transition-colors"
|
||||
>
|
||||
{log.username}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-white font-medium">{log.username}</span>
|
||||
)}
|
||||
<span className="text-neutral-500">
|
||||
{actionIcons[log.action] || '📋'} {actionLabels[log.action] || log.action}
|
||||
</span>
|
||||
{log.target && (
|
||||
<span className="text-blue-400">
|
||||
{serverLabels[log.target] || log.target}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{log.details && (
|
||||
<div className="text-sm text-neutral-500 mt-1 truncate font-mono">
|
||||
{log.details}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-neutral-500 whitespace-nowrap">
|
||||
{formatDate(log.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
336
gsm-frontend/src/components/PalworldConfigEditor.jsx
Normal file
336
gsm-frontend/src/components/PalworldConfigEditor.jsx
Normal file
@@ -0,0 +1,336 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { FileText, Save, RefreshCw, AlertTriangle, Check, X, ChevronDown } from 'lucide-react'
|
||||
import { getPalworldConfigs, getPalworldConfig, savePalworldConfig } from '../api'
|
||||
|
||||
export default function PalworldConfigEditor({ 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 getPalworldConfigs(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 getPalworldConfig(token, filename)
|
||||
setSelectedFile(filename)
|
||||
// Format the config for better readability
|
||||
let formattedContent = data.content
|
||||
if (filename === 'PalWorldSettings.ini') {
|
||||
formattedContent = formatPalworldConfig(data.content)
|
||||
}
|
||||
setContent(formattedContent)
|
||||
setOriginalContent(formattedContent)
|
||||
} catch (err) {
|
||||
setError('Fehler beim Laden: ' + err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Format Palworld config for better readability
|
||||
function formatPalworldConfig(content) {
|
||||
// The config is one long line with OptionSettings=(...)
|
||||
// We'll split key=value pairs for better readability
|
||||
return content
|
||||
.replace(/,(?=[A-Z])/g, ',\n') // Add newline before each key
|
||||
.replace(/\((?=[A-Z])/g, '(\n') // Newline after opening paren
|
||||
.replace(/\)$/gm, '\n)') // Newline before closing paren
|
||||
}
|
||||
|
||||
// Compact config back to single line for saving
|
||||
function compactPalworldConfig(content) {
|
||||
if (!selectedFile?.includes('PalWorldSettings.ini')) return content
|
||||
return content
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.join('')
|
||||
.replace(/,\s+/g, ',')
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!selectedFile || !hasChanges) return
|
||||
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
try {
|
||||
const saveContent = selectedFile === 'PalWorldSettings.ini'
|
||||
? compactPalworldConfig(content)
|
||||
: content
|
||||
await savePalworldConfig(token, selectedFile, saveContent)
|
||||
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 getFileDescription(filename) {
|
||||
const descriptions = {
|
||||
'PalWorldSettings.ini': 'Server-Einstellungen (Spieler, Raten, RCON)',
|
||||
'Engine.ini': 'Engine-Konfiguration (Performance)',
|
||||
'GameUserSettings.ini': 'Grafik und Audio Einstellungen'
|
||||
}
|
||||
return descriptions[filename] || filename
|
||||
}
|
||||
|
||||
// Highlight syntax for INI files
|
||||
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">${highlighted}</span>`
|
||||
}
|
||||
// Comments (;)
|
||||
else if (line.trim().startsWith(';')) {
|
||||
highlighted = `<span class="text-emerald-500">${highlighted}</span>`
|
||||
}
|
||||
// Highlight key=value
|
||||
else if (line.includes('=')) {
|
||||
const idx = line.indexOf('=')
|
||||
const key = highlighted.substring(0, idx)
|
||||
const value = highlighted.substring(idx + 1)
|
||||
|
||||
// Color boolean values
|
||||
let coloredValue = value
|
||||
.replace(/\b(True|False)\b/gi, '<span class="text-orange-400">$1</span>')
|
||||
.replace(/\b(\d+\.?\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 && files.length === 0) {
|
||||
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-Dateien...</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* File selector */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<select
|
||||
value={selectedFile || ''}
|
||||
onChange={(e) => {
|
||||
if (hasChanges) {
|
||||
if (!confirm('Ungespeicherte Änderungen verwerfen?')) return
|
||||
}
|
||||
loadFile(e.target.value)
|
||||
}}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 pr-10 text-white appearance-none cursor-pointer focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
{files.map(file => (
|
||||
<option key={file.filename} value={file.filename}>
|
||||
{file.filename}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => loadFile(selectedFile)}
|
||||
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>
|
||||
|
||||
{/* File description */}
|
||||
{selectedFile && (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<FileText className="w-4 h-4" />
|
||||
<span>{getFileDescription(selectedFile)}</span>
|
||||
</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 with syntax highlighting */}
|
||||
<div className="relative h-[500px] rounded-lg overflow-hidden border border-gray-700">
|
||||
{/* Highlighted background layer */}
|
||||
<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' }}
|
||||
/>
|
||||
|
||||
{/* Transparent textarea for editing */}
|
||||
<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' }}
|
||||
/>
|
||||
|
||||
{/* Change indicator */}
|
||||
{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-between">
|
||||
<div className="text-sm text-gray-500">
|
||||
{selectedFile && files.find(f => f.filename === selectedFile)?.modified && (
|
||||
<span>Zuletzt geändert: {files.find(f => f.filename === selectedFile).modified}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center 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'
|
||||
: 'bg-gray-700 text-gray-500 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
Speichern...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4" />
|
||||
Speichern
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</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-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>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
const serverInfo = {
|
||||
minecraft: {
|
||||
address: 'minecraft.dimension47.de',
|
||||
address: 'minecraft.zeasy.dev',
|
||||
logo: '/minecraft.png',
|
||||
links: [
|
||||
{ label: 'ATM10 Modpack', url: 'https://www.curseforge.com/minecraft/modpacks/all-the-mods-10' }
|
||||
@@ -8,7 +8,7 @@ const serverInfo = {
|
||||
},
|
||||
factorio: {
|
||||
hint: 'Serverpasswort: affe',
|
||||
address: 'factorio.dimension47.de',
|
||||
address: 'factorio.zeasy.dev',
|
||||
logo: '/factorio.png',
|
||||
links: [
|
||||
{ label: 'Steam', url: 'https://store.steampowered.com/app/427520/Factorio/' }
|
||||
@@ -20,6 +20,21 @@ const serverInfo = {
|
||||
links: [
|
||||
{ label: 'Steam', url: 'https://store.steampowered.com/app/1604030/V_Rising/' }
|
||||
]
|
||||
},
|
||||
zomboid: {
|
||||
hint: 'Version 42.13.1',
|
||||
address: 'pz.zeasy.dev:16261',
|
||||
logo: '/zomboid.png',
|
||||
links: [
|
||||
{ label: 'Steam', url: 'https://store.steampowered.com/app/108600/Project_Zomboid/' }
|
||||
]
|
||||
},
|
||||
palworld: {
|
||||
address: 'palworld.zeasy.dev:8211',
|
||||
logo: '/palworld.png',
|
||||
links: [
|
||||
{ label: 'Steam', url: 'https://store.steampowered.com/app/1623730/Palworld/' }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,11 +43,25 @@ const getServerInfo = (serverName) => {
|
||||
if (name.includes('minecraft') || name.includes('all the mods')) return serverInfo.minecraft
|
||||
if (name.includes('factorio')) return serverInfo.factorio
|
||||
if (name.includes('vrising') || name.includes('v rising')) return serverInfo.vrising
|
||||
if (name.includes('zomboid')) return serverInfo.zomboid
|
||||
if (name.includes('palworld')) return serverInfo.palworld
|
||||
return null
|
||||
}
|
||||
|
||||
export default function ServerCard({ server, onClick, isAuthenticated }) {
|
||||
const info = getServerInfo(server.name)
|
||||
export default function ServerCard({ server, onClick, isAuthenticated, displaySettings }) {
|
||||
const defaultInfo = getServerInfo(server.name)
|
||||
|
||||
// Merge default info with database display settings (database takes priority)
|
||||
const info = defaultInfo ? {
|
||||
...defaultInfo,
|
||||
address: displaySettings?.address || defaultInfo.address,
|
||||
hint: displaySettings?.hint || defaultInfo.hint
|
||||
} : (displaySettings ? {
|
||||
address: displaySettings.address,
|
||||
hint: displaySettings.hint,
|
||||
logo: null,
|
||||
links: []
|
||||
} : null)
|
||||
|
||||
const formatUptime = (seconds) => {
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
@@ -106,27 +135,20 @@ export default function ServerCard({ server, onClick, isAuthenticated }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Whitelist notice for Minecraft - only for authenticated users */}
|
||||
{isAuthenticated && server.type === 'minecraft' && (
|
||||
{/* Server hint - only for authenticated users */}
|
||||
{isAuthenticated && info?.hint && (
|
||||
<div className="mb-4 text-xs text-neutral-500">
|
||||
{info.hint}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Whitelist notice for Minecraft - only if no custom hint is set */}
|
||||
{isAuthenticated && server.type === 'minecraft' && !displaySettings?.hint && (
|
||||
<div className="mb-4 text-xs text-neutral-500">
|
||||
Whitelist erforderlich - im Whitelist-Tab freischalten
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Factorio notice - only for authenticated users */}
|
||||
{isAuthenticated && server.type === 'factorio' && (
|
||||
<div className="mb-4 text-xs text-neutral-500">
|
||||
Serverpasswort: affe
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* V Rising notice - only for authenticated users */}
|
||||
{isAuthenticated && server.type === 'vrising' && (
|
||||
<div className="mb-4 text-xs text-neutral-500">
|
||||
In der Serverliste suchen - Passwort: affe
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metrics */}
|
||||
<div className="space-y-3">
|
||||
{/* CPU */}
|
||||
@@ -162,13 +184,25 @@ export default function ServerCard({ server, onClick, isAuthenticated }) {
|
||||
|
||||
{/* Footer Stats */}
|
||||
<div className="flex items-center justify-between mt-4 pt-4 border-t border-neutral-800 text-sm">
|
||||
<div className="text-neutral-400">
|
||||
<span className="text-white font-medium">{server.players.online}</span>
|
||||
{server.players.max ? ' / ' + server.players.max : ''} players
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-neutral-400">
|
||||
<span className="text-white font-medium">{server.players.online}</span>
|
||||
{server.players.max ? ' / ' + server.players.max : ''} Spieler
|
||||
</div>
|
||||
{server.running && server.autoShutdown?.enabled && server.autoShutdown?.emptySinceMinutes !== null && (
|
||||
<div className="flex items-center gap-1.5 text-yellow-500">
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="text-xs">
|
||||
Shutdown in {server.autoShutdown.timeoutMinutes - server.autoShutdown.emptySinceMinutes}m
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{server.running && (
|
||||
<div className="text-neutral-400">
|
||||
Uptime: <span className="text-white">{formatUptime(server.metrics.uptime)}</span>
|
||||
Laufzeit: <span className="text-white">{formatUptime(server.metrics.uptime)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
326
gsm-frontend/src/components/ZomboidConfigEditor.jsx
Normal file
326
gsm-frontend/src/components/ZomboidConfigEditor.jsx
Normal file
@@ -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, '<')
|
||||
.replace(/>/g, '>')
|
||||
|
||||
if (isLua) {
|
||||
// Lua: -- comments
|
||||
if (line.trim().startsWith('--')) {
|
||||
highlighted = `<span class="text-emerald-500">${highlighted}</span>`
|
||||
} else if (line.includes('--')) {
|
||||
const idx = line.indexOf('--')
|
||||
const code = highlighted.substring(0, idx)
|
||||
const comment = highlighted.substring(idx)
|
||||
highlighted = `${code}<span class="text-emerald-500">${comment}</span>`
|
||||
}
|
||||
// Highlight true/false/nil
|
||||
highlighted = highlighted
|
||||
.replace(/\b(true|false|nil)\b/g, '<span class="text-orange-400">$1</span>')
|
||||
// Highlight numbers
|
||||
highlighted = highlighted
|
||||
.replace(/\b(\d+\.?\d*)\b/g, '<span class="text-cyan-400">$1</span>')
|
||||
} else {
|
||||
// INI: # comments
|
||||
if (line.trim().startsWith('#')) {
|
||||
highlighted = `<span class="text-emerald-500">${highlighted}</span>`
|
||||
}
|
||||
// Highlight key=value
|
||||
else if (line.includes('=')) {
|
||||
const idx = line.indexOf('=')
|
||||
const key = highlighted.substring(0, idx)
|
||||
const value = highlighted.substring(idx + 1)
|
||||
highlighted = `<span class="text-blue-400">${key}</span>=<span class="text-amber-300">${value}</span>`
|
||||
}
|
||||
}
|
||||
|
||||
return highlighted
|
||||
}).join('\n')
|
||||
}
|
||||
|
||||
if (loading && files.length === 0) {
|
||||
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-Dateien...</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* File selector */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<select
|
||||
value={selectedFile || ''}
|
||||
onChange={(e) => {
|
||||
if (hasChanges) {
|
||||
if (!confirm('Ungespeicherte Änderungen verwerfen?')) return
|
||||
}
|
||||
loadFile(e.target.value)
|
||||
}}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 pr-10 text-white appearance-none cursor-pointer focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
{files.map(file => (
|
||||
<option key={file.filename} value={file.filename}>
|
||||
{file.filename}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => loadFile(selectedFile)}
|
||||
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>
|
||||
|
||||
{/* File description */}
|
||||
{selectedFile && (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<FileText className="w-4 h-4" />
|
||||
<span>{getFileDescription(selectedFile)}</span>
|
||||
</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 with syntax highlighting */}
|
||||
<div className="relative h-[500px] rounded-lg overflow-hidden border border-gray-700">
|
||||
{/* Highlighted background layer */}
|
||||
<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, selectedFile) + '\n' }}
|
||||
/>
|
||||
|
||||
{/* Transparent textarea for editing */}
|
||||
<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' }}
|
||||
/>
|
||||
|
||||
{/* Change indicator */}
|
||||
{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-between">
|
||||
<div className="text-sm text-gray-500">
|
||||
{selectedFile && files.find(f => f.filename === selectedFile)?.modified && (
|
||||
<span>Zuletzt geändert: {files.find(f => f.filename === selectedFile).modified}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center 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'
|
||||
: 'bg-gray-700 text-gray-500 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
Speichern...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4" />
|
||||
Speichern
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
{selectedFile?.endsWith('.ini') && (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
{selectedFile?.endsWith('.lua') && (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded bg-orange-400"></span>
|
||||
<span className="text-gray-400">Boolean/Nil</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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user