Unverified Commit 77c1e740 authored by Lemon-miaow's avatar Lemon-miaow
Browse files

fix(panel): refine login and lobby settings layout

parent 10bd2dda
Loading
Loading
Loading
Loading
+8 −1
Changes for panel/src/i18n/resources/en-US/lobby.json: 8 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -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
Changes for panel/src/i18n/resources/zh-CN/lobby.json: 8 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -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
Changes for panel/src/lib/experience.ts: 1 added line, 1 removed line.
Original line number Diff line number Diff line
@@ -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 },
  ] },
];

+85 −29
Changes for panel/src/pages/admin/LobbyPage.tsx: 85 added lines, 29 removed lines.
Original line number Diff line number Diff line
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) => {
      {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 } });
              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>
                    <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-md border border-input bg-background px-3 text-sm">
                        <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="min-h-24 w-full rounded-md border border-input bg-background p-3 text-sm" />
                        <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} />
                        <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")} />}
        <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">
    <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>
      <Link to="/admin/images" className="text-sm text-primary hover:underline">{t("images_link")}</Link>
      </div>
      <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>