327 lines
11 KiB
JavaScript
327 lines
11 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, '&')
|
|
.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>
|
|
)
|
|
}
|