Initial commit
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
import Editor from '@monaco-editor/react'
|
||||
|
||||
interface Props {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
language?: string
|
||||
}
|
||||
|
||||
export default function ConfigEditor({ value, onChange, language = 'plaintext' }: Props) {
|
||||
return (
|
||||
<div className="border border-neutral-800 rounded-lg overflow-hidden">
|
||||
<Editor
|
||||
height="500px"
|
||||
language={language}
|
||||
value={value}
|
||||
onChange={v => onChange(v ?? '')}
|
||||
theme="vs-dark"
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 13,
|
||||
fontFamily: '"JetBrains Mono", "Fira Code", monospace',
|
||||
lineNumbers: 'on',
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
tabSize: 2,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
const nav = [
|
||||
{ to: '/', label: 'Dashboard', icon: '⊞' },
|
||||
{ to: '/settings', label: 'Settings', icon: '🖥' },
|
||||
{ to: '/configs', label: 'Configs', icon: '⚙' },
|
||||
{ to: '/modlists', label: 'Modlists', icon: '📦' },
|
||||
{ to: '/logs', label: 'Logs', icon: '📋' },
|
||||
]
|
||||
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
const loc = useLocation()
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-neutral-950 text-neutral-100">
|
||||
<aside className="w-56 border-r border-neutral-800 p-4 flex flex-col gap-2">
|
||||
<h1 className="text-lg font-bold mb-4 tracking-tight">Arma3 Web</h1>
|
||||
{nav.map(item => (
|
||||
<Link
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors ${
|
||||
loc.pathname === item.to || (item.to !== '/' && loc.pathname.startsWith(item.to))
|
||||
? 'bg-neutral-800 text-white'
|
||||
: 'text-neutral-400 hover:text-white hover:bg-neutral-800/50'
|
||||
}`}
|
||||
>
|
||||
<span>{item.icon}</span>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</aside>
|
||||
<main className="flex-1 overflow-auto p-6">{children}</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
|
||||
interface Props {
|
||||
wsUrl: string
|
||||
}
|
||||
|
||||
export default function LiveTerminal({ wsUrl }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const terminalRef = useRef<Terminal | null>(null)
|
||||
const fitRef = useRef<FitAddon | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
|
||||
const term = new Terminal({
|
||||
theme: { background: '#0a0a0a', foreground: '#e0e0e0', cursor: '#e0e0e0' },
|
||||
fontSize: 13,
|
||||
fontFamily: '"JetBrains Mono", "Fira Code", monospace',
|
||||
cursorBlink: true,
|
||||
convertEol: true,
|
||||
rows: 30,
|
||||
})
|
||||
|
||||
const fit = new FitAddon()
|
||||
term.loadAddon(fit)
|
||||
term.open(containerRef.current)
|
||||
fit.fit()
|
||||
term.write('Connecting...\r\n')
|
||||
|
||||
terminalRef.current = term
|
||||
fitRef.current = fit
|
||||
|
||||
let ws: WebSocket | null = null
|
||||
let reconnectTimer: ReturnType<typeof setTimeout>
|
||||
let disposed = false
|
||||
|
||||
function connect() {
|
||||
if (disposed) return
|
||||
ws = new WebSocket(wsUrl)
|
||||
ws.onopen = () => {
|
||||
if (disposed) { ws?.close(); return }
|
||||
term.clear()
|
||||
term.write('--- Connected ---\r\n')
|
||||
}
|
||||
ws.onmessage = (event) => {
|
||||
const line = event.data as string
|
||||
if (line === '[SERVER_PROCESS_EXITED]') {
|
||||
term.write('\r\n\x1b[31m--- Process exited ---\x1b[0m\r\n')
|
||||
} else if (line.startsWith('[STEAMCMD] SUCCESS:')) {
|
||||
term.write('\r\n\x1b[32m' + line + '\x1b[0m\r\n')
|
||||
} else if (line.startsWith('[STEAMCMD] ERROR:')) {
|
||||
term.write('\r\n\x1b[31m' + line + '\x1b[0m\r\n')
|
||||
} else if (line.startsWith('[STEAMCMD]')) {
|
||||
term.write('\r\n\x1b[33m' + line + '\x1b[0m\r\n')
|
||||
} else {
|
||||
term.writeln(line)
|
||||
}
|
||||
}
|
||||
ws.onclose = () => {
|
||||
if (disposed) return
|
||||
term.write('\r\n\x1b[33m--- Disconnected, reconnecting in 3s ---\x1b[0m\r\n')
|
||||
reconnectTimer = setTimeout(connect, 3000)
|
||||
}
|
||||
ws.onerror = () => ws?.close()
|
||||
}
|
||||
|
||||
connect()
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
try { fit.fit() } catch {}
|
||||
})
|
||||
resizeObserver.observe(containerRef.current)
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
clearTimeout(reconnectTimer)
|
||||
resizeObserver.disconnect()
|
||||
ws?.close()
|
||||
term.dispose()
|
||||
}
|
||||
}, [wsUrl])
|
||||
|
||||
return <div ref={containerRef} className="h-full min-h-[400px] bg-[#0a0a0a] rounded-lg" />
|
||||
}
|
||||
Reference in New Issue
Block a user