fix(panel): refine login and lobby settings layout

This commit is contained in:
Lemon-miaow committed 2026-10-04 12:56:46 +08:00
1 parent 10bd2ddad0
commit 77c1e7404f
4 files changed
+122 -52

No files matched your search

+8 -1
View File
@@ -81,5 +81,12 @@
"builder_running": "Start the lobby before changing builder access.",
"discard_title": "Discard unsaved settings?",
"discard_hint": "Switching spaces discards your edits. Cancel to save them first.",
"discard": "Discard & switch"
"discard": "Discard & switch",
"appearance_description": "Set the welcome message and server menu players see.",
"players_description": "Choose join behavior, game mode and player protection.",
"world_description": "Adjust time, weather and the lobby game rules.",
"loginBook_description": "Edit the guidance players see in the login book.",
"content_tools": "Content & maintenance",
"unsaved": "You have unsaved changes",
"apply_on_start": "Saved settings apply when this space starts"
}
+8 -1
View File
@@ -81,5 +81,12 @@
"builder_running": "请先启动大厅,才能修改建造权限。",
"discard_title": "放弃未保存的设置?",
"discard_hint": "切换空间会丢失本次编辑。可以取消后先保存。",
"discard": "放弃并切换"
"discard": "放弃并切换",
"appearance_description": "设置玩家加入时看到的欢迎信息与菜单。",
"players_description": "设置加入行为、游戏模式与玩家保护。",
"world_description": "调整大厅的时间、天气和游戏规则。",
"loginBook_description": "编辑玩家打开登录书时看到的引导内容。",
"content_tools": "内容与维护",
"unsaved": "有未保存的更改",
"apply_on_start": "保存的设置将在启动后生效"
}
+1 -1
View File
@@ -50,10 +50,10 @@ export const LOGIN_GROUPS: ExperienceGroup[] = [
{ key: "bookTitle", value: "Felis Login" },
{ key: "bookAuthor", value: "Felis" },
{ key: "bookHeading", value: "Felis 登录 / Login" },
{ key: "loginTimeoutSeconds", value: 600, min: 30, max: 3600 },
{ key: "bookAction", value: "▶ 点此打开登录页\n▶ Open login page", multiline: true },
{ key: "bookHelp", value: "在系统浏览器中完成。\nUse your SYSTEM browser —\nnot WeChat / QQ (passkey\nwon't work there).", multiline: true },
{ key: "openBook", value: true },
{ key: "loginTimeoutSeconds", value: 600, min: 30, max: 3600 },
] },
];
+105 -49
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { DoorOpen, FolderOpen, Map, Package, Save, Shield, Terminal } from "lucide-react";
import { BookOpen, ChevronRight, DoorOpen, FolderOpen, Globe, Map, MessageSquare, Package, Save, Shield, Sun, Terminal, type LucideIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { PageHeader } from "@/components/PageHeader";
@@ -16,9 +16,17 @@ import { ErrorState, Loading } from "@/components/States";
import { InlineError, MessageLine } from "@/components/MessageLine";
import { api, humanizeError } from "@/lib/api";
import { joinAddress } from "@/lib/config";
import { cn } from "@/lib/utils";
import { STATUS_POLL_FAST_MS, useAsync, useConfig, usePolling, useUnsavedGuard } from "@/lib/hooks";
import { experienceValid, LOGIN_GROUPS, LOBBY_GROUPS, readExperience, writeExperience, type Experience } from "@/lib/experience";
const GROUP_ICONS: Record<string, LucideIcon> = {
appearance: MessageSquare,
players: Shield,
world: Sun,
loginBook: BookOpen,
};
function ExperienceSettings({ name, stopped, onDirtyChange }: { name: string; stopped: boolean; onDirtyChange: (dirty: boolean) => void }) {
const { t } = useTranslation("lobby");
const groups = name === "login" ? LOGIN_GROUPS : LOBBY_GROUPS;
@@ -55,43 +63,66 @@ function ExperienceSettings({ name, stopped, onDirtyChange }: { name: string; st
{!stopped && <p className="rounded-md border border-amber-500/30 bg-amber-500/10 p-4 text-sm">{t(name === "login" ? "stop_login" : "stop_lobby")}</p>}
{stopped && query.loading && !draft && <Loading />}
{query.error != null && <ErrorState error={t("read_failed", { reason: humanizeError(query.error) })} onRetry={query.reload} />}
{draft && groups.map((group) => (
<Card key={group.key}>
<CardHeader><CardTitle>{t(group.key)}</CardTitle></CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
{group.fields.map((field) => {
const value = draft.values[field.key] ?? field.value;
const id = `experience-${field.key}`;
const set = (next: unknown) => setDraft({ ...draft, values: { ...draft.values, [field.key]: next } });
if (typeof field.value === "boolean") return (
<label key={id} className="flex min-h-11 items-center gap-3 rounded-md border border-border px-3 text-sm" htmlFor={id}>
<input id={id} type="checkbox" checked={value as boolean} onChange={(e) => set(e.target.checked)} disabled={!stopped || saving} className="h-4 w-4 accent-primary" />
{t(field.key)}
</label>
);
return (
<div key={id} className="grid min-w-0 gap-2">
<Label htmlFor={id}>{t(field.key)}</Label>
{field.choices ? (
<select id={id} value={value as string} onChange={(e) => set(e.target.value)} disabled={!stopped || saving} className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm">
{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={!stopped || saving} className="min-h-24 w-full rounded-md border border-input bg-background p-3 text-sm" />
) : (
<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={!stopped || saving} />
)}
</div>
);
})}
</CardContent>
</Card>
))}
{draft && 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");
return (
<Card key={group.key} className="overflow-hidden rounded-xl shadow-none">
<CardHeader className="flex-row items-center gap-3 space-y-0 border-b border-border/60 bg-muted/20 py-4">
<span className="rounded-lg bg-primary/10 p-2 text-primary"><Icon className="h-4 w-4" /></span>
<div className="space-y-1.5">
<CardTitle className="text-sm">{t(group.key)}</CardTitle>
<p className="text-xs text-muted-foreground">{t(`${group.key}_description`)}</p>
</div>
</CardHeader>
<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 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={!stopped || saving} 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">
{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={!stopped || saving} 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={!stopped || saving} className="h-10 rounded-lg bg-background shadow-none" />
)}
</div>
);
})}
</div>
<div className={cn("grid gap-x-6 border-t border-border/60", switches.length > 1 && "sm:grid-cols-2")}>
{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.values[field.key] ?? field.value) as boolean} onChange={(e) => setDraft({ ...draft, values: { ...draft.values, [field.key]: e.target.checked } })} disabled={!stopped || saving} 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>
))}
</div>
</CardContent>
</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")} />}
<Button onClick={() => void save()} disabled={!stopped || !dirty || saving || !experienceValid(draft.values, groups)}><Save className="h-4 w-4" />{t(saving ? "saving" : "save")}</Button>
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-border bg-card p-3">
<p className="text-xs text-muted-foreground">{t(dirty ? "unsaved" : "apply_on_start")}</p>
<Button onClick={() => void save()} disabled={!stopped || !dirty || saving || !experienceValid(draft.values, groups)}><Save className="h-4 w-4" />{t(saving ? "saving" : "save")}</Button>
</div>
</>}
</div>
);
@@ -114,9 +145,9 @@ function BuilderAccess({ ready }: { ready: boolean }) {
setBusy(false);
}
}
return <Card>
<CardHeader><CardTitle>{t("builder_title")}</CardTitle></CardHeader>
<CardContent className="space-y-3">
return <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("builder_title")}</CardTitle></CardHeader>
<CardContent className="space-y-3 pt-4">
<p className="text-sm text-muted-foreground">{t("builder_hint")}</p>
<Label htmlFor="builder-player">{t("builder_player")}</Label>
<Input id="builder-player" value={player} onChange={(e) => setPlayer(e.target.value)} placeholder="Steve" />
@@ -140,6 +171,11 @@ export function LobbyPage() {
const status = useAsync(() => api.status(name), [name]);
usePolling(status.reload, STATUS_POLL_FAST_MS);
const data = status.data;
function selectSpace(space: string) {
if (space === name) return;
if (dirty) setNextSpace(space);
else setParams({ space });
}
const links = [
{ icon: Map, title: "map_title", hint: name === "login" ? "login_map_hint" : "lobby_map_hint", to: `/servers/${name}/files` },
{ icon: Package, title: "plugins_title", hint: "plugins_hint", to: `/servers/${name}/files?path=plugins` },
@@ -147,31 +183,51 @@ 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="space-y-6">
return <div className="mx-auto max-w-7xl space-y-5">
<PageHeader icon={DoorOpen} title={t("title")} subtitle={t("subtitle")} />
<Card><CardContent className="flex flex-wrap items-center justify-between gap-4 pt-5">
<div className="space-y-1">
<p className="text-sm font-medium">{t("connection_version", { version: cfg?.gameVersion ?? t("unknown_version") })}</p>
{cfg && <CopyAddress address={joinAddress("login", cfg)} />}
<p className="max-w-2xl text-xs text-muted-foreground">{t("version_hint")}</p>
<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">
<Globe className="mt-0.5 h-5 w-5 shrink-0 text-primary" />
<div className="space-y-1">
<p className="text-sm font-medium">{t("connection_version", { version: cfg?.gameVersion ?? t("unknown_version") })}</p>
{cfg && <CopyAddress address={joinAddress("login", cfg)} />}
<p className="max-w-2xl text-xs text-muted-foreground">{t("version_hint")}</p>
</div>
</div>
<Link to="/admin/images" className="text-sm text-primary hover:underline">{t("images_link")}</Link>
<Link to="/admin/images" className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline">{t("images_link")}<ChevronRight className="h-3.5 w-3.5" /></Link>
</CardContent></Card>
<div className="flex flex-wrap gap-2" aria-label={t("space_label")}>
{(["lobby", "login"] as const).map((space) => <Button key={space} variant={name === space ? "default" : "outline"} aria-pressed={name === space} onClick={() => { if (space !== name) { if (dirty) setNextSpace(space); else setParams({ space }); } }}>{t(space)}</Button>)}
<div className="inline-flex gap-1 rounded-lg bg-muted/60 p-1" role="group" aria-label={t("space_label")}>
{(["lobby", "login"] as const).map((space) => (
<Button key={space} variant="ghost" aria-pressed={name === space} onClick={() => selectSpace(space)} className={cn("px-4", name === space ? "bg-card text-primary shadow-sm hover:bg-card" : "text-muted-foreground")}>
{space === "lobby" ? <DoorOpen /> : <BookOpen />}{t(space)}
</Button>
))}
</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-lg border border-border bg-card p-4">
<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>
<div className="grid items-start gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
<div className="grid items-start gap-5 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
<ExperienceSettings key={name} name={name} onDirtyChange={setDirty} stopped={data.phase === "Stopped" && data.desiredState === "Stopped"} />
<div className="space-y-4">
{links.map(({ icon: Icon, title, hint, to }) => <Link key={title} to={to} className="flex gap-3 rounded-lg border border-border bg-card p-4 hover:border-primary/40"><Icon className="mt-0.5 h-5 w-5 shrink-0 text-primary" /><div className="min-w-0"><p className="text-sm font-medium">{t(title)}</p><p className="mt-1 text-xs text-muted-foreground">{t(hint)}</p></div></Link>)}
<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>