The site half of stage 1, on protocol 14: - server/worldEvents.js: the 24 types, D372's four credit rules, the grid from the map's geometry (none off the map), and the per-viewer reading. - The feed serves `world.event` (a public kind): an unknown type is staff's; the event and its grid are public; names only for the presence audience (D376); Steam ids and positions only for staff (D353). A downed one carries `credited`/`creditedCount` by the server's rule. Client lines for all 24 types and a World events filter. - rust_servers.credit_rule (blow, last, most, all; default blow), set on the server's settings. Ingest counts heli_kills (patrol helicopters and Chinooks, D379) and apc_kills from `world.event` by that rule, so the line and Rotorbane / Tankbreaker agree. A protocol-13 tally's heliKills/apcKills still add as before. - The tally's cratesHacked / dropsLooted → crates_hacked / drops_looted; title categories Safecracker and Scavenger (D356); leaderboard columns Crates hacked and Drops looted, sortable (D377). - 24 engagement triggers rust.world.<event> (D355): ceiling everyone, cooldown subject the server and event (crates add where), names only. - server/eventWorldCall.js: rust.world.call, "Call a world event" (risk change, reversible none, budget rust.world.calls). A position for an airdrop or a patrol helicopter only; the Chinook takes none (D378). Server 585/585 (22 new in test/worldEvents.test.js), client 84/84, every check; booted against the pinned core (43 triggers, 12 actions, 10 budgets, 20 option sources), routes.manifest unchanged at 89. Engagement manifest and swagger fragment regenerated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
455 lines
21 KiB
JavaScript
455 lines
21 KiB
JavaScript
// ── Admin · Rust · Servers (phase 16, D133) ───────────────────────────────
|
||
//
|
||
// The page the module did not have. Until phase 16 a server row was written only
|
||
// through `PUT /admin/rust/servers/:id` — D106 recorded it, and the README said
|
||
// "in Admin → Rust, add a server" about a page that did not exist. D130's wipe
|
||
// schedule needed somewhere to be typed, and the org lead chose to build the
|
||
// missing page rather than hang the schedule on the visibility page (D133).
|
||
//
|
||
// One form for adding and editing, because they are one `PUT`. Three rules it
|
||
// keeps from the API:
|
||
//
|
||
// 1. **The token is write-only.** The field is blank on every edit, a blank
|
||
// save leaves the stored one alone, and the row says whether one is stored.
|
||
// 2. **The protocol a row was configured against is kept.** The `PUT` defaults
|
||
// an omitted protocol to this build's, so an edit sends the stored value back
|
||
// rather than silently re-stamping the row.
|
||
// 3. **The wipe schedule is the operator's words, not the forecast.** The form
|
||
// shows what was stored; the row shows what it computes to, from the same
|
||
// answer the public pages read.
|
||
//
|
||
// Test and Delete act at once rather than on Save — they are questions put to a
|
||
// sidecar and a removal, not settings.
|
||
//
|
||
// Phase 17 added a server's chat titles and the announcement voice, in
|
||
// `ChatTitles.jsx`, each saved on its own.
|
||
|
||
import { useState } from 'react'
|
||
|
||
import { ErrorState, Loading, useAsync } from '../../core.js'
|
||
import api from '../../api.js'
|
||
import { ago, nextWipe } from '../../lib/format.js'
|
||
import { CategoryTitles, TitlesForm, VoiceCard, integrationsLine, titlesSummary } from './ChatTitles.jsx'
|
||
|
||
const RULES = [
|
||
{ id: 'none', label: 'No schedule', hint: 'Nothing is forecast, not even the monthly forced wipe.' },
|
||
{ id: 'forced', label: 'Forced wipe only', hint: 'The first Thursday of each month, 19:00 UK time — Facepunch forces it on every server.' },
|
||
{ id: 'weekly', label: 'Weekly', hint: 'Every week on the day and time below, and the forced wipe.' },
|
||
{ id: 'biweekly', label: 'Every other week', hint: 'Every second week, on the weeks the date below falls in, and the forced wipe.' },
|
||
]
|
||
|
||
// Who a downed patrol helicopter, Chinook or Bradley credits (protocol 14, D372).
|
||
// The same rule writes the feed line and the Rotorbane / Tankbreaker counts; the
|
||
// server's own list, mirrored here so the form needs no round trip.
|
||
const CREDIT_RULES = [
|
||
{ id: 'blow', label: 'Killing blow', hint: 'The player whose hit destroyed it — for the patrol helicopter, the crippling hit, else the last hit. The default.' },
|
||
{ id: 'last', label: 'Last hit', hint: 'The last player to hurt it.' },
|
||
{ id: 'most', label: 'Most damage', hint: 'The player who dealt it the most damage.' },
|
||
{ id: 'all', label: 'Everyone who hurt it', hint: 'Every player who damaged it, one each.' },
|
||
]
|
||
|
||
const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
|
||
|
||
const SOURCE = { forced: 'the forced wipe', rule: 'its own schedule', once: 'the one-off date' }
|
||
|
||
/** The browser's own zone — the likeliest answer for an operator typing a time. */
|
||
const localZone = () => {
|
||
try {
|
||
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
|
||
} catch {
|
||
return 'UTC'
|
||
}
|
||
}
|
||
|
||
/** Every zone the browser knows, for the datalist; an older browser gets none and a free field. */
|
||
const ZONES = (() => {
|
||
try {
|
||
return typeof Intl.supportedValuesOf === 'function' ? Intl.supportedValuesOf('timeZone') : []
|
||
} catch {
|
||
return []
|
||
}
|
||
})()
|
||
|
||
/** An ISO instant as the value a `datetime-local` input takes, in the browser's clock. */
|
||
/**
|
||
* Why the server's RunicNPC will not do (runicnpc stage 9, D310), or null when it
|
||
* does. RunicNPC is required, so a server without it is shown as incomplete.
|
||
*/
|
||
export function runicNpcNote(npc) {
|
||
if (!npc || npc.ready) return null
|
||
return `Incomplete: ${npc.absence || 'RunicNPC is not loaded on it'}. RunicNPC is required — without it every Place NPCs step is refused, Rust’s own scientists included. Runic Gateway’s installer and egg put it in place.`
|
||
}
|
||
|
||
/**
|
||
* What the missing ZoneManager helper costs this server (PLAN_FIXES F12, D182), or
|
||
* null when there is nothing to say — the helper patched, or a plugin that
|
||
* reported no ZoneManager state at all. Scoring is never what is lost: the bridge
|
||
* falls back to the zone's shape. ZoneManager's own flags are.
|
||
*/
|
||
export function zoneHelperNote(helper) {
|
||
if (!helper || helper.state === 'patched' || helper.state === 'no-zonemanager') return null
|
||
const cost =
|
||
'Event zones still score everybody inside, but ZoneManager’s own flags and messages miss a player who was already standing in a zone when it opened or came back after a restart.'
|
||
if (helper.state === 'missing') {
|
||
return `The ZoneManager helper (RunicGatewayZones.cs) is not installed. ${cost} Reinstall or update the plugin to put it back.`
|
||
}
|
||
return `The ZoneManager helper could not patch this ZoneManager${helper.reason ? ` (${helper.reason})` : ''}. ${cost}`
|
||
}
|
||
|
||
function toLocalInput(iso) {
|
||
if (!iso) return ''
|
||
const d = new Date(iso)
|
||
if (Number.isNaN(d.getTime())) return ''
|
||
const pad = (n) => String(n).padStart(2, '0')
|
||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||
}
|
||
|
||
function blankForm() {
|
||
return {
|
||
isNew: true,
|
||
id: '',
|
||
name: '',
|
||
sidecarBaseUrl: '',
|
||
sidecarToken: '',
|
||
enabled: true,
|
||
sortOrder: 0,
|
||
protocol: undefined,
|
||
wipeRule: 'none',
|
||
wipeDay: 4,
|
||
wipeTime: '19:00',
|
||
wipeTz: localZone(),
|
||
wipeAnchor: '',
|
||
wipeOnce: '',
|
||
creditRule: 'blow',
|
||
}
|
||
}
|
||
|
||
function formFrom(server) {
|
||
const s = server.schedule || { rule: 'none' }
|
||
return {
|
||
isNew: false,
|
||
id: server.id,
|
||
name: server.name,
|
||
sidecarBaseUrl: server.sidecarBaseUrl,
|
||
sidecarToken: '',
|
||
enabled: server.enabled,
|
||
sortOrder: server.sortOrder,
|
||
protocol: server.protocol,
|
||
wipeRule: s.rule || 'none',
|
||
wipeDay: s.day == null ? 4 : s.day,
|
||
wipeTime: s.time || '19:00',
|
||
wipeTz: s.tz || localZone(),
|
||
wipeAnchor: s.anchor || '',
|
||
wipeOnce: toLocalInput(s.onceAt),
|
||
storedOnceAt: s.onceAt || null,
|
||
creditRule: server.creditRule || 'blow',
|
||
}
|
||
}
|
||
|
||
/** The PUT body. The token only when one was typed; the schedule always. */
|
||
function bodyFrom(form) {
|
||
const weekly = form.wipeRule === 'weekly' || form.wipeRule === 'biweekly'
|
||
const onceUnchanged = form.storedOnceAt && form.wipeOnce === toLocalInput(form.storedOnceAt)
|
||
const body = {
|
||
name: form.name.trim(),
|
||
sidecarBaseUrl: form.sidecarBaseUrl.trim(),
|
||
enabled: form.enabled,
|
||
sortOrder: Number(form.sortOrder) || 0,
|
||
wipeRule: form.wipeRule,
|
||
wipeDay: weekly ? Number(form.wipeDay) : null,
|
||
wipeTime: weekly ? form.wipeTime : null,
|
||
wipeTz: weekly ? form.wipeTz.trim() : null,
|
||
wipeAnchor: form.wipeRule === 'biweekly' ? form.wipeAnchor : null,
|
||
// A past one-off date that was not touched is dropped rather than sent back:
|
||
// the server refuses a past date on save (it says nothing about the next
|
||
// wipe), and an operator editing the name must not be stopped by it.
|
||
wipeOnceAt: !form.wipeOnce || (onceUnchanged && Date.parse(form.storedOnceAt) <= Date.now())
|
||
? null
|
||
: new Date(form.wipeOnce).toISOString(),
|
||
}
|
||
if (form.creditRule) body.creditRule = form.creditRule
|
||
if (form.sidecarToken) body.sidecarToken = form.sidecarToken
|
||
if (form.protocol !== undefined) body.protocol = form.protocol
|
||
return body
|
||
}
|
||
|
||
export default function ServerSettings() {
|
||
const [reloads, setReloads] = useState(0)
|
||
const { data, error: loadError } = useAsync(() => api.admin.listServers(), [reloads])
|
||
const [form, setForm] = useState(null)
|
||
const [busy, setBusy] = useState(false)
|
||
const [error, setError] = useState('')
|
||
const [notes, setNotes] = useState({})
|
||
const [confirming, setConfirming] = useState(null)
|
||
const [titling, setTitling] = useState(null)
|
||
|
||
if (loadError) return <ErrorState error={loadError} />
|
||
if (!data) return <Loading />
|
||
const servers = data.servers || []
|
||
const categories = data.titleCategories || []
|
||
|
||
const set = (key) => (e) => {
|
||
const value = e && e.target ? (e.target.type === 'checkbox' ? e.target.checked : e.target.value) : e
|
||
setForm((f) => ({ ...f, [key]: value }))
|
||
}
|
||
|
||
const save = async (e) => {
|
||
e.preventDefault()
|
||
setBusy(true)
|
||
setError('')
|
||
try {
|
||
await api.admin.saveServer(form.id.trim(), bodyFrom(form))
|
||
setForm(null)
|
||
setReloads((n) => n + 1)
|
||
} catch (err) {
|
||
setError(err.message || 'That did not save.')
|
||
} finally {
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
const test = async (server) => {
|
||
setNotes((n) => ({ ...n, [server.id]: 'Asking the sidecar…' }))
|
||
try {
|
||
const r = await api.admin.testServer(server.id)
|
||
const sidecar = r.sidecar || {}
|
||
const text = r.ok
|
||
? `The sidecar answered${sidecar.protocol ? ` (protocol ${sidecar.protocol})` : ''}${sidecar.plugin_connected === false ? ', but the game’s plugin is not connected to it' : ''}.`
|
||
: `The sidecar did not answer: ${r.status}.`
|
||
setNotes((n) => ({ ...n, [server.id]: text }))
|
||
} catch (err) {
|
||
setNotes((n) => ({ ...n, [server.id]: err.message || 'The test did not run.' }))
|
||
}
|
||
}
|
||
|
||
const mods = async (server) => {
|
||
setNotes((n) => ({ ...n, [server.id]: 'Asking the game…' }))
|
||
try {
|
||
const line = integrationsLine(await api.admin.integrations(server.id))
|
||
setNotes((n) => ({ ...n, [server.id]: line }))
|
||
} catch (err) {
|
||
setNotes((n) => ({ ...n, [server.id]: err.message || 'The game could not be asked.' }))
|
||
}
|
||
}
|
||
|
||
const remove = async (server) => {
|
||
setConfirming(null)
|
||
try {
|
||
await api.admin.deleteServer(server.id)
|
||
setReloads((n) => n + 1)
|
||
} catch (err) {
|
||
setNotes((n) => ({ ...n, [server.id]: err.message || 'That did not delete.' }))
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div style={{ maxWidth: 900 }}>
|
||
<p className="sans dim" style={{ fontSize: '0.82rem', marginTop: 0 }}>
|
||
Each Rust server this site follows, the sidecar it answers through, and when it wipes. A server’s sidecar is
|
||
installed on its own game host; this page tells the site where to find it.
|
||
</p>
|
||
|
||
<section className="panel" style={{ padding: '16px 18px', marginBottom: 18 }}>
|
||
{servers.length === 0 && (
|
||
<p className="sans dim" style={{ fontSize: '0.82rem', margin: 0 }}>No servers are configured yet.</p>
|
||
)}
|
||
{servers.map((s) => (
|
||
<div key={s.id} className="sans" style={{ borderTop: '1px solid var(--line-soft)', padding: '10px 0', fontSize: '0.84rem' }}>
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'baseline', gap: 10 }}>
|
||
<strong style={{ color: 'var(--head)' }}>{s.name}</strong>
|
||
<code className="dim" style={{ fontSize: '0.74rem' }}>{s.id}</code>
|
||
{!s.enabled && <span className="dim" style={{ fontSize: '0.74rem' }}>disabled</span>}
|
||
<span style={{ marginLeft: 'auto', color: s.online ? 'var(--mode-live, #5fb98a)' : 'var(--dim)' }}>
|
||
{s.online ? `online · ${s.players}${s.maxPlayers ? `/${s.maxPlayers}` : ''}` : s.reachable ? 'sidecar up, game offline' : 'unreachable'}
|
||
</span>
|
||
</div>
|
||
<div className="dim" style={{ fontSize: '0.76rem', marginTop: 4 }}>
|
||
{s.sidecarBaseUrl} · {s.hasToken ? 'token stored' : 'no token'} · protocol {s.protocol}
|
||
{s.sidecarProtocol != null && s.sidecarProtocol !== s.protocol ? ` (sidecar speaks ${s.sidecarProtocol})` : ''}
|
||
{s.lastSeenAt ? ` · last seen ${ago(s.lastSeenAt)}` : ''}
|
||
</div>
|
||
<div className="dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
|
||
{s.nextWipe
|
||
? `Next wipe ${nextWipe(s.nextWipe)}, from ${SOURCE[s.nextWipe.source] || s.nextWipe.source}.`
|
||
: 'No wipe schedule set.'}
|
||
</div>
|
||
<div className="dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>{titlesSummary(s.titles, s.titlePush, categories)}</div>
|
||
{runicNpcNote(s.runicNpc) && (
|
||
<div style={{ fontSize: '0.76rem', marginTop: 2, color: 'var(--warn, #d9c184)' }}>{runicNpcNote(s.runicNpc)}</div>
|
||
)}
|
||
{zoneHelperNote(s.zoneHelper) && (
|
||
<div style={{ fontSize: '0.76rem', marginTop: 2, color: 'var(--warn, #d9c184)' }}>{zoneHelperNote(s.zoneHelper)}</div>
|
||
)}
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8 }}>
|
||
<button type="button" className="btn" onClick={() => { setError(''); setTitling(null); setForm(formFrom(s)) }}>Edit</button>
|
||
<button type="button" className="btn" onClick={() => test(s)}>Test the sidecar</button>
|
||
<button type="button" className="btn" onClick={() => { setForm(null); setTitling(s) }}>Chat titles</button>
|
||
<button type="button" className="btn" onClick={() => mods(s)}>Optional mods</button>
|
||
{confirming === s.id ? (
|
||
<>
|
||
<button type="button" className="btn" onClick={() => remove(s)}>Delete {s.name} and everything recorded about it</button>
|
||
<button type="button" className="btn" onClick={() => setConfirming(null)}>Keep it</button>
|
||
</>
|
||
) : (
|
||
<button type="button" className="btn" onClick={() => setConfirming(s.id)}>Delete</button>
|
||
)}
|
||
</div>
|
||
{notes[s.id] && <p className="dim" style={{ fontSize: '0.76rem', margin: '6px 0 0' }}>{notes[s.id]}</p>}
|
||
</div>
|
||
))}
|
||
{!form && (
|
||
<div style={{ marginTop: 12 }}>
|
||
<button type="button" className="btn" onClick={() => { setError(''); setForm(blankForm()) }}>Add a server</button>
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
{form && (
|
||
<ServerForm form={form} set={set} busy={busy} error={error} onSave={save} onCancel={() => setForm(null)} />
|
||
)}
|
||
|
||
{titling && (
|
||
<TitlesForm
|
||
key={titling.id}
|
||
server={titling}
|
||
categories={categories}
|
||
onSaved={() => { setTitling(null); setReloads((n) => n + 1) }}
|
||
onCancel={() => setTitling(null)}
|
||
/>
|
||
)}
|
||
|
||
<CategoryTitles categories={categories} onSaved={() => setReloads((n) => n + 1)} />
|
||
|
||
<VoiceCard />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function Field({ label, hint, children }) {
|
||
return (
|
||
<label className="sans" style={{ display: 'grid', gap: 4, fontSize: '0.84rem' }}>
|
||
<span style={{ color: 'var(--head)' }}>{label}</span>
|
||
{children}
|
||
{hint && <span className="dim" style={{ fontSize: '0.74rem' }}>{hint}</span>}
|
||
</label>
|
||
)
|
||
}
|
||
|
||
function ServerForm({ form, set, busy, error, onSave, onCancel }) {
|
||
const weekly = form.wipeRule === 'weekly' || form.wipeRule === 'biweekly'
|
||
const rule = RULES.find((r) => r.id === form.wipeRule) || RULES[0]
|
||
const oncePast = form.storedOnceAt && form.wipeOnce === toLocalInput(form.storedOnceAt) && Date.parse(form.storedOnceAt) <= Date.now()
|
||
|
||
return (
|
||
<form onSubmit={onSave} className="panel" style={{ padding: '16px 18px', display: 'grid', gap: 14 }}>
|
||
<h2 className="display" style={{ fontSize: '1.05rem', margin: 0, color: 'var(--head)' }}>
|
||
{form.isNew ? 'Add a server' : `Edit ${form.name}`}
|
||
</h2>
|
||
|
||
{form.isNew && (
|
||
<Field label="Id" hint="Lowercase letters, digits and hyphens. It is in every link to this server and cannot be changed later.">
|
||
<input value={form.id} onChange={set('id')} required pattern="[a-z0-9][a-z0-9-]{0,63}" style={inputStyle} />
|
||
</Field>
|
||
)}
|
||
<Field label="Name">
|
||
<input value={form.name} onChange={set('name')} required maxLength={120} style={inputStyle} />
|
||
</Field>
|
||
<Field label="Sidecar address" hint="The base URL of the sidecar on the game host, e.g. http://10.0.0.5:8090.">
|
||
<input value={form.sidecarBaseUrl} onChange={set('sidecarBaseUrl')} required style={inputStyle} />
|
||
</Field>
|
||
<Field
|
||
label="Sidecar token"
|
||
hint={form.isNew ? 'From the sidecar’s own config. Needed to add a server.' : 'Leave blank to keep the stored token. It is never shown again once saved.'}
|
||
>
|
||
<input
|
||
type="password"
|
||
autoComplete="new-password"
|
||
value={form.sidecarToken}
|
||
onChange={set('sidecarToken')}
|
||
required={form.isNew}
|
||
maxLength={512}
|
||
style={inputStyle}
|
||
/>
|
||
</Field>
|
||
<div style={{ display: 'flex', gap: 18, flexWrap: 'wrap' }}>
|
||
<label className="sans" style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: '0.84rem' }}>
|
||
<input type="checkbox" checked={form.enabled} onChange={set('enabled')} /> Enabled
|
||
</label>
|
||
<Field label="Order">
|
||
<input type="number" value={form.sortOrder} onChange={set('sortOrder')} min={-1000} max={1000} style={{ ...inputStyle, width: 90 }} />
|
||
</Field>
|
||
</div>
|
||
|
||
<fieldset style={{ border: '1px solid var(--line-soft)', borderRadius: 8, padding: '12px 14px', display: 'grid', gap: 12 }}>
|
||
<legend className="sans" style={{ color: 'var(--head)', fontSize: '0.86rem', padding: '0 6px' }}>Wipe schedule</legend>
|
||
<Field label="Rule" hint={rule.hint}>
|
||
<select value={form.wipeRule} onChange={set('wipeRule')} style={inputStyle}>
|
||
{RULES.map((r) => <option key={r.id} value={r.id}>{r.label}</option>)}
|
||
</select>
|
||
</Field>
|
||
{weekly && (
|
||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||
<Field label="Day">
|
||
<select value={form.wipeDay} onChange={set('wipeDay')} style={inputStyle}>
|
||
{DAYS.map((d, i) => <option key={d} value={i}>{d}</option>)}
|
||
</select>
|
||
</Field>
|
||
<Field label="Time">
|
||
<input type="time" value={form.wipeTime} onChange={set('wipeTime')} required style={inputStyle} />
|
||
</Field>
|
||
<Field label="Time zone" hint="The zone the time is in. It follows that zone’s summer time.">
|
||
<input value={form.wipeTz} onChange={set('wipeTz')} list="rust-zones" required style={inputStyle} />
|
||
</Field>
|
||
</div>
|
||
)}
|
||
{form.wipeRule === 'biweekly' && (
|
||
<Field label="One wipe on this schedule" hint={`A ${DAYS[form.wipeDay]} the server wiped or will wipe on. It says which weeks are wipe weeks.`}>
|
||
<input type="date" value={form.wipeAnchor} onChange={set('wipeAnchor')} required style={inputStyle} />
|
||
</Field>
|
||
)}
|
||
<Field
|
||
label="One-off wipe (optional)"
|
||
hint="A delayed or extra wipe, in your own clock. While it is in the future it is the next wipe, and anything scheduled before it is skipped."
|
||
>
|
||
<span style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||
<input type="datetime-local" value={form.wipeOnce} onChange={set('wipeOnce')} style={inputStyle} />
|
||
{form.wipeOnce && <button type="button" className="btn" onClick={() => set('wipeOnce')('')}>Clear</button>}
|
||
</span>
|
||
</Field>
|
||
{oncePast && (
|
||
<p className="sans dim" style={{ fontSize: '0.76rem', margin: 0 }}>
|
||
That date has passed, so it no longer changes anything. Saving will clear it.
|
||
</p>
|
||
)}
|
||
</fieldset>
|
||
<fieldset style={{ border: '1px solid var(--line-soft)', borderRadius: 8, padding: '12px 14px', display: 'grid', gap: 12 }}>
|
||
<legend className="sans" style={{ color: 'var(--head)', fontSize: '0.86rem', padding: '0 6px' }}>World events</legend>
|
||
<Field
|
||
label="Who downs a helicopter or a Bradley"
|
||
hint={`${(CREDIT_RULES.find((r) => r.id === form.creditRule) || CREDIT_RULES[0]).hint} It names them in the feed and counts toward Rotorbane and Tankbreaker, from now on.`}
|
||
>
|
||
<select value={form.creditRule} onChange={set('creditRule')} style={inputStyle}>
|
||
{CREDIT_RULES.map((r) => <option key={r.id} value={r.id}>{r.label}</option>)}
|
||
</select>
|
||
</Field>
|
||
</fieldset>
|
||
<datalist id="rust-zones">{ZONES.map((z) => <option key={z} value={z} />)}</datalist>
|
||
|
||
<div className="sans" style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||
<button type="submit" className="btn" disabled={busy}>{busy ? 'Saving…' : 'Save'}</button>
|
||
<button type="button" className="btn" onClick={onCancel} disabled={busy}>Cancel</button>
|
||
{error && <span style={{ color: '#d08a2a', fontSize: '0.8rem' }}>{error}</span>}
|
||
</div>
|
||
</form>
|
||
)
|
||
}
|
||
|
||
const inputStyle = {
|
||
background: 'var(--panel-flat, transparent)',
|
||
color: 'var(--text)',
|
||
border: '1px solid var(--line)',
|
||
borderRadius: 'var(--radius-input, 6px)',
|
||
padding: '5px 8px',
|
||
fontSize: '0.84rem',
|
||
}
|