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
+223 -63

No files matched your search

+1
View File
@@ -31,6 +31,7 @@ for (const [account, path] of [
["owner", "/"],
["owner", "/servers"],
["owner", "/admin/lobby?space=login"],
["owner", "/admin/auth-sources"],
["owner", "/admin/images"],
["owner", "/admin/builds"],
["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.getByLabel(t("authSources:tag"))).toHaveValue("littleskin");
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.getByLabel(t("authSources:tag")).nth(1).fill("custom");
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 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("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 expect(page.getByText(t("authSources:saved"), { exact: true })).toBeVisible();
await page.reload();
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);
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"]);
+12
View File
@@ -36,6 +36,18 @@ export const test = base.extend<{ signIn: (account: Account) => Promise<void> }>
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
* 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
+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 }) => {
await signIn("linked");
@@ -71,3 +71,12 @@ test("authentication source drafts with long IDs fit a phone", async ({ page, si
await expectFitsScreen(page);
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 }) => {
await signIn("owner");
+18 -1
View File
@@ -1,9 +1,11 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach } from "vitest";
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 { AppShell } from "./AppShell";
import { PageHeader } from "./PageHeader";
import type { Identity } from "@/lib/types";
const tier = vi.hoisted(() => ({
@@ -42,6 +44,21 @@ function roleLine(email: string) {
}
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([
["en-US", "owner", "Owner"],
["en-US", "user", "User"],
+33 -28
View File
@@ -16,6 +16,7 @@ import { ErrorBoundary } from "@/components/ErrorBoundary";
import { Loading } from "@/components/States";
import { ROLE_LABEL_KEY } from "@/components/RoleBadge";
import { FelisLogo } from "@/components/FelisLogo";
import { PageHeaderHostContext } from "@/components/PageHeader";
function SectionGroup({
section,
@@ -242,6 +243,7 @@ function MobileNav({ sections }: { sections: NavSection[] }) {
}
export function AppShell() {
const [headerHost, setHeaderHost] = useState<HTMLDivElement | null>(null);
const { isAdmin, isOwner } = useTier();
const { pathname } = useLocation();
const { t, i18n } = useTranslation("navigation");
@@ -291,34 +293,37 @@ export function AppShell() {
</div>
</aside>
<div className="flex min-w-0 flex-1 flex-col h-full overflow-y-auto">
<ConnectionBanner />
<ConfigBanner />
<header className="flex h-14 shrink-0 items-center justify-between border-b border-border px-4 md:hidden">
<div className="flex items-center gap-2">
<MobileNav sections={sections} />
<FelisLogo />
<span className="font-semibold">{t("common:brand_name")}</span>
</div>
<div className="flex items-center gap-2">
<LangToggle />
<ThemeToggle />
</div>
</header>
<main className="flex flex-1 flex-col p-4 md:p-6">
<div className="mx-auto flex w-full max-w-8xl flex-1 flex-col gap-6">
{/* A crash on one page leaves the navigation usable; moving to
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,
and a chunk gone after a deploy lands in the boundary. */}
<ErrorBoundary resetKey={pathname}>
<Suspense fallback={<Loading />}>
<Outlet />
</Suspense>
</ErrorBoundary>
</div>
</main>
</div>
<PageHeaderHostContext.Provider value={headerHost}>
<div className="flex min-w-0 flex-1 flex-col h-full">
<ConnectionBanner />
<ConfigBanner />
<header className="flex h-14 shrink-0 items-center justify-between border-b border-border px-4 md:hidden">
<div className="flex items-center gap-2">
<MobileNav sections={sections} />
<FelisLogo />
<span className="font-semibold">{t("common:brand_name")}</span>
</div>
<div className="flex items-center gap-2">
<LangToggle />
<ThemeToggle />
</div>
</header>
<div ref={setHeaderHost} className="shrink-0 border-b border-border bg-background p-4 empty:hidden md:p-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
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,
and a chunk gone after a deploy lands in the boundary. */}
<ErrorBoundary resetKey={pathname}>
<Suspense fallback={<Loading />}>
<Outlet />
</Suspense>
</ErrorBoundary>
</div>
</main>
</div>
</PageHeaderHostContext.Provider>
</div>
);
}
+9 -4
View File
@@ -1,7 +1,10 @@
import React from "react";
import { createPortal } from "react-dom";
import type { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
export const PageHeaderHostContext = React.createContext<HTMLElement | null>(null);
export interface PageHeaderProps {
icon?: LucideIcon | React.ReactNode;
title: React.ReactNode;
@@ -11,9 +14,10 @@ export interface PageHeaderProps {
}
export function PageHeader({ icon: Icon, title, subtitle, actions, className }: PageHeaderProps) {
return (
<div className={cn("flex flex-wrap items-center justify-between gap-3", className)}>
<div className="flex items-center gap-3">
const host = React.useContext(PageHeaderHostContext);
const header = (
<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 && (
<div className="shrink-0 text-primary">
{React.isValidElement(Icon)
@@ -23,7 +27,7 @@ export function PageHeader({ icon: Icon, title, subtitle, actions, className }:
})}
</div>
)}
<div className="text-left">
<div className="min-w-0 text-left">
<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>}
</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>}
</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",
"loading": "Loading authentication sources…",
"enabled": "Enabled",
"expand": "Expand {{name}}",
"collapse": "Collapse {{name}}",
"move_up": "Move source up",
"move_down": "Move source down",
"remove": "Remove unsaved source",
@@ -10,6 +10,8 @@
"new_source": "新认证源",
"loading": "正在加载认证源…",
"enabled": "启用",
"expand": "展开 {{name}}",
"collapse": "收起 {{name}}",
"move_up": "上移认证源",
"move_down": "下移认证源",
"remove": "移除未保存的认证源",
+3 -3
View File
@@ -263,7 +263,7 @@ export function ServerConsole() {
const failure = data ? startFailure(data) : null;
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>
<BackLink to="/servers" label={t("my_servers_breadcrumb")} />
</div>
@@ -301,7 +301,7 @@ export function ServerConsole() {
<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 */}
<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">
@@ -336,7 +336,7 @@ export function ServerConsole() {
</div>
{/* 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 && (
<EditServerDialog
serverName={name}
+35 -3
View File
@@ -13,7 +13,7 @@ const settings = (sources = [source()]): AuthSourcesSettings => ({ sources, revi
function page() { render(<MemoryRouter><AuthSourcesPage /></MemoryRouter>); }
const button = (name: string) => screen.getByRole("button", { name });
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(() => {
vi.clearAllMocks();
@@ -23,6 +23,32 @@ beforeEach(() => {
});
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 () => {
let resolve!: (settings: AuthSourcesSettings) => void;
calls.getAuthSources.mockReturnValue(new Promise((r) => { resolve = r; }));
@@ -37,8 +63,11 @@ describe("AuthSourcesPage", () => {
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 () => {
page(); await ready();
page(); await screen.findByRole("button", { name: "Expand littleskin" });
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" } });
expect(field("Permanent source ID", 1).readOnly).toBe(false);
expect(button("Remove unsaved source")).toBeTruthy();
@@ -57,11 +86,14 @@ describe("AuthSourcesPage", () => {
calls.getAuthSources.mockResolvedValue(settings([source(), source("custom", "CS")]));
page(); await ready();
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");
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 waitFor(() => expect(calls.setAuthSources).toHaveBeenCalledWith([{ ...source("custom", "CS"), enabled: false }, source()], "original"));
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" } });
await userEvent.click(button("Discard changes"));
expect(field("Name collision prefix").value).toBe("CS");
+21 -13
View File
@@ -1,5 +1,5 @@
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 { PageHeader } from "@/components/PageHeader";
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 { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { api, humanizeError } from "@/lib/api";
import { useAsync, useUnsavedGuard } from "@/lib/hooks";
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));
}
type SourceDraft = AuthSourceConfig & { locked?: boolean };
type SourceDraft = AuthSourceConfig & { locked?: boolean; expanded?: boolean };
type Draft = Omit<AuthSourcesSettings, "sources"> & { sources: SourceDraft[]; original: string };
const wireSources = (sources: SourceDraft[]): AuthSourceConfig[] => sources.map(({ locked: _locked, ...source }) => source);
const loadedDraft = (settings: AuthSourcesSettings): Draft => ({ ...settings, sources: settings.sources.map((source) => ({ ...source, locked: true })), original: JSON.stringify(settings.sources) });
const wireSources = (sources: SourceDraft[]): AuthSourceConfig[] => sources.map(({ locked: _locked, expanded: _expanded, ...source }) => source);
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() {
const { t } = useTranslation("authSources");
@@ -41,7 +42,7 @@ export function AuthSourcesPage() {
const [checks, setChecks] = useState<Record<number, { kind: "success" | "error"; text: string }>>({});
const [message, setMessage] = useState<{ kind: "success" | "error"; text: string } | null>(null);
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]);
const dirty = draft !== null && JSON.stringify(wireSources(draft.sources)) !== draft.original;
useUnsavedGuard(dirty);
@@ -58,7 +59,7 @@ export function AuthSourcesPage() {
function discard() {
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({});
setMessage(null);
}
@@ -69,7 +70,7 @@ export function AuthSourcesPage() {
setMessage(null);
try {
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") });
} catch (error) {
if (!isReauthCancelled(error)) setMessage({ kind: "error", text: humanizeError(error) });
@@ -87,7 +88,7 @@ export function AuthSourcesPage() {
} 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")} />
<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" />
@@ -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 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 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>
{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">
{(draft?.sources ?? [emptySource()]).map((source, index) => {
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 move = (offset: number) => {
if (!draft) return;
@@ -113,16 +115,22 @@ export function AuthSourcesPage() {
edit(sources);
};
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">
<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>
<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")}>
<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">
<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_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>}
</div>
</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="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>
+3 -7
View File
@@ -4,6 +4,7 @@ import { BookOpen, ChevronRight, DoorOpen, FolderOpen, Globe, Loader2, Map, Mess
import { useTranslation } from "react-i18next";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { PageHeader } from "@/components/PageHeader";
import { Switch } from "@/components/ui/switch";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -131,12 +132,7 @@ function ExperienceSettings({ name, server, onDirtyChange, onChanged }: { name:
{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">
<span>{t(field.key)}</span>
<span className="relative shrink-0">
<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>
<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} />
</label>
))}
</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: 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")} />
<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">