feat(rcon): unify terminal workspace
This commit is contained in:
@@ -1,14 +1,7 @@
|
||||
import { rconServers } from "@minecraft-account-manager/database";
|
||||
import { asc } from "drizzle-orm";
|
||||
import { RconConsole } from "@/components/rcon-console";
|
||||
import { RconConsole, type RconTerminalNotice } from "@/components/rcon-console";
|
||||
import { db } from "@/lib/database";
|
||||
import {
|
||||
createRconServer,
|
||||
deleteRconServer,
|
||||
setRconServerEnabled,
|
||||
testSavedRconServer,
|
||||
updateRconServer,
|
||||
} from "./actions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -46,13 +39,17 @@ export default async function RconPage({
|
||||
const query = await searchParams;
|
||||
const saved = queryValue(query.saved);
|
||||
const error = queryValue(query.error);
|
||||
const notice: RconTerminalNotice | undefined = error
|
||||
? { status: "error", message: errorMessages[error] ?? "The RCON operation failed." }
|
||||
: saved
|
||||
? { status: "success", message: savedMessages[saved] ?? "RCON settings saved." }
|
||||
: undefined;
|
||||
const servers = await db.select({
|
||||
id: rconServers.id,
|
||||
name: rconServers.name,
|
||||
host: rconServers.host,
|
||||
port: rconServers.port,
|
||||
enabled: rconServers.enabled,
|
||||
updatedAt: rconServers.updatedAt,
|
||||
}).from(rconServers).orderBy(asc(rconServers.name));
|
||||
|
||||
return (
|
||||
@@ -60,88 +57,19 @@ export default async function RconPage({
|
||||
<header className="border-b border-line pb-8">
|
||||
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Server operations</p>
|
||||
<h1 className="mt-4 font-display text-5xl font-black uppercase sm:text-7xl">RCON</h1>
|
||||
<p className="mt-5 max-w-2xl text-sm leading-6 text-muted">Run commands through the portal backend to internal or external server addresses. Credentials are never sent to the browser.</p>
|
||||
<p className="mt-5 max-w-2xl text-sm leading-6 text-muted">Select and manage a connection, then run commands through the portal backend. Credentials are never sent to the browser.</p>
|
||||
</header>
|
||||
|
||||
{saved && <p className="mt-7 border-l-2 border-signal bg-panel px-5 py-4 font-mono text-xs font-bold uppercase tracking-wider" role="status">{savedMessages[saved] ?? "RCON settings saved."}</p>}
|
||||
{error && <p className="mt-7 border-l-2 border-accent bg-panel px-5 py-4 text-sm text-accent" role="alert">{errorMessages[error] ?? "The RCON operation failed."}</p>}
|
||||
|
||||
<div className="mt-10 grid gap-10 lg:grid-cols-[1.1fr_0.9fr]">
|
||||
<section className="border border-line bg-panel p-6 shadow-[6px_6px_0_var(--color-shadow)]">
|
||||
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Command proxy</p>
|
||||
<h2 className="mt-2 font-display text-3xl font-black uppercase">Console</h2>
|
||||
<p className="mt-3 text-xs leading-5 text-muted">Only the latest bounded response is shown. Commands and responses are not saved as console history.</p>
|
||||
<RconConsole servers={servers.filter((server) => server.enabled).map(({ id, name }) => ({ id, name }))} />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Configuration</p>
|
||||
<h2 className="mt-2 font-display text-3xl font-black uppercase">Add connection</h2>
|
||||
<form action={createRconServer} className="mt-5 space-y-4 border border-line bg-panel p-6">
|
||||
<ConnectionFields prefix="new" />
|
||||
<label className="flex items-center gap-3 font-mono text-[10px] font-bold uppercase"><input className="size-4" name="enabled" type="checkbox" value="yes" />Enable immediately</label>
|
||||
<button className="border border-ink bg-ink px-5 py-3 font-mono text-[10px] font-bold uppercase tracking-wider text-canvas" type="submit">Add connection</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="mt-12">
|
||||
<div className="flex items-end justify-between border-b border-line pb-4">
|
||||
<div><p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Saved endpoints</p><h2 className="mt-2 font-display text-3xl font-black uppercase">Connections</h2></div>
|
||||
<span className="font-mono text-xs text-muted">{servers.length} configured</span>
|
||||
</div>
|
||||
<div className="divide-y divide-line">
|
||||
{servers.map((server) => (
|
||||
<article className="grid gap-5 py-6 lg:grid-cols-[1fr_auto] lg:items-start" key={server.id}>
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-3"><h3 className="font-mono text-lg font-bold">{server.name}</h3><span className={`px-2 py-1 font-mono text-[9px] font-bold uppercase ${server.enabled ? "bg-signal text-ink" : "border border-line text-muted"}`}>{server.enabled ? "Enabled" : "Disabled"}</span></div>
|
||||
<p className="mt-2 font-mono text-[10px] text-muted">{server.host}:{server.port}</p>
|
||||
<p className="mt-1 font-mono text-[9px] text-muted">Updated {server.updatedAt.toISOString()}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-start gap-4">
|
||||
<form action={testSavedRconServer}><input name="serverId" type="hidden" value={server.id} /><button className="font-mono text-[9px] font-bold uppercase underline underline-offset-4" type="submit">Test</button></form>
|
||||
<form action={setRconServerEnabled}><input name="serverId" type="hidden" value={server.id} /><input name="enabled" type="hidden" value={server.enabled ? "no" : "yes"} /><button className="font-mono text-[9px] font-bold uppercase underline underline-offset-4" type="submit">{server.enabled ? "Disable" : "Enable"}</button></form>
|
||||
<details className="relative">
|
||||
<summary className="cursor-pointer list-none font-mono text-[9px] font-bold uppercase underline underline-offset-4">Edit</summary>
|
||||
<form action={updateRconServer} className="relative z-10 mt-3 w-[min(28rem,80vw)] space-y-4 border border-line bg-panel p-5 shadow-[5px_5px_0_var(--color-shadow)] lg:absolute lg:right-0">
|
||||
<input name="serverId" type="hidden" value={server.id} />
|
||||
<ConnectionFields defaults={server} prefix={server.id} />
|
||||
<label className="flex items-center gap-3 font-mono text-[10px] font-bold uppercase"><input className="size-4" defaultChecked={server.enabled} name="enabled" type="checkbox" value="yes" />Enabled</label>
|
||||
<button className="border border-ink px-4 py-2 font-mono text-[9px] font-bold uppercase" type="submit">Save connection</button>
|
||||
</form>
|
||||
</details>
|
||||
<details className="relative">
|
||||
<summary className="cursor-pointer list-none font-mono text-[9px] font-bold uppercase text-accent underline underline-offset-4">Delete</summary>
|
||||
<form action={deleteRconServer} className="relative z-10 mt-3 w-64 border border-accent bg-panel p-5 shadow-[5px_5px_0_var(--color-accent)] lg:absolute lg:right-0">
|
||||
<input name="serverId" type="hidden" value={server.id} /><input name="confirmation" type="hidden" value={server.id} />
|
||||
<p className="text-xs leading-5">Delete {server.name}? Its encrypted credential will be removed.</p>
|
||||
<button className="mt-4 bg-accent px-4 py-2 font-mono text-[9px] font-bold uppercase text-canvas" type="submit">Confirm deletion</button>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
{!servers.length && <p className="py-8 text-sm text-muted">No RCON connections configured.</p>}
|
||||
<section className="mt-10">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Command proxy</p>
|
||||
<h2 className="mt-2 font-display text-3xl font-black uppercase">Terminal</h2>
|
||||
</div>
|
||||
<p className="max-w-xl text-xs leading-5 text-muted">Only the latest bounded response is shown. Commands and responses are not saved as console history.</p>
|
||||
</div>
|
||||
<RconConsole notice={notice} servers={servers} />
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectionFields({
|
||||
prefix,
|
||||
defaults,
|
||||
}: {
|
||||
prefix: string;
|
||||
defaults?: { name: string; host: string; port: number };
|
||||
}) {
|
||||
const fieldClass = "mt-2 block w-full border border-line bg-canvas px-4 py-3 font-mono text-sm outline-none focus:border-accent";
|
||||
return (
|
||||
<>
|
||||
<label className="block font-mono text-[10px] font-bold uppercase" htmlFor={`${prefix}-rcon-name`}>Name<input className={fieldClass} defaultValue={defaults?.name} id={`${prefix}-rcon-name`} maxLength={100} name="name" required /></label>
|
||||
<label className="block font-mono text-[10px] font-bold uppercase" htmlFor={`${prefix}-rcon-host`}>Server address<input autoCapitalize="none" autoCorrect="off" className={fieldClass} defaultValue={defaults?.host} id={`${prefix}-rcon-host`} maxLength={253} name="host" placeholder="minecraft.example.com" required spellCheck={false} /></label>
|
||||
<label className="block font-mono text-[10px] font-bold uppercase" htmlFor={`${prefix}-rcon-port`}>Port<input className={fieldClass} defaultValue={defaults?.port ?? 25575} id={`${prefix}-rcon-port`} max={65535} min={1} name="port" required type="number" /></label>
|
||||
<label className="block font-mono text-[10px] font-bold uppercase" htmlFor={`${prefix}-rcon-password`}>{defaults ? "Replacement password" : "Password"}<input autoComplete="new-password" className={fieldClass} id={`${prefix}-rcon-password`} maxLength={512} name="password" required={!defaults} type="password" />{defaults && <span className="mt-2 block font-sans text-[10px] font-normal normal-case text-muted">Leave blank to preserve the current password.</span>}</label>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,25 +2,58 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/app/admin/(console)/rcon/actions", () => ({
|
||||
createRconServer: vi.fn(),
|
||||
deleteRconServer: vi.fn(),
|
||||
executeRconCommand: vi.fn(),
|
||||
setRconServerEnabled: vi.fn(),
|
||||
testSavedRconServer: vi.fn(),
|
||||
updateRconServer: vi.fn(),
|
||||
}));
|
||||
|
||||
import { RconConsole } from "./rcon-console";
|
||||
|
||||
const server = {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
name: "Season 4",
|
||||
host: "season4.somc.svc.cluster.local",
|
||||
port: 25575,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
describe("RconConsole", () => {
|
||||
it("renders a labelled keyboard-operable terminal without persisted history", () => {
|
||||
const markup = renderToStaticMarkup(<RconConsole servers={[{ id: "one", name: "Season 4" }]} />);
|
||||
it("renders one wide terminal workspace with connection controls and modal forms", () => {
|
||||
const markup = renderToStaticMarkup(<RconConsole servers={[server]} />);
|
||||
expect(markup).toContain('aria-label="RCON terminal"');
|
||||
expect(markup).toContain('for="rcon-console-server"');
|
||||
expect(markup).toContain('for="rcon-command"');
|
||||
expect(markup).toContain("w-full");
|
||||
expect(markup).toContain("Season 4");
|
||||
expect(markup).toContain("server://");
|
||||
expect(markup).toContain("Awaiting command");
|
||||
expect(markup).toContain("Add");
|
||||
expect(markup).toContain("Edit");
|
||||
expect(markup).toContain("Test");
|
||||
expect(markup).toContain("Disable");
|
||||
expect(markup).toContain("Delete");
|
||||
expect(markup).toContain("Add RCON connection");
|
||||
expect(markup).toContain("Edit Season 4");
|
||||
expect(markup).toContain("Delete Season 4?");
|
||||
expect(markup).toContain("Enter ↵");
|
||||
expect(markup).not.toContain("Latest response");
|
||||
});
|
||||
|
||||
it("explains when no enabled connection is available", () => {
|
||||
expect(renderToStaticMarkup(<RconConsole servers={[]} />)).toContain("Enable an RCON connection");
|
||||
it("renders connection operation notices inside the terminal viewport", () => {
|
||||
const markup = renderToStaticMarkup(<RconConsole notice={{ status: "error", message: "RCON authentication timed out." }} servers={[server]} />);
|
||||
expect(markup).toContain("RCON authentication timed out.");
|
||||
expect(markup).toContain('role="alert"');
|
||||
});
|
||||
|
||||
it("keeps the terminal and add action available when no connection exists", () => {
|
||||
const markup = renderToStaticMarkup(<RconConsole servers={[]} />);
|
||||
expect(markup).toContain('aria-label="RCON terminal"');
|
||||
expect(markup).toContain("No connections configured");
|
||||
expect(markup).toContain("Add");
|
||||
expect(markup).not.toContain("Edit");
|
||||
expect(markup).not.toContain("Delete");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,56 +1,202 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { executeRconCommand, type RconCommandState } from "@/app/admin/(console)/rcon/actions";
|
||||
import { useActionState, useState } from "react";
|
||||
import {
|
||||
createRconServer,
|
||||
deleteRconServer,
|
||||
executeRconCommand,
|
||||
setRconServerEnabled,
|
||||
testSavedRconServer,
|
||||
type RconCommandState,
|
||||
updateRconServer,
|
||||
} from "@/app/admin/(console)/rcon/actions";
|
||||
import { AdminModalForm } from "@/components/admin-modal-form";
|
||||
|
||||
const initialState: RconCommandState = { status: "idle", message: "", serverId: "" };
|
||||
|
||||
type ServerOption = { id: string; name: string };
|
||||
export type RconServerOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export function RconConsole({ servers }: { servers: ServerOption[] }) {
|
||||
export type RconTerminalNotice = {
|
||||
status: "success" | "error";
|
||||
message: string;
|
||||
};
|
||||
|
||||
export function RconConsole({
|
||||
notice,
|
||||
servers,
|
||||
}: {
|
||||
notice?: RconTerminalNotice;
|
||||
servers: RconServerOption[];
|
||||
}) {
|
||||
const [selectedId, setSelectedId] = useState(servers[0]?.id ?? "");
|
||||
const [state, action, pending] = useActionState(executeRconCommand, initialState);
|
||||
const selected = servers.find((server) => server.id === selectedId) ?? servers[0];
|
||||
const responseServer = servers.find((server) => server.id === state.serverId);
|
||||
|
||||
if (!servers.length) {
|
||||
return <p className="mt-5 text-sm text-muted">Enable an RCON connection before opening the console.</p>;
|
||||
}
|
||||
const output = terminalOutput({ notice, pending, responseServer, selected, state });
|
||||
|
||||
return (
|
||||
<form action={action} aria-label="RCON terminal" className="mt-6 overflow-hidden border-2 border-ink bg-panel shadow-[6px_6px_0_var(--color-shadow)]">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-line bg-canvas px-4 py-3">
|
||||
<div className="flex items-center gap-2 font-mono text-[10px] font-bold uppercase tracking-wider text-muted">
|
||||
<span aria-hidden="true" className="size-2 rounded-full bg-signal shadow-[0_0_0_1px_var(--color-ink)]" />
|
||||
<span>server://</span>
|
||||
<section aria-label="RCON terminal" className="mt-8 w-full overflow-hidden border-2 border-ink bg-panel shadow-[8px_8px_0_var(--color-shadow)]">
|
||||
<div className="flex flex-col gap-4 border-b-2 border-ink bg-canvas px-4 py-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-3">
|
||||
<div className="flex items-center gap-2 font-mono text-[10px] font-bold uppercase tracking-wider text-muted">
|
||||
<span aria-hidden="true" className={`size-2 rounded-full shadow-[0_0_0_1px_var(--color-ink)] ${selected?.enabled ? "bg-signal" : "bg-line"}`} />
|
||||
<span>server://</span>
|
||||
</div>
|
||||
{servers.length ? (
|
||||
<label className="flex min-w-0 items-center gap-2 font-mono text-[9px] font-bold uppercase tracking-wider" htmlFor="rcon-console-server">
|
||||
<span className="sr-only">Server</span>
|
||||
<select
|
||||
className="max-w-full border border-line bg-panel px-3 py-2 font-mono text-xs font-bold normal-case outline-none focus:border-accent"
|
||||
id="rcon-console-server"
|
||||
onChange={(event) => setSelectedId(event.target.value)}
|
||||
value={selected?.id}
|
||||
>
|
||||
{servers.map((server) => <option key={server.id} value={server.id}>{server.name}{server.enabled ? "" : " — disabled"}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
) : (
|
||||
<span className="font-mono text-xs font-bold text-muted">no-target</span>
|
||||
)}
|
||||
{selected && <span className="font-mono text-[9px] text-muted">{selected.host}:{selected.port}</span>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<ConnectionModal mode="add" />
|
||||
{selected && (
|
||||
<>
|
||||
<form action={testSavedRconServer}>
|
||||
<input name="serverId" type="hidden" value={selected.id} />
|
||||
<HeaderButton label="Test" />
|
||||
</form>
|
||||
<form action={setRconServerEnabled}>
|
||||
<input name="serverId" type="hidden" value={selected.id} />
|
||||
<input name="enabled" type="hidden" value={selected.enabled ? "no" : "yes"} />
|
||||
<HeaderButton label={selected.enabled ? "Disable" : "Enable"} />
|
||||
</form>
|
||||
<ConnectionModal mode="edit" server={selected} />
|
||||
<AdminModalForm
|
||||
action={deleteRconServer}
|
||||
description={`Delete ${selected.name} and its encrypted credential. This cannot be undone.`}
|
||||
intent="danger"
|
||||
submitLabel="Delete connection"
|
||||
title={`Delete ${selected.name}?`}
|
||||
triggerClassName="border border-accent px-3 py-2 font-mono text-[9px] font-bold uppercase tracking-wider text-accent"
|
||||
triggerLabel="Delete"
|
||||
>
|
||||
<input name="serverId" type="hidden" value={selected.id} />
|
||||
<input name="confirmation" type="hidden" value={selected.id} />
|
||||
</AdminModalForm>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<label className="flex items-center gap-2 font-mono text-[9px] font-bold uppercase tracking-wider" htmlFor="rcon-console-server">
|
||||
Target
|
||||
<select className="border border-line bg-panel px-3 py-2 font-mono text-xs font-bold normal-case outline-none focus:border-accent" defaultValue={state.serverId || servers[0]?.id} id="rcon-console-server" name="serverId" required>
|
||||
{servers.map((server) => <option key={server.id} value={server.id}>{server.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div aria-live="polite" aria-relevant="additions text" className="min-h-52 max-h-80 overflow-auto p-5 font-mono text-xs leading-5" role={state.status === "error" ? "alert" : "status"}>
|
||||
{pending ? (
|
||||
<p className="text-muted"><span aria-hidden="true" className="mr-2 text-accent">›</span>Executing command…</p>
|
||||
) : state.status === "idle" ? (
|
||||
<p className="text-muted"><span aria-hidden="true" className="mr-2 text-accent">#</span>Awaiting command</p>
|
||||
) : (
|
||||
<>
|
||||
<p className={`text-[9px] font-bold uppercase tracking-wider ${state.status === "error" ? "text-accent" : "text-muted"}`}>
|
||||
{state.status === "error" ? "Error" : "Response"}{responseServer ? ` — ${responseServer.name}` : ""}
|
||||
</p>
|
||||
<pre className="mt-3 whitespace-pre-wrap break-words font-mono text-xs leading-5">{state.message}</pre>
|
||||
</>
|
||||
)}
|
||||
<div aria-live="polite" aria-relevant="additions text" className="min-h-72 max-h-[32rem] overflow-auto p-5 font-mono text-xs leading-5" role={output.status === "error" ? "alert" : "status"}>
|
||||
<p className={`text-[9px] font-bold uppercase tracking-wider ${output.status === "error" ? "text-accent" : "text-muted"}`}>{output.label}</p>
|
||||
<pre className="mt-3 whitespace-pre-wrap break-words font-mono text-xs leading-5">{output.message}</pre>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 border-t-2 border-ink bg-canvas p-3">
|
||||
<form action={action} className="flex items-center gap-3 border-t-2 border-ink bg-canvas p-3">
|
||||
<input name="serverId" type="hidden" value={selected?.id ?? ""} />
|
||||
<span aria-hidden="true" className="font-mono text-lg font-black text-accent">$</span>
|
||||
<label className="sr-only" htmlFor="rcon-command">Command</label>
|
||||
<input autoComplete="off" autoFocus className="min-w-0 flex-1 bg-transparent px-1 py-2 font-mono text-sm outline-none placeholder:text-muted focus-visible:outline-none" id="rcon-command" maxLength={1024} name="command" placeholder="list" required spellCheck={false} />
|
||||
<button className="border border-ink bg-ink px-4 py-2 font-mono text-[9px] font-bold uppercase tracking-wider text-canvas disabled:cursor-wait disabled:opacity-60" disabled={pending} type="submit">{pending ? "Running…" : "Enter ↵"}</button>
|
||||
<input
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
className="min-w-0 flex-1 bg-transparent px-1 py-2 font-mono text-sm outline-none placeholder:text-muted focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={!selected?.enabled || pending}
|
||||
id="rcon-command"
|
||||
key={selected?.id ?? "no-server"}
|
||||
maxLength={1024}
|
||||
name="command"
|
||||
placeholder={selected ? (selected.enabled ? "list" : "Enable this connection to run commands") : "Add a connection to begin"}
|
||||
required
|
||||
spellCheck={false}
|
||||
/>
|
||||
<button className="border border-ink bg-ink px-4 py-2 font-mono text-[9px] font-bold uppercase tracking-wider text-canvas disabled:cursor-not-allowed disabled:opacity-50" disabled={!selected?.enabled || pending} type="submit">{pending ? "Running…" : "Enter ↵"}</button>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function terminalOutput({
|
||||
notice,
|
||||
pending,
|
||||
responseServer,
|
||||
selected,
|
||||
state,
|
||||
}: {
|
||||
notice?: RconTerminalNotice;
|
||||
pending: boolean;
|
||||
responseServer?: RconServerOption;
|
||||
selected?: RconServerOption;
|
||||
state: RconCommandState;
|
||||
}) {
|
||||
if (pending) return { status: "success" as const, label: "Executing", message: "Command in progress…" };
|
||||
if (state.status !== "idle") return {
|
||||
status: state.status,
|
||||
label: `${state.status === "error" ? "Error" : "Response"}${responseServer ? ` — ${responseServer.name}` : ""}`,
|
||||
message: state.message,
|
||||
};
|
||||
if (notice) return { ...notice, label: notice.status === "error" ? "Connection error" : "Connection update" };
|
||||
if (!selected) return { status: "success" as const, label: "Ready", message: "No connections configured. Use Add to create a server connection." };
|
||||
if (!selected.enabled) return { status: "error" as const, label: `Disabled — ${selected.name}`, message: "Enable this connection before testing commands." };
|
||||
return { status: "success" as const, label: `Ready — ${selected.name}`, message: "Awaiting command" };
|
||||
}
|
||||
|
||||
function HeaderButton({ label }: { label: string }) {
|
||||
return <button className="border border-line px-3 py-2 font-mono text-[9px] font-bold uppercase tracking-wider hover:border-ink" type="submit">{label}</button>;
|
||||
}
|
||||
|
||||
function ConnectionModal({
|
||||
mode,
|
||||
server,
|
||||
}: {
|
||||
mode: "add" | "edit";
|
||||
server?: RconServerOption;
|
||||
}) {
|
||||
const editing = mode === "edit" ? server : undefined;
|
||||
return (
|
||||
<AdminModalForm
|
||||
action={editing ? updateRconServer : createRconServer}
|
||||
description={editing ? `Update ${editing.name}. Leave the password blank to preserve its encrypted credential.` : "Add an internal or external RCON server address. The password is encrypted before storage."}
|
||||
submitLabel={editing ? "Save connection" : "Add connection"}
|
||||
title={editing ? `Edit ${editing.name}` : "Add RCON connection"}
|
||||
triggerClassName={editing ? "border border-line px-3 py-2 font-mono text-[9px] font-bold uppercase tracking-wider" : "border border-ink bg-ink px-3 py-2 font-mono text-[9px] font-bold uppercase tracking-wider text-canvas"}
|
||||
triggerLabel={editing ? "Edit" : "Add"}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{editing && <input name="serverId" type="hidden" value={editing.id} />}
|
||||
<ConnectionFields defaults={editing} prefix={editing?.id ?? "new"} />
|
||||
<label className="flex items-center gap-3 font-mono text-[10px] font-bold uppercase">
|
||||
<input className="size-4" defaultChecked={editing?.enabled ?? false} name="enabled" type="checkbox" value="yes" />
|
||||
{editing ? "Enabled" : "Enable immediately"}
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
</AdminModalForm>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectionFields({
|
||||
defaults,
|
||||
prefix,
|
||||
}: {
|
||||
defaults?: { name: string; host: string; port: number };
|
||||
prefix: string;
|
||||
}) {
|
||||
const fieldClass = "mt-2 block w-full border border-line bg-canvas px-4 py-3 font-mono text-sm outline-none focus:border-accent";
|
||||
return (
|
||||
<>
|
||||
<label className="block font-mono text-[10px] font-bold uppercase" htmlFor={`${prefix}-rcon-name`}>Name<input className={fieldClass} defaultValue={defaults?.name} id={`${prefix}-rcon-name`} maxLength={100} name="name" required /></label>
|
||||
<label className="block font-mono text-[10px] font-bold uppercase" htmlFor={`${prefix}-rcon-host`}>Server address<input autoCapitalize="none" autoCorrect="off" className={fieldClass} defaultValue={defaults?.host} id={`${prefix}-rcon-host`} maxLength={253} name="host" placeholder="minecraft.example.com" required spellCheck={false} /></label>
|
||||
<label className="block font-mono text-[10px] font-bold uppercase" htmlFor={`${prefix}-rcon-port`}>Port<input className={fieldClass} defaultValue={defaults?.port ?? 25575} id={`${prefix}-rcon-port`} max={65535} min={1} name="port" required type="number" /></label>
|
||||
<label className="block font-mono text-[10px] font-bold uppercase" htmlFor={`${prefix}-rcon-password`}>{defaults ? "Replacement password" : "Password"}<input autoComplete="new-password" className={fieldClass} id={`${prefix}-rcon-password`} maxLength={512} name="password" required={!defaults} type="password" />{defaults && <span className="mt-2 block font-sans text-[10px] font-normal normal-case text-muted">Leave blank to preserve the current password.</span>}</label>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user