Phase3
This commit is contained in:
@@ -127,7 +127,10 @@
|
||||
"planInvalid": "Ungültiger Tarif ausgewählt",
|
||||
"versionRequired": "Minecraft-Version ist erforderlich",
|
||||
"softwareRequired": "Software-Familie ist erforderlich",
|
||||
"eulaRequired": "Sie müssen die Minecraft-EULA akzeptieren"
|
||||
"eulaRequired": "Sie müssen die Minecraft-EULA akzeptieren",
|
||||
"motdMax": "MOTD darf höchstens 256 Zeichen lang sein",
|
||||
"maxPlayersMin": "Mindestens 1 Spieler erforderlich",
|
||||
"maxPlayersMax": "Höchstens 200 Spieler erlaubt"
|
||||
},
|
||||
"servers": {
|
||||
"title": "Server",
|
||||
@@ -180,6 +183,97 @@
|
||||
"errors": {
|
||||
"generic": "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut.",
|
||||
"loadFailed": "Server konnten nicht geladen werden."
|
||||
},
|
||||
"tabs": {
|
||||
"navigation": "Server-Bereiche",
|
||||
"overview": "Übersicht",
|
||||
"console": "Konsole",
|
||||
"files": "Dateien",
|
||||
"properties": "Eigenschaften",
|
||||
"players": "Spieler"
|
||||
},
|
||||
"console": {
|
||||
"loading": "Wird geladen…",
|
||||
"notRunning": "Die Konsole ist nur verfügbar, wenn der Server läuft. Starten Sie den Server über die Übersicht.",
|
||||
"connecting": "Verbindung wird hergestellt…",
|
||||
"connected": "Verbunden",
|
||||
"disconnected": "Getrennt",
|
||||
"clear": "Log leeren",
|
||||
"commandPlaceholder": "Serverbefehl eingeben…",
|
||||
"send": "Senden",
|
||||
"errors": {
|
||||
"connectionFailed": "Verbindung zur Konsole fehlgeschlagen."
|
||||
}
|
||||
},
|
||||
"files": {
|
||||
"loading": "Wird geladen…",
|
||||
"browser": "Dateibrowser",
|
||||
"editor": "Editor",
|
||||
"up": "Hoch",
|
||||
"empty": "Dieses Verzeichnis ist leer.",
|
||||
"selectFile": "Wählen Sie eine Datei zum Anzeigen oder Bearbeiten.",
|
||||
"readOnlyStopped": "Schreibgeschützt, solange der Server gestoppt ist.",
|
||||
"notAvailable": "Dateien sind nur verfügbar, wenn der Server läuft oder gestoppt ist.",
|
||||
"truncated": "Datei wurde zur Anzeige gekürzt. Bearbeiten Sie mit Vorsicht.",
|
||||
"save": "Speichern",
|
||||
"saving": "Wird gespeichert…",
|
||||
"saved": "Datei gespeichert.",
|
||||
"errors": {
|
||||
"loadFailed": "Verzeichnis konnte nicht geladen werden.",
|
||||
"readFailed": "Datei konnte nicht gelesen werden.",
|
||||
"saveFailed": "Datei konnte nicht gespeichert werden."
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"title": "Server-Eigenschaften",
|
||||
"description": "Bearbeiten Sie gängige server.properties-Einstellungen. Unbekannte Eigenschaften bleiben auf dem Server erhalten.",
|
||||
"loading": "Wird geladen…",
|
||||
"motd": "MOTD",
|
||||
"gamemode": "Spielmodus",
|
||||
"difficulty": "Schwierigkeit",
|
||||
"maxPlayers": "Max. Spieler",
|
||||
"pvp": "PVP",
|
||||
"whitelist": "Whitelist",
|
||||
"hardcore": "Hardcore",
|
||||
"onlineMode": "Online-Modus",
|
||||
"enableCommandBlock": "Befehlsblöcke",
|
||||
"allowFlight": "Fliegen erlauben",
|
||||
"viewDistance": "Sichtweite",
|
||||
"simulationDistance": "Simulationsdistanz",
|
||||
"spawnProtection": "Spawn-Schutz",
|
||||
"save": "Änderungen speichern",
|
||||
"saving": "Wird gespeichert…",
|
||||
"saved": "Eigenschaften gespeichert.",
|
||||
"gamemodes": {
|
||||
"survival": "Überleben",
|
||||
"creative": "Kreativ",
|
||||
"adventure": "Abenteuer",
|
||||
"spectator": "Zuschauer"
|
||||
},
|
||||
"difficulties": {
|
||||
"peaceful": "Friedlich",
|
||||
"easy": "Einfach",
|
||||
"normal": "Normal",
|
||||
"hard": "Schwer"
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Server-Eigenschaften konnten nicht geladen werden.",
|
||||
"saveFailed": "Server-Eigenschaften konnten nicht gespeichert werden."
|
||||
}
|
||||
},
|
||||
"players": {
|
||||
"loading": "Wird geladen…",
|
||||
"onlineCount": "Spieler online",
|
||||
"whitelistCount": "Whitelist-Einträge",
|
||||
"empty": "Keine Spieler online.",
|
||||
"retry": "Erneut versuchen",
|
||||
"columns": {
|
||||
"name": "Spieler",
|
||||
"ping": "Ping"
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Spielerliste konnte nicht geladen werden."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,7 +127,10 @@
|
||||
"planInvalid": "Invalid plan selected",
|
||||
"versionRequired": "Minecraft version is required",
|
||||
"softwareRequired": "Software family is required",
|
||||
"eulaRequired": "You must accept the Minecraft EULA"
|
||||
"eulaRequired": "You must accept the Minecraft EULA",
|
||||
"motdMax": "MOTD must be at most 256 characters",
|
||||
"maxPlayersMin": "At least 1 player required",
|
||||
"maxPlayersMax": "At most 200 players allowed"
|
||||
},
|
||||
"servers": {
|
||||
"title": "Servers",
|
||||
@@ -180,6 +183,97 @@
|
||||
"errors": {
|
||||
"generic": "Something went wrong. Please try again.",
|
||||
"loadFailed": "Could not load servers."
|
||||
},
|
||||
"tabs": {
|
||||
"navigation": "Server sections",
|
||||
"overview": "Overview",
|
||||
"console": "Console",
|
||||
"files": "Files",
|
||||
"properties": "Properties",
|
||||
"players": "Players"
|
||||
},
|
||||
"console": {
|
||||
"loading": "Loading…",
|
||||
"notRunning": "The console is only available while the server is running. Start the server from the overview tab.",
|
||||
"connecting": "Connecting…",
|
||||
"connected": "Connected",
|
||||
"disconnected": "Disconnected",
|
||||
"clear": "Clear log",
|
||||
"commandPlaceholder": "Enter server command…",
|
||||
"send": "Send",
|
||||
"errors": {
|
||||
"connectionFailed": "Could not connect to the console."
|
||||
}
|
||||
},
|
||||
"files": {
|
||||
"loading": "Loading…",
|
||||
"browser": "File browser",
|
||||
"editor": "Editor",
|
||||
"up": "Up",
|
||||
"empty": "This directory is empty.",
|
||||
"selectFile": "Select a file to view or edit.",
|
||||
"readOnlyStopped": "Read-only while the server is stopped.",
|
||||
"notAvailable": "Files are only available when the server is running or stopped.",
|
||||
"truncated": "File was truncated for display. Edit with care.",
|
||||
"save": "Save",
|
||||
"saving": "Saving…",
|
||||
"saved": "File saved.",
|
||||
"errors": {
|
||||
"loadFailed": "Could not load directory.",
|
||||
"readFailed": "Could not read file.",
|
||||
"saveFailed": "Could not save file."
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"title": "Server properties",
|
||||
"description": "Edit common server.properties settings. Unknown properties are preserved on the server.",
|
||||
"loading": "Loading…",
|
||||
"motd": "MOTD",
|
||||
"gamemode": "Gamemode",
|
||||
"difficulty": "Difficulty",
|
||||
"maxPlayers": "Max players",
|
||||
"pvp": "PVP",
|
||||
"whitelist": "Whitelist",
|
||||
"hardcore": "Hardcore",
|
||||
"onlineMode": "Online mode",
|
||||
"enableCommandBlock": "Command blocks",
|
||||
"allowFlight": "Allow flight",
|
||||
"viewDistance": "View distance",
|
||||
"simulationDistance": "Simulation distance",
|
||||
"spawnProtection": "Spawn protection",
|
||||
"save": "Save changes",
|
||||
"saving": "Saving…",
|
||||
"saved": "Properties saved.",
|
||||
"gamemodes": {
|
||||
"survival": "Survival",
|
||||
"creative": "Creative",
|
||||
"adventure": "Adventure",
|
||||
"spectator": "Spectator"
|
||||
},
|
||||
"difficulties": {
|
||||
"peaceful": "Peaceful",
|
||||
"easy": "Easy",
|
||||
"normal": "Normal",
|
||||
"hard": "Hard"
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Could not load server properties.",
|
||||
"saveFailed": "Could not save server properties."
|
||||
}
|
||||
},
|
||||
"players": {
|
||||
"loading": "Loading…",
|
||||
"onlineCount": "Online players",
|
||||
"whitelistCount": "Whitelist entries",
|
||||
"empty": "No players online.",
|
||||
"retry": "Retry",
|
||||
"columns": {
|
||||
"name": "Player",
|
||||
"ping": "Ping"
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Could not load player list."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
13
apps/web/src/app/[locale]/servers/[id]/console/page.tsx
Normal file
13
apps/web/src/app/[locale]/servers/[id]/console/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { setRequestLocale } from "next-intl/server";
|
||||
import { ServerConsole } from "@/components/servers/server-console";
|
||||
|
||||
interface ServerConsolePageProps {
|
||||
params: Promise<{ locale: string; id: string }>;
|
||||
}
|
||||
|
||||
export default async function ServerConsolePage({ params }: ServerConsolePageProps) {
|
||||
const { locale, id } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
return <ServerConsole serverId={id} />;
|
||||
}
|
||||
13
apps/web/src/app/[locale]/servers/[id]/files/page.tsx
Normal file
13
apps/web/src/app/[locale]/servers/[id]/files/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { setRequestLocale } from "next-intl/server";
|
||||
import { ServerFiles } from "@/components/servers/server-files";
|
||||
|
||||
interface ServerFilesPageProps {
|
||||
params: Promise<{ locale: string; id: string }>;
|
||||
}
|
||||
|
||||
export default async function ServerFilesPage({ params }: ServerFilesPageProps) {
|
||||
const { locale, id } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
return <ServerFiles serverId={id} />;
|
||||
}
|
||||
15
apps/web/src/app/[locale]/servers/[id]/layout.tsx
Normal file
15
apps/web/src/app/[locale]/servers/[id]/layout.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { setRequestLocale } from "next-intl/server";
|
||||
import type { ReactNode } from "react";
|
||||
import { ServerDetailShell } from "@/components/servers/server-detail-shell";
|
||||
|
||||
interface ServerDetailLayoutProps {
|
||||
children: ReactNode;
|
||||
params: Promise<{ locale: string; id: string }>;
|
||||
}
|
||||
|
||||
export default async function ServerDetailLayout({ children, params }: ServerDetailLayoutProps) {
|
||||
const { locale, id } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
return <ServerDetailShell serverId={id}>{children}</ServerDetailShell>;
|
||||
}
|
||||
13
apps/web/src/app/[locale]/servers/[id]/players/page.tsx
Normal file
13
apps/web/src/app/[locale]/servers/[id]/players/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { setRequestLocale } from "next-intl/server";
|
||||
import { ServerPlayers } from "@/components/servers/server-players";
|
||||
|
||||
interface ServerPlayersPageProps {
|
||||
params: Promise<{ locale: string; id: string }>;
|
||||
}
|
||||
|
||||
export default async function ServerPlayersPage({ params }: ServerPlayersPageProps) {
|
||||
const { locale, id } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
return <ServerPlayers serverId={id} />;
|
||||
}
|
||||
13
apps/web/src/app/[locale]/servers/[id]/properties/page.tsx
Normal file
13
apps/web/src/app/[locale]/servers/[id]/properties/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { setRequestLocale } from "next-intl/server";
|
||||
import { ServerPropertiesForm } from "@/components/servers/server-properties-form";
|
||||
|
||||
interface ServerPropertiesPageProps {
|
||||
params: Promise<{ locale: string; id: string }>;
|
||||
}
|
||||
|
||||
export default async function ServerPropertiesPage({ params }: ServerPropertiesPageProps) {
|
||||
const { locale, id } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
return <ServerPropertiesForm serverId={id} />;
|
||||
}
|
||||
215
apps/web/src/components/servers/server-console.tsx
Normal file
215
apps/web/src/components/servers/server-console.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@hexahost/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ApiError } from "@/lib/api/auth";
|
||||
import {
|
||||
createConsoleWebSocket,
|
||||
getConsoleToken,
|
||||
sendConsoleCommand,
|
||||
} from "@/lib/api/console";
|
||||
import { getServer } from "@/lib/api/servers";
|
||||
import { parseAnsiLine } from "@/lib/utils/ansi";
|
||||
|
||||
const MAX_LOG_LINES = 500;
|
||||
|
||||
interface LogLine {
|
||||
id: number;
|
||||
segments: ReturnType<typeof parseAnsiLine>;
|
||||
}
|
||||
|
||||
interface ServerConsoleProps {
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
const inputClassName =
|
||||
"flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 font-mono text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100";
|
||||
|
||||
export function ServerConsole({ serverId }: ServerConsoleProps) {
|
||||
const t = useTranslations("servers.console");
|
||||
const [lines, setLines] = useState<LogLine[]>([]);
|
||||
const [command, setCommand] = useState("");
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const lineIdRef = useRef(0);
|
||||
const socketRef = useRef<WebSocket | null>(null);
|
||||
const logEndRef = useRef<HTMLDivElement>(null);
|
||||
const autoScrollRef = useRef(true);
|
||||
|
||||
const { data: server } = useQuery({
|
||||
queryKey: ["server", serverId],
|
||||
queryFn: () => getServer(serverId),
|
||||
});
|
||||
|
||||
const appendLine = useCallback((line: string) => {
|
||||
lineIdRef.current += 1;
|
||||
setLines((prev) => {
|
||||
const next = [
|
||||
...prev,
|
||||
{ id: lineIdRef.current, segments: parseAnsiLine(line) },
|
||||
];
|
||||
return next.length > MAX_LOG_LINES ? next.slice(-MAX_LOG_LINES) : next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoScrollRef.current) {
|
||||
logEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}, [lines]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!server || server.status !== "RUNNING") {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
async function connect() {
|
||||
setConnecting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const { token } = await getConsoleToken(serverId);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const socket = createConsoleWebSocket(serverId, token, {
|
||||
onOpen: () => {
|
||||
if (!cancelled) {
|
||||
setConnected(true);
|
||||
setConnecting(false);
|
||||
}
|
||||
},
|
||||
onClose: () => {
|
||||
setConnected(false);
|
||||
setConnecting(false);
|
||||
},
|
||||
onError: () => {
|
||||
if (!cancelled) {
|
||||
setError(t("errors.connectionFailed"));
|
||||
setConnecting(false);
|
||||
}
|
||||
},
|
||||
onLine: (line) => {
|
||||
appendLine(line);
|
||||
},
|
||||
});
|
||||
|
||||
socketRef.current = socket;
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(
|
||||
err instanceof ApiError ? (err.detail ?? err.title) : t("errors.connectionFailed"),
|
||||
);
|
||||
setConnecting(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void connect();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
socketRef.current?.close();
|
||||
socketRef.current = null;
|
||||
setConnected(false);
|
||||
setConnecting(false);
|
||||
};
|
||||
}, [server, serverId, appendLine, t]);
|
||||
|
||||
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const trimmed = command.trim();
|
||||
if (!trimmed || !socketRef.current || socketRef.current.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
sendConsoleCommand(socketRef.current, trimmed);
|
||||
appendLine(`> ${trimmed}`);
|
||||
setCommand("");
|
||||
}
|
||||
|
||||
if (!server) {
|
||||
return <p className="text-sm text-zinc-600 dark:text-zinc-400">{t("loading")}</p>;
|
||||
}
|
||||
|
||||
if (server.status !== "RUNNING") {
|
||||
return (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-900 dark:bg-amber-950/40">
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200">{t("notRunning")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{connecting
|
||||
? t("connecting")
|
||||
: connected
|
||||
? t("connected")
|
||||
: t("disconnected")}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => setLines([])}
|
||||
disabled={lines.length === 0}
|
||||
>
|
||||
{t("clear")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className="text-sm text-red-600 dark:text-red-400" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className="h-96 overflow-y-auto rounded-lg border border-zinc-200 bg-zinc-950 p-3 font-mono text-xs leading-relaxed dark:border-zinc-800"
|
||||
onScroll={(event) => {
|
||||
const target = event.currentTarget;
|
||||
const atBottom = target.scrollHeight - target.scrollTop - target.clientHeight < 32;
|
||||
autoScrollRef.current = atBottom;
|
||||
}}
|
||||
>
|
||||
<pre className="whitespace-pre-wrap break-words text-zinc-100">
|
||||
<code>
|
||||
{lines.map((line) => (
|
||||
<div key={line.id}>
|
||||
{line.segments.map((segment, index) => (
|
||||
<span key={index} className={segment.className}>
|
||||
{segment.text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<div ref={logEndRef} />
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={command}
|
||||
onChange={(event) => setCommand(event.target.value)}
|
||||
placeholder={t("commandPlaceholder")}
|
||||
className={inputClassName}
|
||||
disabled={!connected}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<Button type="submit" disabled={!connected || !command.trim()}>
|
||||
{t("send")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,9 +3,8 @@
|
||||
import { Button } from "@hexahost/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { getServer } from "@/lib/api/servers";
|
||||
import { ServerCard } from "@/components/servers/server-card";
|
||||
import { getServer } from "@/lib/api/servers";
|
||||
|
||||
interface ServerDetailContentProps {
|
||||
serverId: string;
|
||||
@@ -13,18 +12,9 @@ interface ServerDetailContentProps {
|
||||
|
||||
export function ServerDetailContent({ serverId }: ServerDetailContentProps) {
|
||||
const t = useTranslations("servers");
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ["server", serverId],
|
||||
queryFn: () => getServer(serverId),
|
||||
refetchInterval: (query) => {
|
||||
const status = query.state.data?.status;
|
||||
if (!status) {
|
||||
return false;
|
||||
}
|
||||
return ["PROVISIONING", "INSTALLING", "STARTING", "STOPPING"].includes(status)
|
||||
? 3000
|
||||
: false;
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
@@ -32,42 +22,8 @@ export function ServerDetailContent({ serverId }: ServerDetailContentProps) {
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<div className="space-y-3 rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-900 dark:bg-red-950/40">
|
||||
<p className="text-sm text-red-700 dark:text-red-300">{t("errors.loadFailed")}</p>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={() => void refetch()}>
|
||||
{t("retry")}
|
||||
</Button>
|
||||
<Link
|
||||
href="/servers"
|
||||
className="inline-flex h-8 items-center rounded-md border border-zinc-300 bg-white px-3 text-sm font-medium text-zinc-900 hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:bg-zinc-800"
|
||||
>
|
||||
{t("backToList")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
|
||||
{data.name}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">{t("detailSubtitle")}</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/servers"
|
||||
className="inline-flex h-8 items-center text-sm font-medium text-sky-600 hover:text-sky-500 dark:text-sky-400"
|
||||
>
|
||||
{t("backToList")}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<ServerCard server={data} showDetailsLink={false} />
|
||||
</div>
|
||||
);
|
||||
return <ServerCard server={data} showDetailsLink={false} />;
|
||||
}
|
||||
|
||||
77
apps/web/src/components/servers/server-detail-shell.tsx
Normal file
77
apps/web/src/components/servers/server-detail-shell.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@hexahost/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { ReactNode } from "react";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { getServer } from "@/lib/api/servers";
|
||||
import { ServerTabs } from "./server-tabs";
|
||||
|
||||
interface ServerDetailShellProps {
|
||||
serverId: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ServerDetailShell({ serverId, children }: ServerDetailShellProps) {
|
||||
const t = useTranslations("servers");
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ["server", serverId],
|
||||
queryFn: () => getServer(serverId),
|
||||
refetchInterval: (query) => {
|
||||
const status = query.state.data?.status;
|
||||
if (!status) {
|
||||
return false;
|
||||
}
|
||||
return ["PROVISIONING", "INSTALLING", "STARTING", "STOPPING"].includes(status)
|
||||
? 3000
|
||||
: false;
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <p className="text-sm text-zinc-600 dark:text-zinc-400">{t("loading")}</p>;
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<div className="space-y-3 rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-900 dark:bg-red-950/40">
|
||||
<p className="text-sm text-red-700 dark:text-red-300">{t("errors.loadFailed")}</p>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={() => void refetch()}>
|
||||
{t("retry")}
|
||||
</Button>
|
||||
<Link
|
||||
href="/servers"
|
||||
className="inline-flex h-8 items-center rounded-md border border-zinc-300 bg-white px-3 text-sm font-medium text-zinc-900 hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:bg-zinc-800"
|
||||
>
|
||||
{t("backToList")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
|
||||
{data.name}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">{t("detailSubtitle")}</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/servers"
|
||||
className="inline-flex h-8 items-center text-sm font-medium text-sky-600 hover:text-sky-500 dark:text-sky-400"
|
||||
>
|
||||
{t("backToList")}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<ServerTabs serverId={serverId} />
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
227
apps/web/src/components/servers/server-files.tsx
Normal file
227
apps/web/src/components/servers/server-files.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@hexahost/ui";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ApiError } from "@/lib/api/auth";
|
||||
import { listFiles, readFile, writeFile } from "@/lib/api/files";
|
||||
import { getServer } from "@/lib/api/servers";
|
||||
|
||||
interface ServerFilesProps {
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
const textareaClassName =
|
||||
"min-h-80 w-full rounded-md border border-zinc-300 bg-white px-3 py-2 font-mono text-sm text-zinc-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100";
|
||||
|
||||
export function ServerFiles({ serverId }: ServerFilesProps) {
|
||||
const t = useTranslations("servers.files");
|
||||
const queryClient = useQueryClient();
|
||||
const [currentPath, setCurrentPath] = useState("");
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||
const [editorContent, setEditorContent] = useState("");
|
||||
const [fileVersion, setFileVersion] = useState<number | undefined>();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||
|
||||
const { data: server } = useQuery({
|
||||
queryKey: ["server", serverId],
|
||||
queryFn: () => getServer(serverId),
|
||||
});
|
||||
|
||||
const canAccessFiles =
|
||||
server?.status === "RUNNING" || server?.status === "STOPPED";
|
||||
|
||||
const {
|
||||
data: listing,
|
||||
isLoading: listingLoading,
|
||||
isError: listingError,
|
||||
} = useQuery({
|
||||
queryKey: ["server-files", serverId, currentPath],
|
||||
queryFn: () => listFiles(serverId, currentPath),
|
||||
enabled: Boolean(server && canAccessFiles),
|
||||
});
|
||||
|
||||
const {
|
||||
data: fileData,
|
||||
isLoading: fileLoading,
|
||||
isError: fileError,
|
||||
} = useQuery({
|
||||
queryKey: ["server-file", serverId, selectedFile],
|
||||
queryFn: () => readFile(serverId, selectedFile!),
|
||||
enabled: Boolean(selectedFile && canAccessFiles),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (fileData) {
|
||||
setEditorContent(fileData.content);
|
||||
setFileVersion(fileData.version);
|
||||
}
|
||||
}, [fileData]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
writeFile(serverId, {
|
||||
path: selectedFile!,
|
||||
content: editorContent,
|
||||
version: fileVersion,
|
||||
}),
|
||||
onSuccess: (result) => {
|
||||
setError(null);
|
||||
setSaveSuccess(true);
|
||||
setFileVersion(result.version);
|
||||
void queryClient.invalidateQueries({ queryKey: ["server-file", serverId, selectedFile] });
|
||||
setTimeout(() => setSaveSuccess(false), 2000);
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
setSaveSuccess(false);
|
||||
setError(err instanceof ApiError ? (err.detail ?? err.title) : t("errors.saveFailed"));
|
||||
},
|
||||
});
|
||||
|
||||
function handleSelectEntry(path: string, isDir: boolean) {
|
||||
setError(null);
|
||||
setSaveSuccess(false);
|
||||
if (isDir) {
|
||||
setCurrentPath(path);
|
||||
setSelectedFile(null);
|
||||
setEditorContent("");
|
||||
return;
|
||||
}
|
||||
setSelectedFile(path);
|
||||
setEditorContent("");
|
||||
setFileVersion(undefined);
|
||||
}
|
||||
|
||||
function handleNavigateUp() {
|
||||
if (!currentPath) {
|
||||
return;
|
||||
}
|
||||
const segments = currentPath.split("/").filter(Boolean);
|
||||
segments.pop();
|
||||
setCurrentPath(segments.join("/"));
|
||||
setSelectedFile(null);
|
||||
setEditorContent("");
|
||||
setError(null);
|
||||
}
|
||||
|
||||
if (!server) {
|
||||
return <p className="text-sm text-zinc-600 dark:text-zinc-400">{t("loading")}</p>;
|
||||
}
|
||||
|
||||
if (!canAccessFiles) {
|
||||
return (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-900 dark:bg-amber-950/40">
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200">{t("notAvailable")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isReadOnly = server.status === "STOPPED";
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h2 className="text-sm font-medium text-zinc-900 dark:text-zinc-100">{t("browser")}</h2>
|
||||
{currentPath ? (
|
||||
<Button size="sm" variant="secondary" onClick={handleNavigateUp}>
|
||||
{t("up")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<p className="truncate font-mono text-xs text-zinc-500 dark:text-zinc-400">
|
||||
/{currentPath || ""}
|
||||
</p>
|
||||
|
||||
{isReadOnly ? (
|
||||
<p className="text-xs text-amber-700 dark:text-amber-300">{t("readOnlyStopped")}</p>
|
||||
) : null}
|
||||
|
||||
{listingLoading ? (
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400">{t("loading")}</p>
|
||||
) : listingError ? (
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{t("errors.loadFailed")}</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-zinc-200 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
|
||||
{(listing?.entries ?? []).length === 0 ? (
|
||||
<li className="px-3 py-2 text-sm text-zinc-500 dark:text-zinc-400">{t("empty")}</li>
|
||||
) : (
|
||||
(listing?.entries ?? []).map((entry) => (
|
||||
<li key={entry.path}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSelectEntry(entry.path, entry.isDir)}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-zinc-50 dark:hover:bg-zinc-900"
|
||||
>
|
||||
<span className="w-8 shrink-0 text-xs text-zinc-400">
|
||||
{entry.isDir ? "DIR" : "FILE"}
|
||||
</span>
|
||||
<span className="truncate text-zinc-900 dark:text-zinc-100">{entry.name}</span>
|
||||
{!entry.isDir ? (
|
||||
<span className="ml-auto text-xs text-zinc-500">{entry.size} B</span>
|
||||
) : null}
|
||||
</button>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-sm font-medium text-zinc-900 dark:text-zinc-100">{t("editor")}</h2>
|
||||
|
||||
{!selectedFile ? (
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400">{t("selectFile")}</p>
|
||||
) : fileLoading ? (
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400">{t("loading")}</p>
|
||||
) : fileError ? (
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{t("errors.readFailed")}</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<p className="truncate font-mono text-xs text-zinc-500 dark:text-zinc-400">
|
||||
{selectedFile}
|
||||
</p>
|
||||
|
||||
{fileData?.truncated ? (
|
||||
<p className="text-xs text-amber-700 dark:text-amber-300">{t("truncated")}</p>
|
||||
) : null}
|
||||
|
||||
<textarea
|
||||
className={textareaClassName}
|
||||
value={editorContent}
|
||||
onChange={(event) => {
|
||||
setEditorContent(event.target.value);
|
||||
setSaveSuccess(false);
|
||||
}}
|
||||
readOnly={isReadOnly}
|
||||
spellCheck={false}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<p className="text-sm text-red-600 dark:text-red-400" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{saveSuccess ? (
|
||||
<p className="text-sm text-green-600 dark:text-green-400">{t("saved")}</p>
|
||||
) : null}
|
||||
|
||||
{!isReadOnly ? (
|
||||
<Button
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
{saveMutation.isPending ? t("saving") : t("save")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
96
apps/web/src/components/servers/server-players.tsx
Normal file
96
apps/web/src/components/servers/server-players.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { getPlayers } from "@/lib/api/players";
|
||||
|
||||
interface ServerPlayersProps {
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
export function ServerPlayers({ serverId }: ServerPlayersProps) {
|
||||
const t = useTranslations("servers.players");
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ["server-players", serverId],
|
||||
queryFn: () => getPlayers(serverId),
|
||||
refetchInterval: 15000,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <p className="text-sm text-zinc-600 dark:text-zinc-400">{t("loading")}</p>;
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<div className="space-y-3 rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-900 dark:bg-red-950/40">
|
||||
<p className="text-sm text-red-700 dark:text-red-300">{t("errors.loadFailed")}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void refetch()}
|
||||
className="text-sm font-medium text-sky-600 hover:text-sky-500 dark:text-sky-400"
|
||||
>
|
||||
{t("retry")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-950">
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400">{t("onlineCount")}</p>
|
||||
<p className="mt-1 text-2xl font-semibold text-zinc-900 dark:text-zinc-50">
|
||||
{data.online.length}
|
||||
<span className="text-base font-normal text-zinc-500"> / {data.maxPlayers}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-950">
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400">{t("whitelistCount")}</p>
|
||||
<p className="mt-1 text-2xl font-semibold text-zinc-900 dark:text-zinc-50">
|
||||
{data.whitelistCount}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-lg border border-zinc-200 dark:border-zinc-800">
|
||||
<table className="min-w-full divide-y divide-zinc-200 text-sm dark:divide-zinc-800">
|
||||
<thead className="bg-zinc-50 dark:bg-zinc-900">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium text-zinc-600 dark:text-zinc-400">
|
||||
{t("columns.name")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-zinc-600 dark:text-zinc-400">
|
||||
{t("columns.ping")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-200 bg-white dark:divide-zinc-800 dark:bg-zinc-950">
|
||||
{data.online.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={2}
|
||||
className="px-4 py-6 text-center text-zinc-500 dark:text-zinc-400"
|
||||
>
|
||||
{t("empty")}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.online.map((player) => (
|
||||
<tr key={player.uuid ?? player.name}>
|
||||
<td className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">
|
||||
{player.name}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-zinc-600 dark:text-zinc-400">
|
||||
{player.ping !== undefined ? `${player.ping} ms` : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
318
apps/web/src/components/servers/server-properties-form.tsx
Normal file
318
apps/web/src/components/servers/server-properties-form.tsx
Normal file
@@ -0,0 +1,318 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { ApiError } from "@/lib/api/auth";
|
||||
import {
|
||||
getProperties,
|
||||
updateProperties,
|
||||
type Difficulty,
|
||||
type GameMode,
|
||||
type ServerProperties,
|
||||
} from "@/lib/api/properties";
|
||||
|
||||
interface ServerPropertiesFormProps {
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
const inputClassName =
|
||||
"flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100";
|
||||
|
||||
const labelClassName = "text-sm font-medium text-zinc-700 dark:text-zinc-300";
|
||||
|
||||
const GAME_MODES: GameMode[] = ["survival", "creative", "adventure", "spectator"];
|
||||
const DIFFICULTIES: Difficulty[] = ["peaceful", "easy", "normal", "hard"];
|
||||
|
||||
function createPropertiesSchema(messages: {
|
||||
motdMax: string;
|
||||
maxPlayersMin: string;
|
||||
maxPlayersMax: string;
|
||||
}) {
|
||||
return z.object({
|
||||
motd: z.string().max(256, messages.motdMax),
|
||||
gamemode: z.enum(["survival", "creative", "adventure", "spectator"]),
|
||||
difficulty: z.enum(["peaceful", "easy", "normal", "hard"]),
|
||||
maxPlayers: z.coerce.number().int().min(1, messages.maxPlayersMin).max(200, messages.maxPlayersMax),
|
||||
pvp: z.boolean(),
|
||||
whitelist: z.boolean(),
|
||||
hardcore: z.boolean().optional(),
|
||||
onlineMode: z.boolean().optional(),
|
||||
enableCommandBlock: z.boolean().optional(),
|
||||
viewDistance: z.coerce.number().int().min(3).max(32).optional(),
|
||||
simulationDistance: z.coerce.number().int().min(3).max(32).optional(),
|
||||
spawnProtection: z.coerce.number().int().min(0).max(64).optional(),
|
||||
allowFlight: z.boolean().optional(),
|
||||
});
|
||||
}
|
||||
|
||||
type PropertiesFormValues = z.infer<ReturnType<typeof createPropertiesSchema>>;
|
||||
|
||||
export function ServerPropertiesForm({ serverId }: ServerPropertiesFormProps) {
|
||||
const t = useTranslations("servers.properties");
|
||||
const tv = useTranslations("validation");
|
||||
const queryClient = useQueryClient();
|
||||
const [apiError, setApiError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
const schema = createPropertiesSchema({
|
||||
motdMax: tv("motdMax"),
|
||||
maxPlayersMin: tv("maxPlayersMin"),
|
||||
maxPlayersMax: tv("maxPlayersMax"),
|
||||
});
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ["server-properties", serverId],
|
||||
queryFn: () => getProperties(serverId),
|
||||
});
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isSubmitting, isDirty },
|
||||
} = useForm<PropertiesFormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
motd: "",
|
||||
gamemode: "survival",
|
||||
difficulty: "normal",
|
||||
maxPlayers: 20,
|
||||
pvp: true,
|
||||
whitelist: false,
|
||||
hardcore: false,
|
||||
onlineMode: true,
|
||||
enableCommandBlock: false,
|
||||
viewDistance: 10,
|
||||
simulationDistance: 10,
|
||||
spawnProtection: 16,
|
||||
allowFlight: false,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
reset(mapPropertiesToForm(data));
|
||||
}
|
||||
}, [data, reset]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (values: PropertiesFormValues) => updateProperties(serverId, values),
|
||||
onSuccess: (result) => {
|
||||
setApiError(null);
|
||||
setSaved(true);
|
||||
reset(mapPropertiesToForm(result));
|
||||
void queryClient.invalidateQueries({ queryKey: ["server-properties", serverId] });
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
setSaved(false);
|
||||
setApiError(error instanceof ApiError ? (error.detail ?? error.title) : t("errors.saveFailed"));
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(values: PropertiesFormValues) {
|
||||
setApiError(null);
|
||||
await saveMutation.mutateAsync(values);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <p className="text-sm text-zinc-600 dark:text-zinc-400">{t("loading")}</p>;
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return <p className="text-sm text-red-600 dark:text-red-400">{t("errors.loadFailed")}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="max-w-2xl">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("title")}</CardTitle>
|
||||
<CardDescription>{t("description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="motd" className={labelClassName}>
|
||||
{t("motd")}
|
||||
</label>
|
||||
<input id="motd" type="text" className={inputClassName} {...register("motd")} />
|
||||
{errors.motd ? (
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{errors.motd.message}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="gamemode" className={labelClassName}>
|
||||
{t("gamemode")}
|
||||
</label>
|
||||
<select id="gamemode" className={inputClassName} {...register("gamemode")}>
|
||||
{GAME_MODES.map((mode) => (
|
||||
<option key={mode} value={mode}>
|
||||
{t(`gamemodes.${mode}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="difficulty" className={labelClassName}>
|
||||
{t("difficulty")}
|
||||
</label>
|
||||
<select id="difficulty" className={inputClassName} {...register("difficulty")}>
|
||||
{DIFFICULTIES.map((level) => (
|
||||
<option key={level} value={level}>
|
||||
{t(`difficulties.${level}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="maxPlayers" className={labelClassName}>
|
||||
{t("maxPlayers")}
|
||||
</label>
|
||||
<input
|
||||
id="maxPlayers"
|
||||
type="number"
|
||||
min={1}
|
||||
max={200}
|
||||
className={inputClassName}
|
||||
{...register("maxPlayers")}
|
||||
/>
|
||||
{errors.maxPlayers ? (
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{errors.maxPlayers.message}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="flex items-center gap-2 text-sm text-zinc-700 dark:text-zinc-300">
|
||||
<input type="checkbox" className="h-4 w-4 rounded border-zinc-300" {...register("pvp")} />
|
||||
{t("pvp")}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-zinc-700 dark:text-zinc-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-zinc-300"
|
||||
{...register("whitelist")}
|
||||
/>
|
||||
{t("whitelist")}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-zinc-700 dark:text-zinc-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-zinc-300"
|
||||
{...register("hardcore")}
|
||||
/>
|
||||
{t("hardcore")}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-zinc-700 dark:text-zinc-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-zinc-300"
|
||||
{...register("onlineMode")}
|
||||
/>
|
||||
{t("onlineMode")}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-zinc-700 dark:text-zinc-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-zinc-300"
|
||||
{...register("enableCommandBlock")}
|
||||
/>
|
||||
{t("enableCommandBlock")}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-zinc-700 dark:text-zinc-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-zinc-300"
|
||||
{...register("allowFlight")}
|
||||
/>
|
||||
{t("allowFlight")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="viewDistance" className={labelClassName}>
|
||||
{t("viewDistance")}
|
||||
</label>
|
||||
<input
|
||||
id="viewDistance"
|
||||
type="number"
|
||||
min={3}
|
||||
max={32}
|
||||
className={inputClassName}
|
||||
{...register("viewDistance")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="simulationDistance" className={labelClassName}>
|
||||
{t("simulationDistance")}
|
||||
</label>
|
||||
<input
|
||||
id="simulationDistance"
|
||||
type="number"
|
||||
min={3}
|
||||
max={32}
|
||||
className={inputClassName}
|
||||
{...register("simulationDistance")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="spawnProtection" className={labelClassName}>
|
||||
{t("spawnProtection")}
|
||||
</label>
|
||||
<input
|
||||
id="spawnProtection"
|
||||
type="number"
|
||||
min={0}
|
||||
max={64}
|
||||
className={inputClassName}
|
||||
{...register("spawnProtection")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{apiError ? (
|
||||
<p className="text-sm text-red-600 dark:text-red-400" role="alert">
|
||||
{apiError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{saved ? (
|
||||
<p className="text-sm text-green-600 dark:text-green-400">{t("saved")}</p>
|
||||
) : null}
|
||||
|
||||
<Button type="submit" disabled={isSubmitting || saveMutation.isPending || !isDirty}>
|
||||
{isSubmitting || saveMutation.isPending ? t("saving") : t("save")}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function mapPropertiesToForm(properties: ServerProperties): PropertiesFormValues {
|
||||
return {
|
||||
motd: properties.motd,
|
||||
gamemode: properties.gamemode,
|
||||
difficulty: properties.difficulty,
|
||||
maxPlayers: properties.maxPlayers,
|
||||
pvp: properties.pvp,
|
||||
whitelist: properties.whitelist,
|
||||
hardcore: properties.hardcore ?? false,
|
||||
onlineMode: properties.onlineMode ?? true,
|
||||
enableCommandBlock: properties.enableCommandBlock ?? false,
|
||||
viewDistance: properties.viewDistance ?? 10,
|
||||
simulationDistance: properties.simulationDistance ?? 10,
|
||||
spawnProtection: properties.spawnProtection ?? 16,
|
||||
allowFlight: properties.allowFlight ?? false,
|
||||
};
|
||||
}
|
||||
59
apps/web/src/components/servers/server-tabs.tsx
Normal file
59
apps/web/src/components/servers/server-tabs.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Link, usePathname } from "@/i18n/navigation";
|
||||
|
||||
interface ServerTabsProps {
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ key: "overview", suffix: "" },
|
||||
{ key: "console", suffix: "/console" },
|
||||
{ key: "files", suffix: "/files" },
|
||||
{ key: "properties", suffix: "/properties" },
|
||||
{ key: "players", suffix: "/players" },
|
||||
] as const;
|
||||
|
||||
export function ServerTabs({ serverId }: ServerTabsProps) {
|
||||
const t = useTranslations("servers.tabs");
|
||||
const pathname = usePathname();
|
||||
|
||||
const basePath = `/servers/${serverId}`;
|
||||
|
||||
function isActive(suffix: string): boolean {
|
||||
const href = `${basePath}${suffix}`;
|
||||
if (suffix === "") {
|
||||
return pathname === basePath;
|
||||
}
|
||||
return pathname === href || pathname.startsWith(`${href}/`);
|
||||
}
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label={t("navigation")}
|
||||
className="flex flex-wrap gap-1 border-b border-zinc-200 dark:border-zinc-800"
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const href = `${basePath}${tab.suffix}`;
|
||||
const active = isActive(tab.suffix);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={tab.key}
|
||||
href={href}
|
||||
className={[
|
||||
"inline-flex items-center border-b-2 px-3 py-2 text-sm font-medium transition-colors",
|
||||
active
|
||||
? "border-sky-500 text-sky-600 dark:text-sky-400"
|
||||
: "border-transparent text-zinc-600 hover:border-zinc-300 hover:text-zinc-900 dark:text-zinc-400 dark:hover:border-zinc-600 dark:hover:text-zinc-100",
|
||||
].join(" ")}
|
||||
aria-current={active ? "page" : undefined}
|
||||
>
|
||||
{t(tab.key)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
86
apps/web/src/lib/api/console.ts
Normal file
86
apps/web/src/lib/api/console.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { getApiUrl } from "../env";
|
||||
import { apiFetch } from "./auth";
|
||||
|
||||
export interface ConsoleTokenResponse {
|
||||
token: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface ConsoleWebSocketHandlers {
|
||||
onOpen?: () => void;
|
||||
onClose?: (event: CloseEvent) => void;
|
||||
onError?: (event: Event) => void;
|
||||
onLine?: (line: string, stream?: string) => void;
|
||||
}
|
||||
|
||||
interface ConsoleLogMessage {
|
||||
type?: string;
|
||||
line?: string;
|
||||
stream?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
function getConsoleWebSocketUrl(serverId: string, token: string): string {
|
||||
const apiUrl = new URL(getApiUrl());
|
||||
apiUrl.protocol = apiUrl.protocol === "https:" ? "wss:" : "ws:";
|
||||
const basePath = apiUrl.pathname.replace(/\/$/, "");
|
||||
apiUrl.pathname = `${basePath}/servers/${serverId}/console/ws`;
|
||||
apiUrl.search = new URLSearchParams({ token }).toString();
|
||||
return apiUrl.toString();
|
||||
}
|
||||
|
||||
export async function getConsoleToken(serverId: string): Promise<ConsoleTokenResponse> {
|
||||
return apiFetch<ConsoleTokenResponse>(`/servers/${serverId}/console/token`);
|
||||
}
|
||||
|
||||
export function createConsoleWebSocket(
|
||||
serverId: string,
|
||||
token: string,
|
||||
handlers: ConsoleWebSocketHandlers,
|
||||
): WebSocket {
|
||||
const socket = new WebSocket(getConsoleWebSocketUrl(serverId, token));
|
||||
|
||||
socket.addEventListener("open", () => {
|
||||
handlers.onOpen?.();
|
||||
});
|
||||
|
||||
socket.addEventListener("close", (event) => {
|
||||
handlers.onClose?.(event);
|
||||
});
|
||||
|
||||
socket.addEventListener("error", (event) => {
|
||||
handlers.onError?.(event);
|
||||
});
|
||||
|
||||
socket.addEventListener("message", (event) => {
|
||||
const raw = typeof event.data === "string" ? event.data : "";
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as ConsoleLogMessage;
|
||||
if (parsed.type === "log" && parsed.line) {
|
||||
handlers.onLine?.(parsed.line, parsed.stream);
|
||||
return;
|
||||
}
|
||||
if (parsed.line) {
|
||||
handlers.onLine?.(parsed.line, parsed.stream);
|
||||
return;
|
||||
}
|
||||
if (parsed.message) {
|
||||
handlers.onLine?.(parsed.message, parsed.stream);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Plain text log line
|
||||
}
|
||||
|
||||
if (raw) {
|
||||
handlers.onLine?.(raw);
|
||||
}
|
||||
});
|
||||
|
||||
return socket;
|
||||
}
|
||||
|
||||
export function sendConsoleCommand(socket: WebSocket, command: string): void {
|
||||
socket.send(JSON.stringify({ command }));
|
||||
}
|
||||
61
apps/web/src/lib/api/files.ts
Normal file
61
apps/web/src/lib/api/files.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { apiFetch } from "./auth";
|
||||
|
||||
export interface FileEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
isDir: boolean;
|
||||
size: number;
|
||||
modifiedAt?: string;
|
||||
}
|
||||
|
||||
export interface ListFilesResponse {
|
||||
path: string;
|
||||
entries: FileEntry[];
|
||||
}
|
||||
|
||||
export interface FileContentResponse {
|
||||
path: string;
|
||||
content: string;
|
||||
truncated?: boolean;
|
||||
version?: number;
|
||||
}
|
||||
|
||||
export interface WriteFilePayload {
|
||||
path: string;
|
||||
content: string;
|
||||
version?: number;
|
||||
}
|
||||
|
||||
export async function listFiles(
|
||||
serverId: string,
|
||||
path = "",
|
||||
): Promise<ListFilesResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (path) {
|
||||
params.set("path", path);
|
||||
}
|
||||
const query = params.toString();
|
||||
return apiFetch<ListFilesResponse>(
|
||||
`/servers/${serverId}/files${query ? `?${query}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function readFile(
|
||||
serverId: string,
|
||||
path: string,
|
||||
): Promise<FileContentResponse> {
|
||||
const params = new URLSearchParams({ path });
|
||||
return apiFetch<FileContentResponse>(
|
||||
`/servers/${serverId}/files/content?${params.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function writeFile(
|
||||
serverId: string,
|
||||
payload: WriteFilePayload,
|
||||
): Promise<FileContentResponse> {
|
||||
return apiFetch<FileContentResponse>(`/servers/${serverId}/files/content`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
17
apps/web/src/lib/api/players.ts
Normal file
17
apps/web/src/lib/api/players.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { apiFetch } from "./auth";
|
||||
|
||||
export interface OnlinePlayer {
|
||||
name: string;
|
||||
uuid?: string;
|
||||
ping?: number;
|
||||
}
|
||||
|
||||
export interface PlayerListResponse {
|
||||
online: OnlinePlayer[];
|
||||
maxPlayers: number;
|
||||
whitelistCount: number;
|
||||
}
|
||||
|
||||
export async function getPlayers(serverId: string): Promise<PlayerListResponse> {
|
||||
return apiFetch<PlayerListResponse>(`/servers/${serverId}/players`);
|
||||
}
|
||||
36
apps/web/src/lib/api/properties.ts
Normal file
36
apps/web/src/lib/api/properties.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { apiFetch } from "./auth";
|
||||
|
||||
export type GameMode = "survival" | "creative" | "adventure" | "spectator";
|
||||
export type Difficulty = "peaceful" | "easy" | "normal" | "hard";
|
||||
|
||||
export interface ServerProperties {
|
||||
motd: string;
|
||||
gamemode: GameMode;
|
||||
difficulty: Difficulty;
|
||||
maxPlayers: number;
|
||||
pvp: boolean;
|
||||
whitelist: boolean;
|
||||
hardcore?: boolean;
|
||||
onlineMode?: boolean;
|
||||
enableCommandBlock?: boolean;
|
||||
viewDistance?: number;
|
||||
simulationDistance?: number;
|
||||
spawnProtection?: number;
|
||||
allowFlight?: boolean;
|
||||
}
|
||||
|
||||
export type UpdateServerPropertiesPayload = Partial<ServerProperties>;
|
||||
|
||||
export async function getProperties(serverId: string): Promise<ServerProperties> {
|
||||
return apiFetch<ServerProperties>(`/servers/${serverId}/properties`);
|
||||
}
|
||||
|
||||
export async function updateProperties(
|
||||
serverId: string,
|
||||
payload: UpdateServerPropertiesPayload,
|
||||
): Promise<ServerProperties> {
|
||||
return apiFetch<ServerProperties>(`/servers/${serverId}/properties`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
69
apps/web/src/lib/utils/ansi.ts
Normal file
69
apps/web/src/lib/utils/ansi.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
const ANSI_PATTERN =
|
||||
/\u001b\[[0-9;]*m|\u001b\]8;;.*?\u001b\\|\u001b\][0-9];.*?\u0007/g;
|
||||
|
||||
const ANSI_COLOR_MAP: Record<string, string> = {
|
||||
"30": "text-zinc-400",
|
||||
"31": "text-red-400",
|
||||
"32": "text-green-400",
|
||||
"33": "text-yellow-400",
|
||||
"34": "text-blue-400",
|
||||
"35": "text-purple-400",
|
||||
"36": "text-cyan-400",
|
||||
"37": "text-zinc-200",
|
||||
"90": "text-zinc-500",
|
||||
"91": "text-red-300",
|
||||
"92": "text-green-300",
|
||||
"93": "text-yellow-300",
|
||||
"94": "text-blue-300",
|
||||
"95": "text-purple-300",
|
||||
"96": "text-cyan-300",
|
||||
"97": "text-zinc-100",
|
||||
};
|
||||
|
||||
export function stripAnsi(text: string): string {
|
||||
return text.replace(ANSI_PATTERN, "");
|
||||
}
|
||||
|
||||
export interface AnsiSegment {
|
||||
text: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function parseAnsiLine(line: string): AnsiSegment[] {
|
||||
const segments: AnsiSegment[] = [];
|
||||
let currentClass: string | undefined;
|
||||
let buffer = "";
|
||||
const regex = /\u001b\[([0-9;]*)m/g;
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = regex.exec(line)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
const text = line.slice(lastIndex, match.index);
|
||||
if (text) {
|
||||
segments.push({ text, className: currentClass });
|
||||
}
|
||||
}
|
||||
|
||||
const codes = match[1].split(";").filter(Boolean);
|
||||
if (codes.length === 0 || codes.includes("0")) {
|
||||
currentClass = undefined;
|
||||
} else {
|
||||
const colorCode = codes.find((code) => ANSI_COLOR_MAP[code]);
|
||||
currentClass = colorCode ? ANSI_COLOR_MAP[colorCode] : currentClass;
|
||||
}
|
||||
|
||||
lastIndex = regex.lastIndex;
|
||||
}
|
||||
|
||||
buffer = line.slice(lastIndex).replace(ANSI_PATTERN, "");
|
||||
if (buffer) {
|
||||
segments.push({ text: buffer, className: currentClass });
|
||||
}
|
||||
|
||||
if (segments.length === 0) {
|
||||
return [{ text: stripAnsi(line) }];
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user