perf(panel): speed up file browsing and preserve loading forms
This commit is contained in:
27 files changed
+1387
-145
No files matched your search
@@ -0,0 +1,66 @@
|
||||
import { test, expect, t, expectFitsScreen } from "./fixtures";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
test("file editor highlights configuration, preserves CRLF, undoes edits and saves by shortcut", async ({ page, signIn }) => {
|
||||
await signIn("owner");
|
||||
await page.emulateMedia({ colorScheme: "dark" });
|
||||
await page.request.post("/api/v1/servers/lobby/stop");
|
||||
const original = "groups:\r\n admin:\r\n enabled: true\r\n permissions:\r\n - limboserver.stop\r\n - limboserver.kick\r\n\r\nplayers:\r\n LOOHP:\r\n - admin\r\n";
|
||||
const created = await page.request.put("/api/v1/servers/lobby/file?path=permission.yml", { data: {
|
||||
content: Buffer.from(original).toString("base64"), content_sha256: createHash("sha256").update(original).digest("hex"), create_only: true,
|
||||
} });
|
||||
expect(created.ok()).toBe(true);
|
||||
await page.goto("/servers/lobby/files");
|
||||
await page.getByRole("button", { name: t("files:open_file", { name: "permission.yml" }), exact: true }).click();
|
||||
const editor = page.getByRole("textbox", { name: t("files:file_content") });
|
||||
await expect(editor).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: t("files:save"), exact: true })).toBeDisabled();
|
||||
await expect(page.locator(".cm-lineNumbers .cm-gutterElement")).not.toHaveCount(0);
|
||||
await expect(page.locator(".cm-line span")).not.toHaveCount(0);
|
||||
await editor.press("ControlOrMeta+End");
|
||||
await page.keyboard.type("# edited");
|
||||
await editor.press("ControlOrMeta+z");
|
||||
await expect(editor).not.toContainText("# edited");
|
||||
await page.keyboard.type("# edited");
|
||||
await expectFitsScreen(page);
|
||||
await page.screenshot({ path: "/tmp/felis-files-editor.png", fullPage: true });
|
||||
const saved = page.waitForRequest((r) => r.method() === "PUT" && r.url().includes("/file?path="));
|
||||
await editor.press("ControlOrMeta+s");
|
||||
const body = (await saved).postDataJSON();
|
||||
expect(Buffer.from(body.content, "base64").toString()).toBe(original + "# edited");
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("binary files show an explanation and download action without requesting text", async ({ page, signIn }) => {
|
||||
await signIn("owner");
|
||||
await page.emulateMedia({ colorScheme: "dark" });
|
||||
await page.request.post("/api/v1/servers/lobby/stop");
|
||||
let reads = 0;
|
||||
page.on("request", (r) => { if (r.url().includes("/file?path=")) reads++; });
|
||||
await page.goto("/servers/lobby/files");
|
||||
await page.getByRole("button", { name: t("files:open_folder", { name: "plugins" }), exact: true }).click();
|
||||
await page.getByRole("button", { name: t("files:open_file", { name: "LuckPerms-Bukkit-5.4.141.jar" }), exact: true }).click();
|
||||
await expect(page.getByText(t("files:binary_title"), { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: t("files:download_file"), exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("textbox")).toHaveCount(0);
|
||||
expect(reads).toBe(0);
|
||||
await page.screenshot({ path: "/tmp/felis-files-binary.png", fullPage: true });
|
||||
});
|
||||
|
||||
test("opening a slow file shows immediate progress and closing it ignores the delayed answer", async ({ page, signIn }) => {
|
||||
await signIn("owner");
|
||||
await page.emulateMedia({ colorScheme: "dark" });
|
||||
await page.request.post("/api/v1/servers/lobby/stop");
|
||||
let release!: () => void;
|
||||
const pending = new Promise<void>((resolve) => { release = resolve; });
|
||||
await page.route("**/api/v1/servers/lobby/file?path=*", async (route) => { await pending; await route.continue(); });
|
||||
await page.goto("/servers/lobby/files");
|
||||
await page.getByRole("button", { name: t("files:open_file", { name: "server.properties" }), exact: true }).click();
|
||||
await expect(page.getByText(t("files:opening_file"), { exact: true })).toBeVisible();
|
||||
await page.screenshot({ path: "/tmp/felis-files-loading.png", fullPage: true });
|
||||
await page.keyboard.press("Escape");
|
||||
const answer = page.waitForResponse((r) => r.url().includes("/file?path="));
|
||||
release();
|
||||
await answer;
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
});
|
||||
@@ -1,5 +1,27 @@
|
||||
import { test, expect, t, expectFitsScreen } from "./fixtures";
|
||||
|
||||
test("space settings keep the form visible while reading configuration", async ({ page, signIn }) => {
|
||||
await signIn("owner");
|
||||
await page.emulateMedia({ colorScheme: "dark" });
|
||||
let release!: () => void;
|
||||
const pending = new Promise<void>((resolve) => { release = resolve; });
|
||||
await page.route("**/api/v1/servers/login/file?path=felis-experience.json", async (route) => { await pending; await route.continue(); });
|
||||
await page.goto("/admin/lobby");
|
||||
const title = page.getByLabel(t("lobby:bookTitle"));
|
||||
await expect(title).toBeVisible();
|
||||
await expect(title).toBeDisabled();
|
||||
await expect(title).toHaveValue("");
|
||||
await expect(page.getByText(t("lobby:loading_settings"), { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: t("lobby:save"), exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: t("lobby:save"), exact: true })).toBeDisabled();
|
||||
await expectFitsScreen(page);
|
||||
await page.screenshot({ path: "/tmp/felis-lobby-loading.png", fullPage: true });
|
||||
release();
|
||||
await expect(title).toBeEnabled();
|
||||
await expect(title).toHaveValue("Felis Login");
|
||||
await expect(page.getByText(t("lobby:loading_settings"), { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("login is the default space; live lobby settings save before a confirmed restart", async ({ page, signIn }) => {
|
||||
await signIn("owner");
|
||||
await page.goto("/admin/lobby");
|
||||
|
||||
Generated
+159
@@ -8,12 +8,18 @@
|
||||
"name": "felis-panel",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.4",
|
||||
"@codemirror/state": "^6.7.6",
|
||||
"@codemirror/view": "^6.43.13",
|
||||
"@lezer/highlight": "^1.2.4",
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@radix-ui/react-label": "^2.1.2",
|
||||
"@radix-ui/react-select": "^2.1.6",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"codemirror": "^6.0.2",
|
||||
"i18next": "^26.3.3",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^0.469.0",
|
||||
@@ -6508,6 +6514,159 @@
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/highlight": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.4.tgz",
|
||||
"integrity": "sha512-4UeRVFO0lsBaM7GA32zOqbmqNPme9jedo8Ey+5c5656MU3MLitMLIA8aCZTYjUMEpEsuSeXEm78Uea6Iq3bvSg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/legacy-modes": {
|
||||
"version": "6.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.4.tgz",
|
||||
"integrity": "sha512-/cZr6qZyl08iYNLGsJ862CXXNI51LryRFRE40ejgoIjXZz0C1rGkD3/Ek5jM/8w1ceRjqtt4qx/KLMh4zBTgew==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/language": {
|
||||
"version": "6.12.4",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz",
|
||||
"integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/lr": "^1.0.0",
|
||||
"style-mod": "^4.0.0",
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@codemirror/view": "^6.23.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@codemirror/state": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/codemirror": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
|
||||
"integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/lint": "^6.0.0",
|
||||
"@codemirror/view": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/search": "^6.0.0",
|
||||
"@codemirror/commands": "^6.0.0",
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/autocomplete": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/view": {
|
||||
"version": "6.43.13",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.13.tgz",
|
||||
"integrity": "sha512-sihaFrUzAsYBQsL9J2t69y8nfMQGwcYmggAZsk+kjPbjYZMyuf2hU8tUNTZ+P+isb6XRr8JE22TZlJxBoVdH1A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"crelt": "^1.0.6",
|
||||
"style-mod": "^4.1.0",
|
||||
"w3c-keyname": "^2.2.4",
|
||||
"@codemirror/state": "^6.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/style-mod": {
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.4.tgz",
|
||||
"integrity": "sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lezer/common": {
|
||||
"version": "1.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.3.tgz",
|
||||
"integrity": "sha512-H0iErY4e43LpXbYDyBci5W4v/RwTgGV3YzYOlJPiGZ8RY8w51kVE+xLn1tg3Z6UmSfyETvtsUK3r7YsgDQEI7Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lezer/lr": {
|
||||
"version": "1.4.10",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
|
||||
"integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/crelt": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
|
||||
"integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@codemirror/autocomplete": {
|
||||
"version": "6.20.3",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
|
||||
"integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/language": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/search": {
|
||||
"version": "6.7.2",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.2.tgz",
|
||||
"integrity": "sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"crelt": "^1.0.5",
|
||||
"@codemirror/view": "^6.37.0",
|
||||
"@codemirror/state": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/commands": {
|
||||
"version": "6.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.1.tgz",
|
||||
"integrity": "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.1.0",
|
||||
"@codemirror/view": "^6.27.0",
|
||||
"@codemirror/state": "^6.7.0",
|
||||
"@codemirror/language": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lint": {
|
||||
"version": "6.9.7",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz",
|
||||
"integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"crelt": "^1.0.5",
|
||||
"@codemirror/view": "^6.42.0",
|
||||
"@codemirror/state": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/state": {
|
||||
"version": "6.7.6",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.6.tgz",
|
||||
"integrity": "sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@marijn/find-cluster-break": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@marijn/find-cluster-break": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz",
|
||||
"integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,12 +16,18 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.4",
|
||||
"@codemirror/state": "^6.7.6",
|
||||
"@codemirror/view": "^6.43.13",
|
||||
"@lezer/highlight": "^1.2.4",
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@radix-ui/react-label": "^2.1.2",
|
||||
"@radix-ui/react-select": "^2.1.6",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"codemirror": "^6.0.2",
|
||||
"i18next": "^26.3.3",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^0.469.0",
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { basicSetup } from "codemirror";
|
||||
import { EditorView, keymap } from "@codemirror/view";
|
||||
import { EditorState, Compartment } from "@codemirror/state";
|
||||
import { StreamLanguage, syntaxHighlighting, HighlightStyle } from "@codemirror/language";
|
||||
import { tags } from "@lezer/highlight";
|
||||
import { yaml } from "@codemirror/legacy-modes/mode/yaml";
|
||||
import { properties } from "@codemirror/legacy-modes/mode/properties";
|
||||
import { toml } from "@codemirror/legacy-modes/mode/toml";
|
||||
import { json, javascript } from "@codemirror/legacy-modes/mode/javascript";
|
||||
import { xml } from "@codemirror/legacy-modes/mode/xml";
|
||||
import { shell } from "@codemirror/legacy-modes/mode/shell";
|
||||
import { useTheme } from "@/lib/theme";
|
||||
|
||||
const languages = { yaml, yml: yaml, properties, toml, json, js: javascript, xml, sh: shell };
|
||||
|
||||
export function TextFileEditor({ path, value, onChange, onSave, label }: {
|
||||
path: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSave: () => void;
|
||||
label: string;
|
||||
}) {
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
const view = useRef<EditorView | null>(null);
|
||||
const themeConfig = useRef(new Compartment());
|
||||
const callbacks = useRef({ onChange, onSave });
|
||||
const { theme } = useTheme();
|
||||
useEffect(() => { callbacks.current = { onChange, onSave }; }, [onChange, onSave]);
|
||||
|
||||
useEffect(() => {
|
||||
const extension = path.split(".").pop()?.toLowerCase() ?? "";
|
||||
const language = languages[extension as keyof typeof languages];
|
||||
const lineBreak = value.includes("\r\n") ? "\r\n" : value.includes("\r") && !value.includes("\n") ? "\r" : "\n";
|
||||
const editor = new EditorView({
|
||||
parent: host.current!,
|
||||
state: EditorState.create({
|
||||
doc: value,
|
||||
extensions: [
|
||||
basicSetup,
|
||||
language ? StreamLanguage.define(language) : [],
|
||||
EditorState.tabSize.of(2),
|
||||
EditorState.lineSeparator.of(lineBreak),
|
||||
EditorView.contentAttributes.of({ "aria-label": label }),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) callbacks.current.onChange(update.state.sliceDoc());
|
||||
}),
|
||||
keymap.of([{ key: "Mod-s", run: () => { callbacks.current.onSave(); return true; } }]),
|
||||
themeConfig.current.of(editorTheme(theme === "dark")),
|
||||
],
|
||||
}),
|
||||
});
|
||||
view.current = editor;
|
||||
editor.focus();
|
||||
return () => { editor.destroy(); view.current = null; };
|
||||
// A file gets one editor; text and theme changes update it without losing history.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [path, label]);
|
||||
|
||||
useEffect(() => {
|
||||
const editor = view.current;
|
||||
if (editor && editor.state.sliceDoc() !== value) {
|
||||
editor.dispatch({ changes: { from: 0, to: editor.state.doc.length, insert: value } });
|
||||
}
|
||||
}, [value]);
|
||||
useEffect(() => {
|
||||
view.current?.dispatch({ effects: themeConfig.current.reconfigure(editorTheme(theme === "dark")) });
|
||||
}, [theme]);
|
||||
|
||||
return <div ref={host} className="min-w-0 overflow-hidden rounded-md border border-input" />;
|
||||
}
|
||||
|
||||
function editorTheme(dark: boolean) {
|
||||
return [EditorView.theme({
|
||||
"&": { height: "50vh", color: "hsl(var(--foreground))", backgroundColor: "hsl(var(--background))", fontSize: "13px" },
|
||||
"&.cm-focused": { outline: "2px solid hsl(var(--ring) / .5)", outlineOffset: "-2px" },
|
||||
".cm-scroller": { fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", lineHeight: "1.7" },
|
||||
".cm-content": { padding: "12px 0", caretColor: "hsl(var(--foreground))" },
|
||||
".cm-gutters": { backgroundColor: "hsl(var(--card))", color: "hsl(var(--muted-foreground))", borderColor: "hsl(var(--border))" },
|
||||
".cm-activeLine, .cm-activeLineGutter": { backgroundColor: "hsl(var(--muted) / .5)" },
|
||||
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground": { backgroundColor: "hsl(var(--primary) / .2)" },
|
||||
}, { dark }), syntaxHighlighting(HighlightStyle.define([
|
||||
{ tag: [tags.keyword, tags.bool, tags.null], color: dark ? "#c4b5fd" : "#7c3aed" },
|
||||
{ tag: [tags.propertyName, tags.variableName, tags.atom, tags.meta], color: dark ? "#7dd3fc" : "#0369a1" },
|
||||
{ tag: [tags.string, tags.quote], color: dark ? "#86efac" : "#15803d" },
|
||||
{ tag: tags.number, color: dark ? "#fdba74" : "#c2410c" },
|
||||
{ tag: tags.comment, color: dark ? "#94a3b8" : "#64748b", fontStyle: "italic" },
|
||||
]))];
|
||||
}
|
||||
@@ -16,7 +16,7 @@
|
||||
"stopped_required_body": "Files can only be browsed or edited while the server is fully stopped — the world volume is single-attached to the running server. Stop it to continue.",
|
||||
"not_yours_title": "Not your server",
|
||||
"not_yours_body": "Only the owner or an admin can edit this server's files.",
|
||||
"binary_hint": "Binary file — opened read-only; saving non-text bytes through an editor could corrupt it.",
|
||||
"binary_hint": "Editing this file as text would damage it. Download it to open with the appropriate application, then upload your changes.",
|
||||
"saved": "Saved {{path}}",
|
||||
"saving": "Saving…",
|
||||
"save": "Save",
|
||||
@@ -129,5 +129,12 @@
|
||||
"job_failed": "The background task ended without saying why. Refresh the list to check, then try again.",
|
||||
"job_timed_out": "The background task did not finish within 2 hours and was stopped. Refresh the list to check, then try again.",
|
||||
"job_out_of_memory": "The background task ran out of memory and the system stopped it. The files on the server were not changed. Try again.",
|
||||
"job_out_of_memory_unzip": "The archive holds more files than the extraction task has memory to list, so the system stopped it. The files on the server were not changed. Split it into several smaller zips and extract each one."
|
||||
"job_out_of_memory_unzip": "The archive holds more files than the extraction task has memory to list, so the system stopped it. The files on the server were not changed. Split it into several smaller zips and extract each one.",
|
||||
"binary_title": "This is a binary file",
|
||||
"preview_large_title": "File exceeds the preview limit",
|
||||
"preview_large_hint": "The editor reads files up to {{limit}}. Download it to edit, then upload your changes.",
|
||||
"download_file": "Download file",
|
||||
"opening_file": "Reading file…",
|
||||
"loading_folder": "Opening {{path}}…",
|
||||
"file_content": "File contents"
|
||||
}
|
||||
@@ -94,5 +94,6 @@
|
||||
"loginBook_description": "Edit the guidance players see in the login book.",
|
||||
"content_tools": "Content & maintenance",
|
||||
"unsaved": "You have unsaved changes",
|
||||
"apply_on_start": "Save while running; settings take effect on the next start or restart"
|
||||
"apply_on_start": "Save while running; settings take effect on the next start or restart",
|
||||
"loading_settings": "Loading settings. Editing will be available once loaded."
|
||||
}
|
||||
@@ -16,7 +16,7 @@
|
||||
"stopped_required_body": "只有在服务器完全停止后才能浏览或编辑文件——世界卷被运行中的服务器独占挂载。请先停止服务器。",
|
||||
"not_yours_title": "这不是你的服务器",
|
||||
"not_yours_body": "只有服务器所有者或管理员才能编辑其文件。",
|
||||
"binary_hint": "二进制文件——以只读方式打开;通过编辑器保存非文本字节可能损坏该文件。",
|
||||
"binary_hint": "这是二进制文件,文本编辑会损坏它。请下载后使用对应的软件打开,修改后重新上传。",
|
||||
"saved": "已保存 {{path}}",
|
||||
"saving": "保存中…",
|
||||
"save": "保存",
|
||||
@@ -125,5 +125,12 @@
|
||||
"job_failed": "后台任务没说明原因就结束了。刷新列表确认一下,再试一次。",
|
||||
"job_timed_out": "后台任务 2 小时还没做完,被停下了。刷新列表确认一下,再试一次。",
|
||||
"job_out_of_memory": "后台任务用完了内存,被系统停掉了。服务器上的文件没有被改动,再试一次。",
|
||||
"job_out_of_memory_unzip": "压缩包里的文件太多,解压任务的内存装不下它的文件清单,被系统停掉了。服务器上的文件没有被改动。把它拆成几个小一些的 zip,分别上传解压。"
|
||||
"job_out_of_memory_unzip": "压缩包里的文件太多,解压任务的内存装不下它的文件清单,被系统停掉了。服务器上的文件没有被改动。把它拆成几个小一些的 zip,分别上传解压。",
|
||||
"binary_title": "此文件不能作为文本编辑",
|
||||
"preview_large_title": "文件超出在线预览上限",
|
||||
"preview_large_hint": "在线编辑器最多读取 {{limit}}。请下载后编辑,再重新上传。",
|
||||
"download_file": "下载文件",
|
||||
"opening_file": "正在读取文件…",
|
||||
"loading_folder": "正在打开 {{path}}…",
|
||||
"file_content": "文件内容"
|
||||
}
|
||||
@@ -94,5 +94,6 @@
|
||||
"loginBook_description": "编辑玩家打开登录书时看到的引导内容。",
|
||||
"content_tools": "内容与维护",
|
||||
"unsaved": "有未保存的更改",
|
||||
"apply_on_start": "可以在运行中保存;设置将在下次启动或重启后生效"
|
||||
"apply_on_start": "可以在运行中保存;设置将在下次启动或重启后生效",
|
||||
"loading_settings": "正在加载配置,读取完成后即可编辑。"
|
||||
}
|
||||
@@ -232,6 +232,26 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/internal/file-browser/{id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Exchange a read-only file Job's result for its next command.
|
||||
* @description Internal face only. A random bearer token scopes the worker to one world and a four-minute session; idle workers exit after 45 seconds. The first request sends an empty object. Later requests return the previous command's id and result or error. This endpoint dispatches only reads already authorized by the external file API.
|
||||
*/
|
||||
post: operations["internalFileBrowser"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/internal/exports/{id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -4179,6 +4199,48 @@ export interface operations {
|
||||
404: components["responses"]["NotFound"];
|
||||
};
|
||||
};
|
||||
internalFileBrowser: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header: {
|
||||
Authorization: string;
|
||||
};
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
id?: string;
|
||||
result?: Record<string, never>;
|
||||
error?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description The next authorized read command. */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
id: string;
|
||||
/** @enum {string} */
|
||||
op: "list" | "read";
|
||||
path: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
204: components["responses"]["NoContent"];
|
||||
400: components["responses"]["BadRequest"];
|
||||
404: components["responses"]["NotFound"];
|
||||
409: components["responses"]["Conflict"];
|
||||
503: components["responses"]["ServiceUnavailable"];
|
||||
};
|
||||
};
|
||||
internalExportUpload: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -68,6 +68,12 @@ vi.mock("@/lib/api", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
vi.mock("@/lib/tier", () => ({ useTier: () => ({ isAdmin: true, loading: false }) }));
|
||||
// Editor interactions are exercised in the browser; page tests focus on reads,
|
||||
// writes, conflict protection and the dialog lifecycle.
|
||||
vi.mock("@/components/files/TextFileEditor", () => ({
|
||||
TextFileEditor: ({ value, onChange, label }: { value: string; onChange: (v: string) => void; label: string }) =>
|
||||
<textarea aria-label={label} value={value} onChange={(e) => onChange(e.target.value)} />,
|
||||
}));
|
||||
|
||||
const t = (key: string) => i18next.t(key);
|
||||
|
||||
@@ -85,7 +91,7 @@ async function openEditor() {
|
||||
renderFiles();
|
||||
await userEvent.click(await screen.findByText("server.properties"));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
return { dialog, editor: within(dialog).getByRole("textbox") as HTMLTextAreaElement };
|
||||
return { dialog, editor: await within(dialog).findByRole("textbox") as HTMLTextAreaElement };
|
||||
}
|
||||
|
||||
let opsNow: FileOp[] = [];
|
||||
@@ -304,6 +310,56 @@ describe("ServerFiles from the keyboard", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ServerFiles opening feedback", () => {
|
||||
it("shows the selected file and loading dialog immediately, and ignores a cancelled read", async () => {
|
||||
let resolve!: (value: { content: string; sha256: string }) => void;
|
||||
mocks.readServerFile.mockReturnValue(new Promise((r) => { resolve = r; }));
|
||||
renderFiles();
|
||||
await userEvent.click(await screen.findByText("server.properties"));
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(within(dialog).getByText(t("files:opening_file"))).toBeTruthy();
|
||||
expect(screen.getByRole("row", { selected: true, hidden: true }).textContent).toContain("server.properties");
|
||||
await userEvent.keyboard("{Escape}");
|
||||
await act(async () => resolve({ content: btoa("late file"), sha256: "abc" }));
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Limbo.jar", 8 * 1024 * 1024, "binary_title"],
|
||||
["spawn.schem", 6000, "binary_title"],
|
||||
["large.log", 2 * 1024 * 1024, "preview_large_title"],
|
||||
])("explains why %s cannot be edited without starting a read Job", async (name, size, title) => {
|
||||
mocks.listServerFiles.mockResolvedValue({ entries: [{ name, size, is_dir: false, mod_time: "" }] });
|
||||
renderFiles();
|
||||
await userEvent.click(await screen.findByText(name));
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(within(dialog).getByText(t(`files:${title}`))).toBeTruthy();
|
||||
expect(within(dialog).getByRole("button", { name: t("files:download_file") })).toBeTruthy();
|
||||
expect(within(dialog).queryByRole("textbox")).toBeNull();
|
||||
expect(within(dialog).queryByRole("button", { name: t("files:save") })).toBeNull();
|
||||
expect(mocks.readServerFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("detects binary bytes even with an unfamiliar extension", async () => {
|
||||
mocks.readServerFile.mockResolvedValue({ content: btoa("a\0b"), sha256: "abc" });
|
||||
renderFiles();
|
||||
await userEvent.click(await screen.findByText("server.properties"));
|
||||
expect(await screen.findByText(t("files:binary_title"))).toBeTruthy();
|
||||
expect(screen.queryByRole("textbox")).toBeNull();
|
||||
});
|
||||
|
||||
it("reuses recent directories, but an explicit refresh invalidates them", async () => {
|
||||
renderFiles();
|
||||
await userEvent.click(await screen.findByText("world"));
|
||||
await screen.findByRole("button", { name: "world" });
|
||||
await userEvent.click(screen.getByRole("button", { name: t("files:up") }));
|
||||
expect(mocks.listServerFiles.mock.calls).toEqual([["lobby", ""], ["lobby", "world"]]);
|
||||
await userEvent.click(screen.getByRole("button", { name: t("files:refresh") }));
|
||||
await userEvent.click(await screen.findByText("world"));
|
||||
expect(mocks.listServerFiles.mock.calls).toEqual([["lobby", ""], ["lobby", "world"], ["lobby", ""], ["lobby", "world"]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ServerFiles folder answers arriving out of order", () => {
|
||||
type Listing = { path: string; truncated: boolean; entries: { name: string; size: number; is_dir: boolean; mod_time: string }[] };
|
||||
function deferred() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useParams, useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
AlertTriangle,
|
||||
@@ -65,6 +65,9 @@ import { cn } from "@/lib/utils";
|
||||
* are capped at 1 MiB server-side; a larger file is refused there with 413, so
|
||||
* this only gates the save button to keep the common case honest up front. */
|
||||
const MAX_WRITE_BYTES = 256 * 1024;
|
||||
const MAX_READ_BYTES = 1024 * 1024;
|
||||
const TextFileEditor = lazy(() => import("@/components/files/TextFileEditor").then((m) => ({ default: m.TextFileEditor })));
|
||||
const BINARY_EXTENSION = /\.(jar|zip|gz|tar|7z|rar|dat|mca|mcr|schem|schematic|nbt|db|sqlite|png|jpe?g|gif|webp|ico|ogg|mp[34]|pdf|class|exe|dll|so|bin)$/i;
|
||||
|
||||
/** The []byte wire codec: Go's encoding/json renders []byte as base64, so the
|
||||
* editor must speak it explicitly in both directions. Chunked so a ~256 KiB
|
||||
@@ -134,6 +137,9 @@ export function ServerFiles() {
|
||||
const [truncated, setTruncated] = useState(false);
|
||||
const [listErr, setListErr] = useState<unknown>(null);
|
||||
const [listLoading, setListLoading] = useState(false);
|
||||
const [listTarget, setListTarget] = useState("");
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const folders = useRef(new Map<string, { at: number; listing: Awaited<ReturnType<typeof api.listServerFiles>> }>());
|
||||
// Bytes free on the world volume by the latest listing; null while unknown
|
||||
// (the listing says null when the Job could not tell; 0 is a full volume).
|
||||
const [free, setFree] = useState<number | null>(null);
|
||||
@@ -144,13 +150,21 @@ export function ServerFiles() {
|
||||
// folder already left behind would otherwise replace the one clicked after it.
|
||||
const loadSeq = useRef(0);
|
||||
const load = useCallback(
|
||||
async (p: string) => {
|
||||
async (p: string, cached = false) => {
|
||||
const ticket = ++loadSeq.current;
|
||||
if (!cached) folders.current.clear();
|
||||
const previous = folders.current.get(`${name}:${p}`);
|
||||
setListLoading(true);
|
||||
setListTarget(p);
|
||||
setListErr(null);
|
||||
try {
|
||||
const r = await api.listServerFiles(name, p);
|
||||
const fresh = cached && previous && Date.now() - previous.at < 30_000;
|
||||
const r = fresh
|
||||
? previous.listing
|
||||
: await api.listServerFiles(name, p);
|
||||
if (ticket !== loadSeq.current) return;
|
||||
if (folders.current.size >= 20) folders.current.delete(folders.current.keys().next().value!);
|
||||
folders.current.set(`${name}:${p}`, { at: fresh ? previous.at : Date.now(), listing: r });
|
||||
setEntries(sortEntries(r.entries ?? []));
|
||||
setTruncated(r.truncated === true);
|
||||
setFree(r.free_bytes ?? null);
|
||||
@@ -174,6 +188,9 @@ export function ServerFiles() {
|
||||
if (owned && stopped) void load(dir);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [owned, stopped, load]);
|
||||
useEffect(() => {
|
||||
if (!stopped) folders.current.clear();
|
||||
}, [stopped]);
|
||||
|
||||
// While the server is not stopped, poll the phase so the first successful
|
||||
// stop flips the page from the notice to the listing without a manual reload.
|
||||
@@ -192,8 +209,12 @@ export function ServerFiles() {
|
||||
sha256: string;
|
||||
conflict: boolean;
|
||||
error: string | null;
|
||||
preview?: "binary" | "large";
|
||||
size?: number;
|
||||
} | null>(null);
|
||||
const [opening, setOpening] = useState<string | null>(null);
|
||||
const openingSeq = useRef(0);
|
||||
useEffect(() => () => { ++openingSeq.current; ++loadSeq.current; }, []);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [reloading, setReloading] = useState(false);
|
||||
// Closing an editor with unsaved text (Esc, the overlay, ✕, Cancel) asks
|
||||
@@ -208,6 +229,8 @@ export function ServerFiles() {
|
||||
setConfirmDiscard(true);
|
||||
return;
|
||||
}
|
||||
++openingSeq.current;
|
||||
setOpening(null);
|
||||
setOpen(null);
|
||||
}
|
||||
|
||||
@@ -216,9 +239,11 @@ export function ServerFiles() {
|
||||
setOpen(null);
|
||||
}
|
||||
|
||||
async function readInto(p: string) {
|
||||
async function readInto(p: string, ticket = openingSeq.current) {
|
||||
const r = await api.readServerFile(name, p);
|
||||
const text = decodeText(base64ToBytes(r.content ?? ""));
|
||||
if (ticket !== openingSeq.current) return;
|
||||
const bytes = base64ToBytes(r.content ?? "");
|
||||
const text = decodeText(bytes);
|
||||
setConfirmDiscard(false);
|
||||
setOpen({
|
||||
path: p,
|
||||
@@ -228,25 +253,36 @@ export function ServerFiles() {
|
||||
sha256: r.sha256 ?? "",
|
||||
conflict: false,
|
||||
error: null,
|
||||
preview: text === null ? "binary" : undefined,
|
||||
size: bytes.length,
|
||||
});
|
||||
}
|
||||
|
||||
async function openFile(entry: ServerFileEntry) {
|
||||
if (opening !== null) return;
|
||||
const p = joinPath(dir, entry.name);
|
||||
setSelected(p);
|
||||
setMsg(null);
|
||||
// The read path refuses this one outright; saying why beats a Job that
|
||||
// answers "invalid path".
|
||||
if (p === SECRET_CONFIG_PATH) {
|
||||
setMsg({ kind: "error", text: t("secret_config_unreadable") });
|
||||
return;
|
||||
}
|
||||
const preview = BINARY_EXTENSION.test(entry.name) ? "binary" : entry.size > MAX_READ_BYTES ? "large" : undefined;
|
||||
if (preview) {
|
||||
setOpen({ path: p, text: "", original: "", editable: false, sha256: "", conflict: false, error: null, preview, size: entry.size });
|
||||
return;
|
||||
}
|
||||
const ticket = ++openingSeq.current;
|
||||
setOpening(p);
|
||||
setMsg(null);
|
||||
try {
|
||||
await readInto(p);
|
||||
await readInto(p, ticket);
|
||||
} catch (e) {
|
||||
setMsg({ kind: "error", text: humanizeError(e) });
|
||||
if (ticket === openingSeq.current) setMsg({ kind: "error", text: humanizeError(e) });
|
||||
} finally {
|
||||
setOpening(null);
|
||||
if (ticket === openingSeq.current) setOpening(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,7 +302,7 @@ export function ServerFiles() {
|
||||
// handleSave writes the editor's text. `overwrite` drops the precondition,
|
||||
// which is what "Overwrite anyway" on a conflict means.
|
||||
async function handleSave(overwrite = false) {
|
||||
if (!open || saving) return;
|
||||
if (!open?.editable || saving || (!overwrite && open.conflict) || !dirty || tooLarge) return;
|
||||
setSaving(true);
|
||||
setMsg(null);
|
||||
setOpen({ ...open, error: null });
|
||||
@@ -314,6 +350,9 @@ export function ServerFiles() {
|
||||
// whichever tab or person started them. A restore replaces the files listed.
|
||||
const worldJobs = useWorldJobs(name, owned && stopped, () => void load(dir));
|
||||
const held = worldJobs.holder;
|
||||
useEffect(() => {
|
||||
if (held !== null) folders.current.clear();
|
||||
}, [held]);
|
||||
|
||||
// A download holds the world while felis-api gets it ready, and a change
|
||||
// sent meanwhile could only be refused.
|
||||
@@ -663,7 +702,7 @@ export function ServerFiles() {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void load(parentOf(dir))}
|
||||
onClick={() => void load(parentOf(dir), true)}
|
||||
disabled={dir === "" || listLoading}
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
@@ -672,7 +711,7 @@ export function ServerFiles() {
|
||||
<nav className="flex min-w-0 flex-wrap items-center gap-1 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void load("")}
|
||||
onClick={() => void load("", true)}
|
||||
className={cn(
|
||||
"rounded px-1.5 py-0.5 hover:bg-muted",
|
||||
dir === "" ? "font-medium text-foreground" : "text-muted-foreground",
|
||||
@@ -688,7 +727,7 @@ export function ServerFiles() {
|
||||
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground/60" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void load(target)}
|
||||
onClick={() => void load(target, true)}
|
||||
className={cn(
|
||||
"rounded px-1.5 py-0.5 font-mono text-xs hover:bg-muted",
|
||||
last ? "font-medium text-foreground" : "text-muted-foreground",
|
||||
@@ -760,6 +799,13 @@ export function ServerFiles() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{listLoading && (
|
||||
<div role="status" className="flex items-center gap-2 border-b border-border bg-primary/5 px-4 py-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||
{t("loading_folder", { path: listTarget || t("root") })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{held !== null && (
|
||||
<section role="status" className="border-b border-border bg-muted/20 px-4 py-2.5">
|
||||
<p className="flex items-start gap-2.5 text-sm">
|
||||
@@ -811,8 +857,13 @@ export function ServerFiles() {
|
||||
return (
|
||||
<tr
|
||||
key={e.name}
|
||||
onClick={() => (e.is_dir ? void load(p) : void openFile(e))}
|
||||
className="cursor-pointer transition-colors hover:bg-muted/30"
|
||||
onClick={() => {
|
||||
setSelected(p);
|
||||
if (e.is_dir) void load(p, true);
|
||||
else void openFile(e);
|
||||
}}
|
||||
aria-selected={selected === p}
|
||||
className={cn("cursor-pointer transition-colors hover:bg-muted/30", selected === p && "bg-primary/10 ring-1 ring-inset ring-primary/30")}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
{/* The name is a button so the keyboard and a screen
|
||||
@@ -829,7 +880,7 @@ export function ServerFiles() {
|
||||
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="truncate font-mono text-xs">{e.name}</span>
|
||||
{opening === p && (
|
||||
{(opening === p || (listLoading && listTarget === p)) && (
|
||||
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
@@ -940,16 +991,18 @@ export function ServerFiles() {
|
||||
)}
|
||||
|
||||
{/* File editor dialog */}
|
||||
<Dialog open={open !== null} onOpenChange={(v) => !v && requestClose()}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<Dialog open={open !== null || opening !== null} onOpenChange={(v) => !v && requestClose()}>
|
||||
<DialogContent className="max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="break-all font-mono text-sm">
|
||||
{open?.path}
|
||||
{opening ?? open?.path}
|
||||
</DialogTitle>
|
||||
{open && !open.editable && (
|
||||
<p className="text-xs text-muted-foreground">{t("binary_hint")}</p>
|
||||
)}
|
||||
</DialogHeader>
|
||||
{opening !== null && (
|
||||
<div role="status" className="flex h-[30vh] items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-primary" />{t("opening_file")}
|
||||
</div>
|
||||
)}
|
||||
{open && (
|
||||
<>
|
||||
{open.conflict && (
|
||||
@@ -997,18 +1050,29 @@ export function ServerFiles() {
|
||||
</div>
|
||||
)}
|
||||
{open.error && <MessageLine kind="error" message={open.error} />}
|
||||
<textarea
|
||||
value={open.text}
|
||||
onChange={(e) => setOpen({ ...open, text: e.target.value })}
|
||||
readOnly={!open.editable}
|
||||
spellCheck={false}
|
||||
className="h-[50vh] w-full resize-none rounded-md border border-input bg-background p-3 font-mono text-xs leading-relaxed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
{open.editable ? (
|
||||
<Suspense fallback={<Loading />}>
|
||||
<TextFileEditor path={open.path} value={open.text} label={t("file_content")}
|
||||
onChange={(text) => setOpen({ ...open, text })} onSave={() => void handleSave()} />
|
||||
</Suspense>
|
||||
) : (
|
||||
<div className="flex min-h-52 flex-col items-center justify-center gap-3 rounded-md border border-border bg-muted/20 px-6 py-8 text-center">
|
||||
<FileArchive className="h-9 w-9 text-muted-foreground" />
|
||||
<p className="font-medium">{t(open.preview === "large" ? "preview_large_title" : "binary_title")}</p>
|
||||
<p className="max-w-md text-sm text-muted-foreground">{t(open.preview === "large" ? "preview_large_hint" : "binary_hint", { limit: formatBytes(MAX_READ_BYTES) })}</p>
|
||||
<Button variant="outline" disabled={changing}
|
||||
onClick={() => void download({ name: open.path.slice(dir.length ? dir.length + 1 : 0), is_dir: false, size: open.size ?? 0, mod_time: "" })}>
|
||||
{downloading === open.path ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />}
|
||||
{t("download_file")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{!open.editable && msg && <MessageLine kind={msg.kind} message={msg.text} />}
|
||||
<DialogFooter className="items-center gap-2 sm:justify-between">
|
||||
<span className={cn("text-xs", tooLarge ? "text-destructive" : "text-muted-foreground")}>
|
||||
{tooLarge
|
||||
? t("too_large", { limit: formatBytes(MAX_WRITE_BYTES) })
|
||||
: formatBytes(dirtyBytes)}
|
||||
: `${formatBytes(open.editable ? dirtyBytes : open.size ?? 0)}${open.editable ? " · UTF-8 · Ctrl/⌘ S" : ""}`}
|
||||
</span>
|
||||
{confirmDiscard ? (
|
||||
<div role="alert" className="flex flex-wrap items-center justify-end gap-2">
|
||||
@@ -1033,7 +1097,7 @@ export function ServerFiles() {
|
||||
>
|
||||
{t("common:cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
{open.editable && <Button
|
||||
onClick={() => void handleSave()}
|
||||
disabled={saving || reloading || open.conflict || !open.editable || !dirty || tooLarge}
|
||||
>
|
||||
@@ -1043,7 +1107,7 @@ export function ServerFiles() {
|
||||
<Save className="h-4 w-4" />
|
||||
)}
|
||||
{saving ? t("saving") : t("save")}
|
||||
</Button>
|
||||
</Button>}
|
||||
</div>
|
||||
)}
|
||||
</DialogFooter>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { LobbyPage } from "./LobbyPage";
|
||||
@@ -25,10 +25,39 @@ function page(space = "lobby") {
|
||||
render(<MemoryRouter initialEntries={[`/admin/lobby${space ? `?space=${space}` : ""}`]}><LobbyPage /></MemoryRouter>);
|
||||
}
|
||||
|
||||
async function readyField(label: string) {
|
||||
const field = await screen.findByLabelText(label) as HTMLInputElement;
|
||||
await waitFor(() => expect(field.disabled).toBe(false));
|
||||
return field;
|
||||
}
|
||||
|
||||
describe("LobbyPage", () => {
|
||||
it.each([["login", "Login book title"], ["lobby", "English menu title"]])("shows %s fields before either status or settings finish loading", async (space, label) => {
|
||||
let resolveRead!: (value: { content: string; sha256: string }) => void;
|
||||
let resolveStatus!: (value: object) => void;
|
||||
calls.readServerFile.mockReturnValue(new Promise((resolve) => { resolveRead = resolve; }));
|
||||
calls.status.mockReturnValue(new Promise((resolve) => { resolveStatus = resolve; }));
|
||||
page(space);
|
||||
const field = screen.getByLabelText(label) as HTMLInputElement;
|
||||
expect(field.value).toBe("");
|
||||
expect(field.disabled).toBe(true);
|
||||
expect(screen.getByText("Loading settings. Editing will be available once loaded.")).toBeTruthy();
|
||||
expect((screen.getByRole("button", { name: "Save settings" }) as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((screen.getByRole("button", { name: "Restart & apply" }) as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(calls.readServerFile).toHaveBeenCalledWith(space, "felis-experience.json");
|
||||
await act(async () => resolveRead({ content: btoa(JSON.stringify({ bookTitle: "My Login", menuTitleEn: "My Lobby" })), sha256: "read-hash" }));
|
||||
await readyField(label);
|
||||
expect(field.value).toBe(space === "login" ? "My Login" : "My Lobby");
|
||||
expect(screen.queryByText("Loading settings. Editing will be available once loaded.")).toBeNull();
|
||||
expect((screen.getByRole("button", { name: "Restart & apply" }) as HTMLButtonElement).disabled).toBe(true);
|
||||
await act(async () => resolveStatus({ name: space, phase: "Running", desiredState: "Running", ready: true }));
|
||||
expect((screen.getByRole("button", { name: "Restart & apply" }) as HTMLButtonElement).disabled).toBe(false);
|
||||
expect(calls.writeServerFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("saves with the read hash and preserves unknown plugin settings", async () => {
|
||||
page();
|
||||
fireEvent.change(await screen.findByLabelText("English menu title"), { target: { value: "My Network" } });
|
||||
fireEvent.change(await readyField("English menu title"), { target: { value: "My Network" } });
|
||||
await userEvent.click(screen.getByRole("button", { name: "Save settings" }));
|
||||
await screen.findByText("Saved. Settings apply on the next start or restart.");
|
||||
const [name, path, content, hash] = calls.writeServerFile.mock.calls[0];
|
||||
@@ -39,7 +68,7 @@ describe("LobbyPage", () => {
|
||||
it("uses defaults for a missing file and creates without overwriting a concurrent file", async () => {
|
||||
calls.readServerFile.mockRejectedValue({ status: 404, code: "not_found" });
|
||||
page("login");
|
||||
fireEvent.change(await screen.findByLabelText("Login timeout (30–3600 seconds)"), { target: { value: "900" } });
|
||||
fireEvent.change(await readyField("Login timeout (30–3600 seconds)"), { target: { value: "900" } });
|
||||
await userEvent.click(screen.getByRole("button", { name: "Save settings" }));
|
||||
await waitFor(() => expect(calls.createServerFile).toHaveBeenCalled());
|
||||
expect(calls.createServerFile.mock.calls[0].slice(0, 2)).toEqual(["login", "felis-experience.json"]);
|
||||
@@ -56,7 +85,7 @@ describe("LobbyPage", () => {
|
||||
it("saves while running and restarts only when requested", async () => {
|
||||
calls.status.mockResolvedValue({ name: "lobby", phase: "Running", desiredState: "Running", ready: true, playersOnline: 0 });
|
||||
page();
|
||||
fireEvent.change(await screen.findByLabelText("English menu title"), { target: { value: "My Network" } });
|
||||
fireEvent.change(await readyField("English menu title"), { target: { value: "My Network" } });
|
||||
expect((screen.getByRole("button", { name: "Restart & apply" }) as HTMLButtonElement).disabled).toBe(true);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Save settings" }));
|
||||
await screen.findByText("Saved. Settings apply on the next start or restart.");
|
||||
@@ -69,7 +98,7 @@ describe("LobbyPage", () => {
|
||||
calls.status.mockResolvedValue({ name: "lobby", phase: "Running", desiredState: "Running", ready: true, playersOnline: 2 });
|
||||
calls.restart.mockRejectedValue({ status: 409, code: "maintenance_in_progress", message: "busy" });
|
||||
page();
|
||||
await screen.findByLabelText("English menu title");
|
||||
await readyField("English menu title");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Restart & apply" }));
|
||||
const dialog = await screen.findByRole("dialog", { name: "Restart this space?" });
|
||||
expect(calls.restart).not.toHaveBeenCalled();
|
||||
@@ -82,7 +111,7 @@ describe("LobbyPage", () => {
|
||||
it("keeps the draft when another editor changed the file", async () => {
|
||||
calls.writeServerFile.mockRejectedValue({ status: 409, code: "file_changed" });
|
||||
page();
|
||||
fireEvent.change(await screen.findByLabelText("English menu title"), { target: { value: "My Network" } });
|
||||
fireEvent.change(await readyField("English menu title"), { target: { value: "My Network" } });
|
||||
await userEvent.click(screen.getByRole("button", { name: "Save settings" }));
|
||||
await screen.findByRole("alert");
|
||||
expect((screen.getByLabelText("English menu title") as HTMLInputElement).value).toBe("My Network");
|
||||
@@ -91,7 +120,7 @@ describe("LobbyPage", () => {
|
||||
|
||||
it("confirms a switch with unsaved edits", async () => {
|
||||
page();
|
||||
fireEvent.change(await screen.findByLabelText("English menu title"), { target: { value: "My Network" } });
|
||||
fireEvent.change(await readyField("English menu title"), { target: { value: "My Network" } });
|
||||
await userEvent.click(screen.getByRole("button", { name: "Login space" }));
|
||||
await screen.findByRole("dialog", { name: "Discard unsaved settings?" });
|
||||
expect(calls.status).not.toHaveBeenCalledWith("login");
|
||||
@@ -100,10 +129,16 @@ describe("LobbyPage", () => {
|
||||
expect(calls.writeServerFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not offer to save malformed JSON", async () => {
|
||||
it("keeps the form disabled after malformed JSON and enables it after retry", async () => {
|
||||
calls.readServerFile.mockResolvedValue({ content: btoa("{broken"), sha256: "hash" });
|
||||
page();
|
||||
await screen.findByRole("alert");
|
||||
expect(screen.queryByRole("button", { name: "Save settings" })).toBeNull();
|
||||
expect((screen.getByLabelText("English menu title") as HTMLInputElement).disabled).toBe(true);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Save settings" }));
|
||||
expect(calls.writeServerFile).not.toHaveBeenCalled();
|
||||
calls.readServerFile.mockResolvedValue({ content: btoa('{"menuTitleEn":"Recovered title"}'), sha256: "hash" });
|
||||
await userEvent.click(screen.getByRole("button", { name: "Try again" }));
|
||||
expect((await readyField("English menu title")).value).toBe("Recovered title");
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { BookOpen, ChevronRight, DoorOpen, FolderOpen, Globe, Map, MessageSquare, Package, RotateCw, Save, Shield, Sun, Terminal, type LucideIcon } from "lucide-react";
|
||||
import { BookOpen, ChevronRight, DoorOpen, FolderOpen, Globe, Loader2, Map, MessageSquare, Package, RotateCw, Save, Shield, Sun, Terminal, type LucideIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
@@ -12,7 +12,7 @@ import { PhaseBadge, shownPhase, startFailure } from "@/components/PhaseBadge";
|
||||
import { PowerButton, SUBMITTED_HOLD_MS } from "@/components/PowerButton";
|
||||
import { CopyAddress } from "@/components/CopyAddress";
|
||||
import { EditServerDialog } from "@/components/EditServerDialog";
|
||||
import { ErrorState, Loading } from "@/components/States";
|
||||
import { ErrorState } from "@/components/States";
|
||||
import { InlineError, MessageLine } from "@/components/MessageLine";
|
||||
import { api, humanizeError } from "@/lib/api";
|
||||
import { joinAddress } from "@/lib/config";
|
||||
@@ -28,7 +28,7 @@ const GROUP_ICONS: Record<string, LucideIcon> = {
|
||||
loginBook: BookOpen,
|
||||
};
|
||||
|
||||
function ExperienceSettings({ name, server, onDirtyChange, onChanged }: { name: string; server: ServerStatus; onDirtyChange: (dirty: boolean) => void; onChanged: () => void }) {
|
||||
function ExperienceSettings({ name, server, onDirtyChange, onChanged }: { name: string; server: ServerStatus | null; onDirtyChange: (dirty: boolean) => void; onChanged: () => void }) {
|
||||
const { t } = useTranslation("lobby");
|
||||
const groups = name === "login" ? LOGIN_GROUPS : LOBBY_GROUPS;
|
||||
const query = useAsync(() => readExperience(name, groups), [name]);
|
||||
@@ -37,7 +37,7 @@ function ExperienceSettings({ name, server, onDirtyChange, onChanged }: { name:
|
||||
const [needsRestart, setNeedsRestart] = useState(false);
|
||||
const [restarting, setRestarting] = useState(false);
|
||||
const [confirmRestart, setConfirmRestart] = useState(false);
|
||||
const running = server.phase === "Running" && server.desiredState === "Running";
|
||||
const running = server?.phase === "Running" && server.desiredState === "Running";
|
||||
useEffect(() => {
|
||||
if (!restarting) return;
|
||||
if (!running) { setRestarting(false); return; }
|
||||
@@ -54,6 +54,10 @@ function ExperienceSettings({ name, server, onDirtyChange, onChanged }: { name:
|
||||
useUnsavedGuard(dirty);
|
||||
useEffect(() => onDirtyChange(dirty), [dirty, onDirtyChange]);
|
||||
|
||||
function setValue(key: string, value: unknown) {
|
||||
setDraft((current) => current && { ...current, values: { ...current.values, [key]: value } });
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!draft || saving || !experienceValid(draft.values, groups)) return;
|
||||
setSaving(true);
|
||||
@@ -85,10 +89,10 @@ function ExperienceSettings({ name, server, onDirtyChange, onChanged }: { name:
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{query.loading && !draft && <Loading />}
|
||||
{query.error != null && <ErrorState error={t("read_failed", { reason: humanizeError(query.error) })} onRetry={query.reload} />}
|
||||
{draft && groups.map((group) => {
|
||||
<div aria-busy={query.loading} className="space-y-4">
|
||||
{query.loading && <p role="status" className="flex items-center gap-2 rounded-lg border border-border bg-muted/20 px-3 py-2.5 text-sm text-muted-foreground"><Loader2 className="h-4 w-4 shrink-0 animate-spin" />{t("loading_settings")}</p>}
|
||||
{query.error != null && <div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-destructive/30 bg-destructive/5 p-3"><InlineError message={t("read_failed", { reason: humanizeError(query.error) })} /><Button variant="outline" size="sm" onClick={query.reload}>{t("common:try_again")}</Button></div>}
|
||||
{groups.map((group) => {
|
||||
const Icon = GROUP_ICONS[group.key];
|
||||
const fields = group.fields.filter((field) => typeof field.value !== "boolean");
|
||||
const switches = group.fields.filter((field) => typeof field.value === "boolean");
|
||||
@@ -104,20 +108,20 @@ function ExperienceSettings({ name, server, onDirtyChange, onChanged }: { name:
|
||||
<CardContent className="space-y-5 pt-5">
|
||||
<div className="grid items-start gap-x-5 gap-y-4 sm:grid-cols-2">
|
||||
{fields.map((field) => {
|
||||
const value = draft.values[field.key] ?? field.value;
|
||||
const value = draft ? draft.values[field.key] ?? field.value : "";
|
||||
const id = `experience-${field.key}`;
|
||||
const set = (next: unknown) => setDraft({ ...draft, values: { ...draft.values, [field.key]: next } });
|
||||
return (
|
||||
<div key={id} className="min-w-0 space-y-2">
|
||||
<Label htmlFor={id} className="block text-xs font-medium text-muted-foreground">{t(field.key)}</Label>
|
||||
{field.choices ? (
|
||||
<select id={id} value={value as string} onChange={(e) => set(e.target.value)} disabled={saving || restarting} className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50">
|
||||
<select id={id} value={value as string} onChange={(e) => setValue(field.key, e.target.value)} disabled={!draft || saving || restarting} className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50">
|
||||
{!draft && <option value="">{query.loading ? t("common:loading") : "—"}</option>}
|
||||
{field.choices.map((choice) => <option key={choice} value={choice}>{t(choice)}</option>)}
|
||||
</select>
|
||||
) : field.multiline ? (
|
||||
<textarea id={id} value={value as string} maxLength={512} onChange={(e) => set(e.target.value)} disabled={saving || restarting} className="block h-32 w-full resize-y rounded-lg border border-input bg-background px-3 py-2.5 text-sm leading-6 outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50" />
|
||||
<textarea id={id} value={value as string} placeholder={!draft && query.loading ? t("common:loading") : undefined} maxLength={512} onChange={(e) => setValue(field.key, e.target.value)} disabled={!draft || saving || restarting} className="block h-32 w-full resize-y rounded-lg border border-input bg-background px-3 py-2.5 text-sm leading-6 outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50" />
|
||||
) : (
|
||||
<Input id={id} type={typeof field.value === "number" ? "number" : "text"} value={value as string | number} min={field.min} max={field.max} step={1} maxLength={512} onChange={(e) => set(typeof field.value === "number" ? (e.target.value === "" ? "" : Number(e.target.value)) : e.target.value)} disabled={saving || restarting} className="h-10 rounded-lg bg-background shadow-none" />
|
||||
<Input id={id} type={typeof field.value === "number" ? "number" : "text"} value={value as string | number} placeholder={!draft && query.loading ? t("common:loading") : undefined} min={field.min} max={field.max} step={1} maxLength={512} onChange={(e) => setValue(field.key, typeof field.value === "number" ? (e.target.value === "" ? "" : Number(e.target.value)) : e.target.value)} disabled={!draft || saving || restarting} className="h-10 rounded-lg bg-background shadow-none" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -128,7 +132,7 @@ function ExperienceSettings({ name, server, onDirtyChange, onChanged }: { name:
|
||||
<label key={field.key} htmlFor={`experience-${field.key}`} className="flex min-h-12 cursor-pointer items-center justify-between gap-4 py-3 text-sm">
|
||||
<span>{t(field.key)}</span>
|
||||
<span className="relative shrink-0">
|
||||
<input id={`experience-${field.key}`} type="checkbox" role="switch" checked={(draft.values[field.key] ?? field.value) as boolean} onChange={(e) => setDraft({ ...draft, values: { ...draft.values, [field.key]: e.target.checked } })} disabled={saving || restarting} className="peer sr-only" />
|
||||
<input id={`experience-${field.key}`} type="checkbox" role="switch" checked={draft ? (draft.values[field.key] ?? field.value) as boolean : false} onChange={(e) => setValue(field.key, e.target.checked)} disabled={!draft || saving || restarting} className="peer sr-only" />
|
||||
<span aria-hidden="true" className="block h-5 w-9 rounded-full bg-muted-foreground/25 p-0.5 transition-colors peer-checked:bg-primary peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2 peer-disabled:opacity-50 peer-checked:[&>span]:translate-x-4">
|
||||
<span className="block h-4 w-4 rounded-full bg-white shadow-sm transition-transform motion-reduce:transition-none" />
|
||||
</span>
|
||||
@@ -140,21 +144,19 @@ function ExperienceSettings({ name, server, onDirtyChange, onChanged }: { name:
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
{draft && <>
|
||||
<p className="text-sm text-muted-foreground">{t(name === "login" ? "login_settings_hint" : "lobby_settings_hint")}</p>
|
||||
{message && <MessageLine kind={message.kind} message={message.text} />}
|
||||
{!experienceValid(draft.values, groups) && <InlineError message={t("invalid_values")} />}
|
||||
<div className={cn("flex flex-wrap items-center justify-between gap-3 rounded-xl border bg-card p-3", needsRestart ? "border-amber-500/30 bg-amber-500/5" : "border-border")}>
|
||||
<p className={cn("text-xs", needsRestart ? "text-amber-700 dark:text-amber-400" : "text-muted-foreground")}>{t(dirty ? "unsaved" : needsRestart ? "restart_required" : "apply_on_start")}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" disabled={!running || dirty || saving || restarting} onClick={() => {
|
||||
if ((server.playersOnline ?? 0) > 0 || server.playerCountUnknown) setConfirmRestart(true);
|
||||
else void restart().catch((error) => setMessage({ kind: "error", text: humanizeError(error) }));
|
||||
}}><RotateCw className={cn(restarting && "animate-spin")} />{t(restarting ? "restarting" : "restart")}</Button>
|
||||
<Button onClick={() => void save()} disabled={!dirty || saving || restarting || !experienceValid(draft.values, groups)}><Save className="h-4 w-4" />{t(saving ? "saving" : "save")}</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{t(name === "login" ? "login_settings_hint" : "lobby_settings_hint")}</p>
|
||||
{message && <MessageLine kind={message.kind} message={message.text} />}
|
||||
{draft && !experienceValid(draft.values, groups) && <InlineError message={t("invalid_values")} />}
|
||||
<div className={cn("flex flex-wrap items-center justify-between gap-3 rounded-xl border bg-card p-3", needsRestart ? "border-amber-500/30 bg-amber-500/5" : "border-border")}>
|
||||
<p className={cn("text-xs", needsRestart ? "text-amber-700 dark:text-amber-400" : "text-muted-foreground")}>{t(dirty ? "unsaved" : needsRestart ? "restart_required" : "apply_on_start")}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" disabled={!draft || !running || dirty || saving || restarting} onClick={() => {
|
||||
if ((server?.playersOnline ?? 0) > 0 || server?.playerCountUnknown) setConfirmRestart(true);
|
||||
else void restart().catch((error) => setMessage({ kind: "error", text: humanizeError(error) }));
|
||||
}}><RotateCw className={cn(restarting && "animate-spin")} />{t(restarting ? "restarting" : "restart")}</Button>
|
||||
<Button onClick={() => void save()} disabled={!draft || !dirty || saving || restarting || !experienceValid(draft.values, groups)}><Save className="h-4 w-4" />{t(saving ? "saving" : "save")}</Button>
|
||||
</div>
|
||||
</>}
|
||||
</div>
|
||||
<ConfirmDialog open={confirmRestart} onOpenChange={setConfirmRestart} title={t("restart_title")} description={t("restart_hint")} confirmLabel={t("restart")} onConfirm={restart} />
|
||||
</div>
|
||||
);
|
||||
@@ -236,35 +238,32 @@ export function LobbyPage() {
|
||||
))}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{t(`${name}_description`)}</p>
|
||||
{status.loading && !data && <Loading />}
|
||||
{status.error != null && <ErrorState error={humanizeError(status.error)} onRetry={status.reload} />}
|
||||
{data && <>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-border bg-card p-4">
|
||||
<div className="min-w-0 space-y-2"><div className="flex flex-wrap items-center gap-3"><h2 className="font-semibold">{data.displayName || t(name)}</h2><PhaseBadge phase={shownPhase(data)} /></div><p className="break-all text-xs text-muted-foreground">{data.image}</p></div>
|
||||
<PowerButton name={name} phase={data.phase} desiredState={data.desiredState} failed={startFailure(data) !== null} playersOnline={data.playersOnline} playerCountUnknown={data.playerCountUnknown} onChanged={status.reload} />
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-border bg-card p-4">
|
||||
<div className="min-w-0 space-y-2"><div className="flex flex-wrap items-center gap-3"><h2 className="font-semibold">{data?.displayName || t(name)}</h2>{data ? <PhaseBadge phase={shownPhase(data)} /> : status.loading && <span role="status" className="flex items-center gap-2 text-xs text-muted-foreground"><Loader2 className="h-3.5 w-3.5 animate-spin" />{t("common:loading")}</span>}</div><p className="break-all text-xs text-muted-foreground">{data?.image || "\u00a0"}</p></div>
|
||||
{data && <PowerButton name={name} phase={data.phase} desiredState={data.desiredState} failed={startFailure(data) !== null} playersOnline={data.playersOnline} playerCountUnknown={data.playerCountUnknown} onChanged={status.reload} />}
|
||||
</div>
|
||||
<div className="grid items-start gap-5 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
|
||||
<ExperienceSettings key={name} name={name} server={data} onDirtyChange={setDirty} onChanged={status.reload} />
|
||||
<div className="space-y-4">
|
||||
<Card className="overflow-hidden rounded-xl shadow-none">
|
||||
<CardHeader className="border-b border-border/60 bg-muted/20 py-4"><CardTitle className="text-sm">{t("content_tools")}</CardTitle></CardHeader>
|
||||
<CardContent className="divide-y divide-border/60 p-0">
|
||||
{links.map(({ icon: Icon, title, hint, to }) => (
|
||||
<Link key={title} to={to} className="group flex gap-3 p-4 transition-colors hover:bg-muted/40">
|
||||
<Icon className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="flex items-center justify-between gap-2 text-sm font-medium">{t(title)}<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" /></p>
|
||||
<p className="mt-1.5 text-xs leading-relaxed text-muted-foreground">{t(hint)}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{data && <EditServerDialog serverName={name} systemService currentDisplayName={data.displayName} currentPolicy={data.autostartPolicy} currentImage={data.image} currentMemory={data.memory} currentStorage={data.storageSize} currentCpu={data.cpu} currentIdleStopSeconds={0} onUpdated={status.reload} />}
|
||||
{name === "lobby" && <BuilderAccess ready={data?.ready ?? false} />}
|
||||
</div>
|
||||
<div className="grid items-start gap-5 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
|
||||
<ExperienceSettings key={name} name={name} server={data} onDirtyChange={setDirty} onChanged={status.reload} />
|
||||
<div className="space-y-4">
|
||||
<Card className="overflow-hidden rounded-xl shadow-none">
|
||||
<CardHeader className="border-b border-border/60 bg-muted/20 py-4"><CardTitle className="text-sm">{t("content_tools")}</CardTitle></CardHeader>
|
||||
<CardContent className="divide-y divide-border/60 p-0">
|
||||
{links.map(({ icon: Icon, title, hint, to }) => (
|
||||
<Link key={title} to={to} className="group flex gap-3 p-4 transition-colors hover:bg-muted/40">
|
||||
<Icon className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="flex items-center justify-between gap-2 text-sm font-medium">{t(title)}<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" /></p>
|
||||
<p className="mt-1.5 text-xs leading-relaxed text-muted-foreground">{t(hint)}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<EditServerDialog serverName={name} systemService currentDisplayName={data.displayName} currentPolicy={data.autostartPolicy} currentImage={data.image} currentMemory={data.memory} currentStorage={data.storageSize} currentCpu={data.cpu} currentIdleStopSeconds={0} onUpdated={status.reload} />
|
||||
{name === "lobby" && <BuilderAccess ready={data.ready} />}
|
||||
</div>
|
||||
</div>
|
||||
</>}
|
||||
</div>
|
||||
<ConfirmDialog open={nextSpace !== null} onOpenChange={(open) => { if (!open) setNextSpace(null); }} title={t("discard_title")} description={t("discard_hint")} confirmLabel={t("discard")} onConfirm={async () => { if (nextSpace) { setParams({ space: nextSpace }); setDirty(false); setNextSpace(null); } }} />
|
||||
</div>;
|
||||
}
|
||||
Reference in new issue
Block a user