fix(panel): unify fixed headers and collapsible auth sources

This commit is contained in:
Lemon-miaow committed 2026-10-05 15:43:57 +08:00
1 parent 34b450bf2c
commit d7ae1e66c1
15 files changed
+198 -38

No files matched your search

+1
View File
@@ -31,6 +31,7 @@ for (const [account, path] of [
["owner", "/"], ["owner", "/"],
["owner", "/servers"], ["owner", "/servers"],
["owner", "/admin/lobby?space=login"], ["owner", "/admin/lobby?space=login"],
["owner", "/admin/auth-sources"],
["owner", "/admin/images"], ["owner", "/admin/images"],
["owner", "/admin/builds"], ["owner", "/admin/builds"],
["owner", "/admin/submissions"], ["owner", "/admin/submissions"],
+3 -2
View File
@@ -7,6 +7,7 @@ test("Owner manages durable authentication sources from the sidebar", async ({ p
await expect(page).toHaveURL(/\/admin\/auth-sources$/); await expect(page).toHaveURL(/\/admin\/auth-sources$/);
await expect(page.getByLabel(t("authSources:tag"))).toHaveValue("littleskin"); await expect(page.getByLabel(t("authSources:tag"))).toHaveValue("littleskin");
await expect(page.getByLabel(t("authSources:tag"))).toHaveAttribute("readonly", ""); await expect(page.getByLabel(t("authSources:tag"))).toHaveAttribute("readonly", "");
await page.getByRole("button", { name: t("authSources:expand", { name: "littleskin" }) }).click();
await page.getByRole("button", { name: t("authSources:add"), exact: true }).click(); await page.getByRole("button", { name: t("authSources:add"), exact: true }).click();
await page.getByLabel(t("authSources:tag")).nth(1).fill("custom"); await page.getByLabel(t("authSources:tag")).nth(1).fill("custom");
await page.getByLabel(t("authSources:prefix")).nth(1).fill("CS"); await page.getByLabel(t("authSources:prefix")).nth(1).fill("CS");
@@ -14,12 +15,12 @@ test("Owner manages durable authentication sources from the sidebar", async ({ p
await page.getByRole("button", { name: t("authSources:test"), exact: true }).nth(1).click(); await page.getByRole("button", { name: t("authSources:test"), exact: true }).nth(1).click();
await expect(page.getByText(t("authSources:test_ok", { ms: 20 }), { exact: true })).toBeVisible(); await expect(page.getByText(t("authSources:test_ok", { ms: 20 }), { exact: true })).toBeVisible();
await page.getByRole("button", { name: t("authSources:move_up"), exact: true }).nth(1).click(); await page.getByRole("button", { name: t("authSources:move_up"), exact: true }).nth(1).click();
await page.getByRole("checkbox", { name: t("authSources:enabled"), exact: true }).nth(1).uncheck(); await page.getByRole("switch", { name: t("authSources:enabled"), exact: true }).nth(1).uncheck();
await page.getByRole("button", { name: t("authSources:save"), exact: true }).click(); await page.getByRole("button", { name: t("authSources:save"), exact: true }).click();
await expect(page.getByText(t("authSources:saved"), { exact: true })).toBeVisible(); await expect(page.getByText(t("authSources:saved"), { exact: true })).toBeVisible();
await page.reload(); await page.reload();
await expect(page.getByLabel(t("authSources:tag")).nth(0)).toHaveValue("custom"); await expect(page.getByLabel(t("authSources:tag")).nth(0)).toHaveValue("custom");
await expect(page.getByRole("checkbox", { name: t("authSources:enabled"), exact: true }).nth(1)).not.toBeChecked(); await expect(page.getByRole("switch", { name: t("authSources:enabled"), exact: true }).nth(1)).not.toBeChecked();
await expect(page.getByRole("button", { name: t("authSources:remove"), exact: true })).toHaveCount(0); await expect(page.getByRole("button", { name: t("authSources:remove"), exact: true })).toHaveCount(0);
const available = await page.request.get("/api/v1/account/link/sources"); const available = await page.request.get("/api/v1/account/link/sources");
expect((await available.json()).sources.map((source: { tag: string }) => source.tag)).toEqual(["mojang", "custom"]); expect((await available.json()).sources.map((source: { tag: string }) => source.tag)).toEqual(["mojang", "custom"]);
+12
View File
@@ -36,6 +36,18 @@ export const test = base.extend<{ signIn: (account: Account) => Promise<void> }>
export { expect }; export { expect };
export async function expectPinnedHeading(page: Page, title: string, top: number) {
const heading = page.getByRole("heading", { name: title, exact: true });
const before = (await heading.boundingBox())!;
const pane = page.getByRole("main");
await pane.evaluate((el) => { el.scrollTop = el.scrollHeight; });
await expect.poll(() => pane.evaluate((el) => el.scrollTop)).toBeGreaterThan(100);
const after = (await heading.boundingBox())!;
expect(after.y).toBeCloseTo(before.y, 0);
expect(after.y).toBeGreaterThanOrEqual(top);
await expect(heading).toBeInViewport({ ratio: 1 });
}
/** expectFitsScreen fails when anything scrolls sideways or a visible element /** expectFitsScreen fails when anything scrolls sideways or a visible element
* pokes past the viewport edge (an overflow-hidden parent would just cut it * pokes past the viewport edge (an overflow-hidden parent would just cut it
* off). The shell scrolls in an overflow-y-auto pane, whose computed * off). The shell scrolls in an overflow-y-auto pane, whose computed
+10 -1
View File
@@ -1,4 +1,4 @@
import { test, expect, t, expectFitsScreen } from "./fixtures"; import { test, expect, t, expectFitsScreen, expectPinnedHeading } from "./fixtures";
test("the menu drawer reaches the other sections", async ({ page, signIn }) => { test("the menu drawer reaches the other sections", async ({ page, signIn }) => {
await signIn("linked"); await signIn("linked");
@@ -71,3 +71,12 @@ test("authentication source drafts with long IDs fit a phone", async ({ page, si
await expectFitsScreen(page); await expectFitsScreen(page);
await expect(page.getByRole("button", { name: t("authSources:remove"), exact: true })).toBeVisible(); await expect(page.getByRole("button", { name: t("authSources:remove"), exact: true })).toBeVisible();
}); });
test("the mobile navigation and page heading stay visible above a scrolling form", async ({ page, signIn }) => {
await signIn("owner");
await page.goto("/admin/lobby");
await expect(page.getByLabel(t("lobby:bookTitle"))).toBeEnabled();
await expectPinnedHeading(page, t("lobby:title"), 56);
await expect(page.getByRole("button", { name: t("common:open_menu") })).toBeInViewport({ ratio: 1 });
await expectFitsScreen(page);
});
+54 -1
View File
@@ -1,4 +1,57 @@
import { test, expect, t, expectFitsScreen } from "./fixtures"; import { test, expect, t, expectFitsScreen, expectPinnedHeading } from "./fixtures";
test("admin headings share their size and gutters and stay pinned while content scrolls", async ({ page, signIn }) => {
await signIn("owner");
await page.setViewportSize({ width: 1440, height: 600 });
const metrics = [];
for (const [path, title] of [
["/admin/submissions", "admin:submissions_title"],
["/admin/lobby", "lobby:title"],
["/admin/auth-sources", "authSources:title"],
]) {
await page.goto(path);
const heading = page.getByRole("heading", { name: t(title), exact: true });
await expect(heading).toBeVisible();
metrics.push(await heading.evaluate((el) => ({ x: el.getBoundingClientRect().x, y: el.getBoundingClientRect().y, size: getComputedStyle(el).fontSize })));
expect(await page.getByRole("main").getByRole("heading", { level: 1 }).count()).toBe(0);
if (path === "/admin/auth-sources") {
await page.getByRole("button", { name: t("authSources:expand", { name: "littleskin" }) }).click();
await expectPinnedHeading(page, t(title), 0);
} else if (path === "/admin/lobby") {
await expect(page.getByLabel(t("lobby:bookTitle"))).toBeEnabled();
await expectPinnedHeading(page, t(title), 0);
}
}
expect(metrics[1]).toEqual(metrics[0]);
expect(metrics[2]).toEqual(metrics[0]);
});
test("saved authentication sources start closed and switches leave them closed", async ({ page, signIn }) => {
await signIn("owner");
await page.goto("/admin/auth-sources");
const expand = page.getByRole("button", { name: t("authSources:expand", { name: "littleskin" }) });
await expect(expand).toHaveAttribute("aria-expanded", "false");
await expect(page.getByLabel(t("authSources:prefix"))).toBeHidden();
const enabled = page.getByRole("switch", { name: t("authSources:enabled"), exact: true });
await enabled.focus();
await page.keyboard.press("Space");
await expect(enabled).toHaveAttribute("aria-checked", "false");
await expect(expand).toHaveAttribute("aria-expanded", "false");
await page.getByRole("button", { name: t("authSources:save"), exact: true }).click();
await expect(page.getByText(t("authSources:saved"), { exact: true })).toBeVisible();
await expand.click();
await expect(page.getByLabel(t("authSources:prefix"))).toBeVisible();
});
test("the console keeps a bounded log pane below the fixed heading", async ({ page, signIn }) => {
await signIn("owner");
await page.setViewportSize({ width: 1440, height: 960 });
await page.goto("/servers/lobby");
await expect(page.getByRole("textbox", { name: t("servers:command_label") })).toBeInViewport({ ratio: 1 });
await expect(page.getByRole("button", { name: t("servers:log_clear_btn") })).toBeInViewport({ ratio: 1 });
await expect(page.getByRole("heading", { name: "Hub Lobby", exact: true })).toBeInViewport({ ratio: 1 });
expect(await page.getByRole("main").evaluate((el) => el.scrollTop)).toBe(0);
});
test("space settings keep the form visible while reading configuration", async ({ page, signIn }) => { test("space settings keep the form visible while reading configuration", async ({ page, signIn }) => {
await signIn("owner"); await signIn("owner");
+18 -1
View File
@@ -1,9 +1,11 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import { describe, it, expect, vi, afterEach } from "vitest"; import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom"; import userEvent from "@testing-library/user-event";
import { Link, MemoryRouter, Route, Routes } from "react-router-dom";
import i18next from "i18next"; import i18next from "i18next";
import { AppShell } from "./AppShell"; import { AppShell } from "./AppShell";
import { PageHeader } from "./PageHeader";
import type { Identity } from "@/lib/types"; import type { Identity } from "@/lib/types";
const tier = vi.hoisted(() => ({ const tier = vi.hoisted(() => ({
@@ -42,6 +44,21 @@ function roleLine(email: string) {
} }
describe("AppShell user card", () => { describe("AppShell user card", () => {
it("keeps page headings and actions outside the scrolling content and replaces them on navigation", async () => {
const action = vi.fn();
render(<MemoryRouter><Routes><Route element={<AppShell />}>
<Route path="/" element={<div><PageHeader title="First page" actions={<button onClick={action}>Header action</button>} /><Link to="/next">Next page</Link></div>} />
<Route path="/next" element={<div><PageHeader title="Second page" /><p>Second content</p></div>} />
</Route></Routes></MemoryRouter>);
expect(screen.getByRole("main").contains(screen.getByRole("heading", { name: "First page" }))).toBe(false);
await userEvent.click(screen.getByRole("button", { name: "Header action" }));
expect(action).toHaveBeenCalledOnce();
await userEvent.click(screen.getByRole("link", { name: "Next page" }));
expect(screen.queryByRole("heading", { name: "First page" })).toBeNull();
expect(screen.queryByRole("button", { name: "Header action" })).toBeNull();
expect(screen.getByRole("main").contains(screen.getByRole("heading", { name: "Second page" }))).toBe(false);
expect(screen.getByRole("main").textContent).toContain("Second content");
});
it.each([ it.each([
["en-US", "owner", "Owner"], ["en-US", "owner", "Owner"],
["en-US", "user", "User"], ["en-US", "user", "User"],
+8 -3
View File
@@ -16,6 +16,7 @@ import { ErrorBoundary } from "@/components/ErrorBoundary";
import { Loading } from "@/components/States"; import { Loading } from "@/components/States";
import { ROLE_LABEL_KEY } from "@/components/RoleBadge"; import { ROLE_LABEL_KEY } from "@/components/RoleBadge";
import { FelisLogo } from "@/components/FelisLogo"; import { FelisLogo } from "@/components/FelisLogo";
import { PageHeaderHostContext } from "@/components/PageHeader";
function SectionGroup({ function SectionGroup({
section, section,
@@ -242,6 +243,7 @@ function MobileNav({ sections }: { sections: NavSection[] }) {
} }
export function AppShell() { export function AppShell() {
const [headerHost, setHeaderHost] = useState<HTMLDivElement | null>(null);
const { isAdmin, isOwner } = useTier(); const { isAdmin, isOwner } = useTier();
const { pathname } = useLocation(); const { pathname } = useLocation();
const { t, i18n } = useTranslation("navigation"); const { t, i18n } = useTranslation("navigation");
@@ -291,7 +293,8 @@ export function AppShell() {
</div> </div>
</aside> </aside>
<div className="flex min-w-0 flex-1 flex-col h-full overflow-y-auto"> <PageHeaderHostContext.Provider value={headerHost}>
<div className="flex min-w-0 flex-1 flex-col h-full">
<ConnectionBanner /> <ConnectionBanner />
<ConfigBanner /> <ConfigBanner />
<header className="flex h-14 shrink-0 items-center justify-between border-b border-border px-4 md:hidden"> <header className="flex h-14 shrink-0 items-center justify-between border-b border-border px-4 md:hidden">
@@ -305,8 +308,9 @@ export function AppShell() {
<ThemeToggle /> <ThemeToggle />
</div> </div>
</header> </header>
<main className="flex flex-1 flex-col p-4 md:p-6"> <div ref={setHeaderHost} className="shrink-0 border-b border-border bg-background p-4 empty:hidden md:p-6" />
<div className="mx-auto flex w-full max-w-8xl flex-1 flex-col gap-6"> <main className="flex min-h-0 flex-1 flex-col overflow-y-auto p-4 md:p-6">
<div className="mx-auto flex min-h-0 w-full max-w-8xl flex-1 flex-col gap-6">
{/* A crash on one page leaves the navigation usable; moving to {/* A crash on one page leaves the navigation usable; moving to
another route clears it. Pages load as their own chunks, so the another route clears it. Pages load as their own chunks, so the
first visit to one shows a spinner here with the shell in place, first visit to one shows a spinner here with the shell in place,
@@ -319,6 +323,7 @@ export function AppShell() {
</div> </div>
</main> </main>
</div> </div>
</PageHeaderHostContext.Provider>
</div> </div>
); );
} }
+9 -4
View File
@@ -1,7 +1,10 @@
import React from "react"; import React from "react";
import { createPortal } from "react-dom";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export const PageHeaderHostContext = React.createContext<HTMLElement | null>(null);
export interface PageHeaderProps { export interface PageHeaderProps {
icon?: LucideIcon | React.ReactNode; icon?: LucideIcon | React.ReactNode;
title: React.ReactNode; title: React.ReactNode;
@@ -11,9 +14,10 @@ export interface PageHeaderProps {
} }
export function PageHeader({ icon: Icon, title, subtitle, actions, className }: PageHeaderProps) { export function PageHeader({ icon: Icon, title, subtitle, actions, className }: PageHeaderProps) {
return ( const host = React.useContext(PageHeaderHostContext);
<div className={cn("flex flex-wrap items-center justify-between gap-3", className)}> const header = (
<div className="flex items-center gap-3"> <div className={cn("mx-auto flex w-full max-w-8xl flex-wrap items-center justify-between gap-3", className, host && "my-0")}>
<div className="flex min-w-0 max-w-full items-center gap-3">
{Icon && ( {Icon && (
<div className="shrink-0 text-primary"> <div className="shrink-0 text-primary">
{React.isValidElement(Icon) {React.isValidElement(Icon)
@@ -23,7 +27,7 @@ export function PageHeader({ icon: Icon, title, subtitle, actions, className }:
})} })}
</div> </div>
)} )}
<div className="text-left"> <div className="min-w-0 text-left">
<h1 className="text-2xl font-semibold tracking-tight text-foreground">{title}</h1> <h1 className="text-2xl font-semibold tracking-tight text-foreground">{title}</h1>
{subtitle && <div className="text-sm text-muted-foreground mt-0.5">{subtitle}</div>} {subtitle && <div className="text-sm text-muted-foreground mt-0.5">{subtitle}</div>}
</div> </div>
@@ -31,4 +35,5 @@ export function PageHeader({ icon: Icon, title, subtitle, actions, className }:
{actions && <div className="flex items-center gap-3 shrink-0">{actions}</div>} {actions && <div className="flex items-center gap-3 shrink-0">{actions}</div>}
</div> </div>
); );
return host ? createPortal(header, host) : header;
} }
+17
View File
@@ -0,0 +1,17 @@
import * as React from "react";
import { cn } from "@/lib/utils";
interface SwitchProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onChange" | "onClick"> {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
}
export const Switch = React.forwardRef<HTMLButtonElement, SwitchProps>(
({ checked, onCheckedChange, className, ...props }, ref) => (
<button {...props} ref={ref} type="button" role="switch" aria-checked={checked} onClick={() => onCheckedChange(!checked)}
className={cn("inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full p-0.5 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50", checked ? "bg-primary" : "bg-muted-foreground/25", className)}>
<span aria-hidden="true" className={cn("h-4 w-4 rounded-full bg-white shadow-sm transition-transform motion-reduce:transition-none", checked ? "translate-x-4" : "translate-x-0")} />
</button>
),
);
Switch.displayName = "Switch";
@@ -10,6 +10,8 @@
"new_source": "New source", "new_source": "New source",
"loading": "Loading authentication sources…", "loading": "Loading authentication sources…",
"enabled": "Enabled", "enabled": "Enabled",
"expand": "Expand {{name}}",
"collapse": "Collapse {{name}}",
"move_up": "Move source up", "move_up": "Move source up",
"move_down": "Move source down", "move_down": "Move source down",
"remove": "Remove unsaved source", "remove": "Remove unsaved source",
@@ -10,6 +10,8 @@
"new_source": "新认证源", "new_source": "新认证源",
"loading": "正在加载认证源…", "loading": "正在加载认证源…",
"enabled": "启用", "enabled": "启用",
"expand": "展开 {{name}}",
"collapse": "收起 {{name}}",
"move_up": "上移认证源", "move_up": "上移认证源",
"move_down": "下移认证源", "move_down": "下移认证源",
"remove": "移除未保存的认证源", "remove": "移除未保存的认证源",
+3 -3
View File
@@ -263,7 +263,7 @@ export function ServerConsole() {
const failure = data ? startFailure(data) : null; const failure = data ? startFailure(data) : null;
return ( return (
<div className="flex flex-col lg:h-[calc(100vh-3.5rem)] lg:min-h-[35rem] gap-4 min-h-0"> <div className="flex flex-col lg:flex-1 lg:min-h-[35rem] gap-4 min-h-0">
<div> <div>
<BackLink to="/servers" label={t("my_servers_breadcrumb")} /> <BackLink to="/servers" label={t("my_servers_breadcrumb")} />
</div> </div>
@@ -301,7 +301,7 @@ export function ServerConsole() {
<RetireNotice name={name} retiring={data.retiring} isAdmin={isAdmin} onChanged={reload} /> <RetireNotice name={name} retiring={data.retiring} isAdmin={isAdmin} onChanged={reload} />
)} )}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-4 flex-1 lg:min-h-0 min-h-0"> <div className="grid grid-cols-1 gap-6 lg:grid-cols-4 lg:grid-rows-[minmax(0,1fr)] flex-1 lg:min-h-0 min-h-0">
{/* Left/Main column: Console */} {/* Left/Main column: Console */}
<div className="lg:col-span-3 flex flex-col lg:min-h-0 min-h-0 h-full"> <div className="lg:col-span-3 flex flex-col lg:min-h-0 min-h-0 h-full">
<Card className="dark flex flex-col flex-1 lg:min-h-0 min-h-0 overflow-hidden bg-black text-zinc-50 border-zinc-800"> <Card className="dark flex flex-col flex-1 lg:min-h-0 min-h-0 overflow-hidden bg-black text-zinc-50 border-zinc-800">
@@ -336,7 +336,7 @@ export function ServerConsole() {
</div> </div>
{/* Right/Sidebar column: Navigation */} {/* Right/Sidebar column: Navigation */}
<div className="flex flex-col gap-4 lg:col-span-1 shrink-0"> <div className="flex flex-col gap-4 lg:col-span-1 lg:min-h-0 lg:overflow-y-auto shrink-0">
{isAdmin && ( {isAdmin && (
<EditServerDialog <EditServerDialog
serverName={name} serverName={name}
+35 -3
View File
@@ -13,7 +13,7 @@ const settings = (sources = [source()]): AuthSourcesSettings => ({ sources, revi
function page() { render(<MemoryRouter><AuthSourcesPage /></MemoryRouter>); } function page() { render(<MemoryRouter><AuthSourcesPage /></MemoryRouter>); }
const button = (name: string) => screen.getByRole("button", { name }); const button = (name: string) => screen.getByRole("button", { name });
const field = (label: string, index = 0) => screen.getAllByLabelText(label)[index] as HTMLInputElement; const field = (label: string, index = 0) => screen.getAllByLabelText(label)[index] as HTMLInputElement;
async function ready() { await waitFor(() => expect(field("Permanent source ID").disabled).toBe(false)); } async function ready() { await userEvent.click(await screen.findByRole("button", { name: "Expand littleskin" })); }
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -23,6 +23,32 @@ beforeEach(() => {
}); });
describe("AuthSourcesPage", () => { describe("AuthSourcesPage", () => {
it("collapses saved sources and retains unsaved edits through keyboard folding", async () => {
page();
const toggle = await screen.findByRole("button", { name: "Expand littleskin" });
expect(screen.queryByRole("textbox")).toBeNull();
toggle.focus();
await userEvent.keyboard("{Enter}");
fireEvent.change(field("Name collision prefix"), { target: { value: "NEW" } });
await userEvent.keyboard("{Enter}");
expect(screen.queryByRole("textbox")).toBeNull();
await userEvent.keyboard(" ");
expect(screen.getByRole("textbox", { name: "Name collision prefix" })).toHaveProperty("value", "NEW");
expect(calls.setAuthSources).not.toHaveBeenCalled();
});
it("toggles a closed source with the keyboard without expanding its fields", async () => {
page();
await screen.findByRole("button", { name: "Expand littleskin" });
expect((button("Reload") as HTMLButtonElement).disabled).toBe(false);
const enabled = screen.getByRole("switch", { name: "Enabled" });
enabled.focus();
await userEvent.keyboard(" ");
expect(enabled.getAttribute("aria-checked")).toBe("false");
expect(screen.queryByRole("textbox")).toBeNull();
await userEvent.click(button("Save & apply"));
await waitFor(() => expect(calls.setAuthSources).toHaveBeenCalledWith([{ ...source(), enabled: false }], "original"));
expect(screen.getByRole("button", { name: "Expand littleskin" })).toBeTruthy();
});
it("shows disabled fields while loading, then protects saved IDs", async () => { it("shows disabled fields while loading, then protects saved IDs", async () => {
let resolve!: (settings: AuthSourcesSettings) => void; let resolve!: (settings: AuthSourcesSettings) => void;
calls.getAuthSources.mockReturnValue(new Promise((r) => { resolve = r; })); calls.getAuthSources.mockReturnValue(new Promise((r) => { resolve = r; }));
@@ -37,8 +63,11 @@ describe("AuthSourcesPage", () => {
expect(screen.queryByRole("button", { name: "Remove unsaved source" })).toBeNull(); expect(screen.queryByRole("button", { name: "Remove unsaved source" })).toBeNull();
}); });
it("adds a source, fixes a duplicate ID, saves and locks it without sending UI state", async () => { it("adds a source, fixes a duplicate ID, saves and locks it without sending UI state", async () => {
page(); await ready(); page(); await screen.findByRole("button", { name: "Expand littleskin" });
await userEvent.click(button("Add source")); await userEvent.click(button("Add source"));
expect(screen.getByRole("button", { name: "Expand littleskin" }).getAttribute("aria-expanded")).toBe("false");
expect(screen.getByRole("button", { name: "Collapse New source" }).getAttribute("aria-expanded")).toBe("true");
expect(screen.getByRole("textbox", { name: "Permanent source ID" })).toBe(field("Permanent source ID", 1));
fireEvent.change(field("Permanent source ID", 1), { target: { value: "littleskin" } }); fireEvent.change(field("Permanent source ID", 1), { target: { value: "littleskin" } });
expect(field("Permanent source ID", 1).readOnly).toBe(false); expect(field("Permanent source ID", 1).readOnly).toBe(false);
expect(button("Remove unsaved source")).toBeTruthy(); expect(button("Remove unsaved source")).toBeTruthy();
@@ -57,11 +86,14 @@ describe("AuthSourcesPage", () => {
calls.getAuthSources.mockResolvedValue(settings([source(), source("custom", "CS")])); calls.getAuthSources.mockResolvedValue(settings([source(), source("custom", "CS")]));
page(); await ready(); page(); await ready();
await userEvent.click(screen.getAllByRole("button", { name: "Move source up" })[1]); await userEvent.click(screen.getAllByRole("button", { name: "Move source up" })[1]);
expect(screen.getByRole("button", { name: "Collapse littleskin" }).getAttribute("aria-controls")).toBe("source-fields-1");
expect(screen.getByRole("button", { name: "Expand custom" }).getAttribute("aria-expanded")).toBe("false");
expect(field("Permanent source ID").value).toBe("custom"); expect(field("Permanent source ID").value).toBe("custom");
await userEvent.click(screen.getAllByRole("checkbox", { name: "Enabled" })[0]); await userEvent.click(screen.getAllByRole("switch", { name: "Enabled" })[0]);
await userEvent.click(button("Save & apply")); await userEvent.click(button("Save & apply"));
await waitFor(() => expect(calls.setAuthSources).toHaveBeenCalledWith([{ ...source("custom", "CS"), enabled: false }, source()], "original")); await waitFor(() => expect(calls.setAuthSources).toHaveBeenCalledWith([{ ...source("custom", "CS"), enabled: false }, source()], "original"));
await waitFor(() => expect((button("Save & apply") as HTMLButtonElement).disabled).toBe(true)); await waitFor(() => expect((button("Save & apply") as HTMLButtonElement).disabled).toBe(true));
await userEvent.click(button("Expand custom"));
fireEvent.change(field("Name collision prefix"), { target: { value: "NEW" } }); fireEvent.change(field("Name collision prefix"), { target: { value: "NEW" } });
await userEvent.click(button("Discard changes")); await userEvent.click(button("Discard changes"));
expect(field("Name collision prefix").value).toBe("CS"); expect(field("Name collision prefix").value).toBe("CS");
+21 -13
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { ArrowDown, ArrowUp, Check, Loader2, Plus, RefreshCw, Save, ShieldCheck, Trash2 } from "lucide-react"; import { ArrowDown, ArrowUp, Check, ChevronRight, Loader2, Plus, RefreshCw, Save, ShieldCheck, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { PageHeader } from "@/components/PageHeader"; import { PageHeader } from "@/components/PageHeader";
import { InlineError, MessageLine } from "@/components/MessageLine"; import { InlineError, MessageLine } from "@/components/MessageLine";
@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { api, humanizeError } from "@/lib/api"; import { api, humanizeError } from "@/lib/api";
import { useAsync, useUnsavedGuard } from "@/lib/hooks"; import { useAsync, useUnsavedGuard } from "@/lib/hooks";
import type { AuthSourceConfig, AuthSourcesSettings } from "@/lib/types"; import type { AuthSourceConfig, AuthSourcesSettings } from "@/lib/types";
@@ -26,10 +27,10 @@ function validSource(source: AuthSourceConfig): boolean {
/^[a-z0-9]{1,4}$/i.test(source.prefix) && endpoint(source.url) && (!source.api_url || endpoint(source.api_url)); /^[a-z0-9]{1,4}$/i.test(source.prefix) && endpoint(source.url) && (!source.api_url || endpoint(source.api_url));
} }
type SourceDraft = AuthSourceConfig & { locked?: boolean }; type SourceDraft = AuthSourceConfig & { locked?: boolean; expanded?: boolean };
type Draft = Omit<AuthSourcesSettings, "sources"> & { sources: SourceDraft[]; original: string }; type Draft = Omit<AuthSourcesSettings, "sources"> & { sources: SourceDraft[]; original: string };
const wireSources = (sources: SourceDraft[]): AuthSourceConfig[] => sources.map(({ locked: _locked, ...source }) => source); const wireSources = (sources: SourceDraft[]): AuthSourceConfig[] => sources.map(({ locked: _locked, expanded: _expanded, ...source }) => source);
const loadedDraft = (settings: AuthSourcesSettings): Draft => ({ ...settings, sources: settings.sources.map((source) => ({ ...source, locked: true })), original: JSON.stringify(settings.sources) }); const loadedDraft = (settings: AuthSourcesSettings, previous: SourceDraft[] = []): Draft => ({ ...settings, sources: settings.sources.map((source) => ({ ...source, locked: true, expanded: previous.find((s) => s.tag === source.tag)?.expanded ?? false })), original: JSON.stringify(settings.sources) });
export function AuthSourcesPage() { export function AuthSourcesPage() {
const { t } = useTranslation("authSources"); const { t } = useTranslation("authSources");
@@ -41,7 +42,7 @@ export function AuthSourcesPage() {
const [checks, setChecks] = useState<Record<number, { kind: "success" | "error"; text: string }>>({}); const [checks, setChecks] = useState<Record<number, { kind: "success" | "error"; text: string }>>({});
const [message, setMessage] = useState<{ kind: "success" | "error"; text: string } | null>(null); const [message, setMessage] = useState<{ kind: "success" | "error"; text: string } | null>(null);
useEffect(() => { useEffect(() => {
if (query.data) setDraft((current) => current && JSON.stringify(wireSources(current.sources)) !== current.original ? current : loadedDraft(query.data!)); if (query.data) setDraft((current) => current && JSON.stringify(wireSources(current.sources)) !== current.original ? current : loadedDraft(query.data!, current?.sources));
}, [query.data]); }, [query.data]);
const dirty = draft !== null && JSON.stringify(wireSources(draft.sources)) !== draft.original; const dirty = draft !== null && JSON.stringify(wireSources(draft.sources)) !== draft.original;
useUnsavedGuard(dirty); useUnsavedGuard(dirty);
@@ -58,7 +59,7 @@ export function AuthSourcesPage() {
function discard() { function discard() {
if (!draft) return; if (!draft) return;
setDraft(loadedDraft({ ...draft, sources: JSON.parse(draft.original) as AuthSourceConfig[] })); setDraft(loadedDraft({ ...draft, sources: JSON.parse(draft.original) as AuthSourceConfig[] }, draft.sources));
setChecks({}); setChecks({});
setMessage(null); setMessage(null);
} }
@@ -69,7 +70,7 @@ export function AuthSourcesPage() {
setMessage(null); setMessage(null);
try { try {
const saved = await reauth.guard(() => api.setAuthSources(wireSources(draft.sources), draft.revision)); const saved = await reauth.guard(() => api.setAuthSources(wireSources(draft.sources), draft.revision));
setDraft(loadedDraft(saved)); setDraft((current) => loadedDraft(saved, current?.sources));
setMessage({ kind: "success", text: t("saved") }); setMessage({ kind: "success", text: t("saved") });
} catch (error) { } catch (error) {
if (!isReauthCancelled(error)) setMessage({ kind: "error", text: humanizeError(error) }); if (!isReauthCancelled(error)) setMessage({ kind: "error", text: humanizeError(error) });
@@ -87,7 +88,7 @@ export function AuthSourcesPage() {
} finally { setTesting(null); } } finally { setTesting(null); }
} }
return <div className="mx-auto w-full max-w-4xl space-y-5"> return <div className="space-y-6">
<PageHeader icon={ShieldCheck} title={t("title")} subtitle={t("subtitle")} /> <PageHeader icon={ShieldCheck} title={t("title")} subtitle={t("subtitle")} />
<Card className="rounded-xl shadow-none"><CardContent className="flex items-start gap-3 pt-5"> <Card className="rounded-xl shadow-none"><CardContent className="flex items-start gap-3 pt-5">
<ShieldCheck className="mt-0.5 h-5 w-5 shrink-0 text-primary" /> <ShieldCheck className="mt-0.5 h-5 w-5 shrink-0 text-primary" />
@@ -97,7 +98,7 @@ export function AuthSourcesPage() {
<div><h2 className="text-sm font-semibold">{t("custom_title")}</h2><p className="mt-1 text-xs text-muted-foreground">{t("priority_hint")}</p></div> <div><h2 className="text-sm font-semibold">{t("custom_title")}</h2><p className="mt-1 text-xs text-muted-foreground">{t("priority_hint")}</p></div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button variant="outline" size="sm" disabled={!draft || dirty || busy || query.loading} onClick={query.reload}><RefreshCw className={cn(query.loading && "animate-spin")} />{t("reload")}</Button> <Button variant="outline" size="sm" disabled={!draft || dirty || busy || query.loading} onClick={query.reload}><RefreshCw className={cn(query.loading && "animate-spin")} />{t("reload")}</Button>
<Button size="sm" disabled={!draft || busy || draft.sources.length >= 32} onClick={() => draft && edit([...draft.sources, emptySource()])}><Plus />{t("add")}</Button> <Button size="sm" disabled={!draft || busy || draft.sources.length >= 32} onClick={() => draft && edit([...draft.sources, { ...emptySource(), expanded: true }])}><Plus />{t("add")}</Button>
</div> </div>
</div> </div>
{query.loading && <p role="status" className="flex items-center gap-2 text-sm text-muted-foreground"><Loader2 className="h-4 w-4 animate-spin" />{t("loading")}</p>} {query.loading && <p role="status" className="flex items-center gap-2 text-sm text-muted-foreground"><Loader2 className="h-4 w-4 animate-spin" />{t("loading")}</p>}
@@ -105,6 +106,7 @@ export function AuthSourcesPage() {
<div aria-busy={query.loading} className="space-y-4"> <div aria-busy={query.loading} className="space-y-4">
{(draft?.sources ?? [emptySource()]).map((source, index) => { {(draft?.sources ?? [emptySource()]).map((source, index) => {
const locked = "locked" in source && source.locked === true; const locked = "locked" in source && source.locked === true;
const expanded = "expanded" in source ? source.expanded === true : true;
const set = (patch: Partial<AuthSourceConfig>) => draft && edit(draft.sources.map((s, i) => i === index ? { ...s, ...patch } : s)); const set = (patch: Partial<AuthSourceConfig>) => draft && edit(draft.sources.map((s, i) => i === index ? { ...s, ...patch } : s));
const move = (offset: number) => { const move = (offset: number) => {
if (!draft) return; if (!draft) return;
@@ -113,16 +115,22 @@ export function AuthSourcesPage() {
edit(sources); edit(sources);
}; };
return <Card key={index} className="overflow-hidden rounded-xl shadow-none"> return <Card key={index} className="overflow-hidden rounded-xl shadow-none">
<CardHeader className="flex-row flex-wrap items-center justify-between gap-3 space-y-0 border-b border-border/60 bg-muted/20 py-4"> <CardHeader className={cn("flex-row flex-wrap items-center justify-between gap-3 space-y-0 bg-muted/20 py-4", expanded && "border-b border-border/60")}>
<div className="flex min-w-0 flex-1 items-center gap-3"><span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-xs font-semibold text-primary">{index + 2}</span><CardTitle className="break-all text-sm">{source.tag || t(draft ? "new_source" : "loading")}</CardTitle></div> <CardTitle className="min-w-0 flex-1 text-sm">
<Button variant="ghost" className="h-auto w-full justify-start whitespace-normal p-0 text-left hover:bg-transparent" aria-expanded={expanded} aria-controls={`source-fields-${index}`} aria-label={t(expanded ? "collapse" : "expand", { name: source.tag || t("new_source") })} disabled={!draft} onClick={() => setDraft((current) => current && { ...current, sources: current.sources.map((s, i) => i === index ? { ...s, expanded: !expanded } : s) })}>
<ChevronRight className={cn("transition-transform motion-reduce:transition-none", expanded && "rotate-90")} />
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-xs font-semibold text-primary">{index + 2}</span>
<span className="break-all">{source.tag || t(draft ? "new_source" : "loading")}</span>
</Button>
</CardTitle>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="flex items-center gap-2 text-xs"><input type="checkbox" checked={draft !== null && source.enabled} onChange={(e) => set({ enabled: e.target.checked })} disabled={!draft || busy} className="h-4 w-4 accent-primary" />{t("enabled")}</label> <label className="flex cursor-pointer items-center gap-2 text-xs"><Switch checked={draft !== null && source.enabled} onCheckedChange={(enabled) => set({ enabled })} disabled={!draft || busy} />{t("enabled")}</label>
<Button variant="outline" size="icon" className="h-8 w-8" aria-label={t("move_up")} disabled={!draft || busy || index === 0} onClick={() => move(-1)}><ArrowUp /></Button> <Button variant="outline" size="icon" className="h-8 w-8" aria-label={t("move_up")} disabled={!draft || busy || index === 0} onClick={() => move(-1)}><ArrowUp /></Button>
<Button variant="outline" size="icon" className="h-8 w-8" aria-label={t("move_down")} disabled={!draft || busy || index === draft.sources.length - 1} onClick={() => move(1)}><ArrowDown /></Button> <Button variant="outline" size="icon" className="h-8 w-8" aria-label={t("move_down")} disabled={!draft || busy || index === draft.sources.length - 1} onClick={() => move(1)}><ArrowDown /></Button>
{!locked && <Button variant="outline" size="icon" className="h-8 w-8" aria-label={t("remove")} disabled={!draft || busy} onClick={() => draft && edit(draft.sources.filter((_, i) => i !== index))}><Trash2 /></Button>} {!locked && <Button variant="outline" size="icon" className="h-8 w-8" aria-label={t("remove")} disabled={!draft || busy} onClick={() => draft && edit(draft.sources.filter((_, i) => i !== index))}><Trash2 /></Button>}
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-4 pt-5"> <CardContent id={`source-fields-${index}`} hidden={!expanded} className="space-y-4 pt-5">
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2"><Label htmlFor={`source-tag-${index}`}>{t("tag")}</Label><Input id={`source-tag-${index}`} value={source.tag} readOnly={locked} disabled={!draft || busy} placeholder="littleskin" maxLength={128} onChange={(e) => set({ tag: e.target.value })} /><p className="text-xs text-muted-foreground">{t(locked ? "tag_locked" : "tag_hint")}</p></div> <div className="space-y-2"><Label htmlFor={`source-tag-${index}`}>{t("tag")}</Label><Input id={`source-tag-${index}`} value={source.tag} readOnly={locked} disabled={!draft || busy} placeholder="littleskin" maxLength={128} onChange={(e) => set({ tag: e.target.value })} /><p className="text-xs text-muted-foreground">{t(locked ? "tag_locked" : "tag_hint")}</p></div>
<div className="space-y-2"><Label htmlFor={`source-prefix-${index}`}>{t("prefix")}</Label><Input id={`source-prefix-${index}`} value={source.prefix} disabled={!draft || busy} placeholder="LS" maxLength={4} onChange={(e) => set({ prefix: e.target.value })} /><p className="text-xs text-muted-foreground">{t("prefix_hint")}</p></div> <div className="space-y-2"><Label htmlFor={`source-prefix-${index}`}>{t("prefix")}</Label><Input id={`source-prefix-${index}`} value={source.prefix} disabled={!draft || busy} placeholder="LS" maxLength={4} onChange={(e) => set({ prefix: e.target.value })} /><p className="text-xs text-muted-foreground">{t("prefix_hint")}</p></div>
+3 -7
View File
@@ -4,6 +4,7 @@ import { BookOpen, ChevronRight, DoorOpen, FolderOpen, Globe, Loader2, Map, Mess
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ConfirmDialog } from "@/components/ConfirmDialog"; import { ConfirmDialog } from "@/components/ConfirmDialog";
import { PageHeader } from "@/components/PageHeader"; import { PageHeader } from "@/components/PageHeader";
import { Switch } from "@/components/ui/switch";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -131,12 +132,7 @@ function ExperienceSettings({ name, server, onDirtyChange, onChanged }: { name:
{switches.map((field) => ( {switches.map((field) => (
<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"> <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>{t(field.key)}</span>
<span className="relative shrink-0"> <Switch id={`experience-${field.key}`} checked={draft ? (draft.values[field.key] ?? field.value) as boolean : false} onCheckedChange={(value) => setValue(field.key, value)} disabled={!draft || saving || restarting} />
<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>
</span>
</label> </label>
))} ))}
</div> </div>
@@ -217,7 +213,7 @@ export function LobbyPage() {
{ icon: Terminal, title: "console_title", hint: name === "login" ? "login_console_hint" : "console_hint", to: `/servers/${name}` }, { icon: Terminal, title: "console_title", hint: name === "login" ? "login_console_hint" : "console_hint", to: `/servers/${name}` },
{ icon: Shield, title: "backups_title", hint: "backups_hint", to: `/servers/${name}/backups` }, { icon: Shield, title: "backups_title", hint: "backups_hint", to: `/servers/${name}/backups` },
]; ];
return <div className="mx-auto max-w-7xl space-y-5"> return <div className="space-y-6">
<PageHeader icon={DoorOpen} title={t("title")} subtitle={t("subtitle")} /> <PageHeader icon={DoorOpen} title={t("title")} subtitle={t("subtitle")} />
<Card className="rounded-xl bg-muted/20 shadow-none"><CardContent className="flex flex-wrap items-start justify-between gap-4 pt-5"> <Card className="rounded-xl bg-muted/20 shadow-none"><CardContent className="flex flex-wrap items-start justify-between gap-4 pt-5">
<div className="flex min-w-0 gap-3"> <div className="flex min-w-0 gap-3">