Files
GSM/gsm-frontend/src/components/ZomboidConfigEditor.jsx
Alexander Zielonka 66716279ad
All checks were successful
Deploy GSM / deploy (push) Successful in 22s
Fix syntax highlighting regex order in config editors
The number regex was applied after the boolean regex, causing it to
match "400" in CSS class names like "text-orange-400" and corrupt
the HTML output. Now uses placeholder tokens to mark numbers before
adding any HTML tags.

Affected editors: Palworld, Zomboid, OpenTTD

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 03:31:20 +01:00

328 lines
12 KiB
JavaScript

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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
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 numbers first (with placeholders), then booleans
// This prevents the regex from matching numbers in CSS class names like "text-orange-400"
highlighted = highlighted
.replace(/\b(\d+\.?\d*)\b/g, '%%%NUM_START%%%$1%%%NUM_END%%%')
.replace(/\b(true|false|nil)\b/g, '<span class="text-orange-400">$1</span>')
.replace(/%%%NUM_START%%%/g, '<span class="text-cyan-400">')
.replace(/%%%NUM_END%%%/g, '</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 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>
</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>
)
}