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

feat(panel): i18n

parent a5a64821
Loading
Loading
Loading
Loading
+104 −1
Changes for panel/package-lock.json: 104 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -14,9 +14,12 @@
        "@radix-ui/react-slot": "^1.1.2",
        "class-variance-authority": "^0.7.1",
        "clsx": "^2.1.1",
        "i18next": "^26.3.3",
        "i18next-browser-languagedetector": "^8.2.1",
        "lucide-react": "^0.469.0",
        "react": "^18.3.1",
        "react-dom": "^18.3.1",
        "react-i18next": "^17.0.8",
        "react-router-dom": "^6.28.2",
        "tailwind-merge": "^2.6.0",
        "three": "^0.172.0"
@@ -283,6 +286,15 @@
        "@babel/core": "^7.0.0-0"
      }
    },
    "node_modules/@babel/runtime": {
      "version": "7.29.7",
      "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
      "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
      "license": "MIT",
      "engines": {
        "node": ">=6.9.0"
      }
    },
    "node_modules/@babel/template": {
      "version": "7.29.7",
      "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
@@ -2750,6 +2762,52 @@
        "node": ">= 0.4"
      }
    },
    "node_modules/html-parse-stringify": {
      "version": "3.0.1",
      "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
      "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
      "license": "MIT",
      "dependencies": {
        "void-elements": "3.1.0"
      }
    },
    "node_modules/i18next": {
      "version": "26.3.3",
      "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.3.tgz",
      "integrity": "sha512-aYVegyBdXSO93CMMihvr47jI7GHSOcIahMpJX+qzUXDzW4xDJf2uenIA+45vDU+YhiVdcfsql70AC9RVdMNrHg==",
      "funding": [
        {
          "type": "individual",
          "url": "https://www.locize.com/i18next"
        },
        {
          "type": "individual",
          "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
        },
        {
          "type": "individual",
          "url": "https://www.locize.com"
        }
      ],
      "license": "MIT",
      "peerDependencies": {
        "typescript": "^5 || ^6"
      },
      "peerDependenciesMeta": {
        "typescript": {
          "optional": true
        }
      }
    },
    "node_modules/i18next-browser-languagedetector": {
      "version": "8.2.1",
      "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz",
      "integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==",
      "license": "MIT",
      "dependencies": {
        "@babel/runtime": "^7.23.2"
      }
    },
    "node_modules/is-binary-path": {
      "version": "2.1.0",
      "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@@ -3301,6 +3359,33 @@
        "react": "^18.3.1"
      }
    },
    "node_modules/react-i18next": {
      "version": "17.0.8",
      "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz",
      "integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==",
      "license": "MIT",
      "dependencies": {
        "@babel/runtime": "^7.29.2",
        "html-parse-stringify": "^3.0.1",
        "use-sync-external-store": "^1.6.0"
      },
      "peerDependencies": {
        "i18next": ">= 26.2.0",
        "react": ">= 16.8.0",
        "typescript": "^5 || ^6"
      },
      "peerDependenciesMeta": {
        "react-dom": {
          "optional": true
        },
        "react-native": {
          "optional": true
        },
        "typescript": {
          "optional": true
        }
      }
    },
    "node_modules/react-refresh": {
      "version": "0.17.0",
      "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -3815,7 +3900,7 @@
      "version": "5.9.3",
      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
      "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
      "dev": true,
      "devOptional": true,
      "license": "Apache-2.0",
      "bin": {
        "tsc": "bin/tsc",
@@ -3906,6 +3991,15 @@
        }
      }
    },
    "node_modules/use-sync-external-store": {
      "version": "1.6.0",
      "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
      "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
      "license": "MIT",
      "peerDependencies": {
        "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
      }
    },
    "node_modules/util-deprecate": {
      "version": "1.0.2",
      "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -4122,6 +4216,15 @@
        "url": "https://github.com/sponsors/jonschlinkert"
      }
    },
    "node_modules/void-elements": {
      "version": "3.1.0",
      "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
      "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
      "license": "MIT",
      "engines": {
        "node": ">=0.10.0"
      }
    },
    "node_modules/why-is-node-running": {
      "version": "2.3.0",
      "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+3 −0
Changes for panel/package.json: 3 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -19,9 +19,12 @@
    "@radix-ui/react-slot": "^1.1.2",
    "class-variance-authority": "^0.7.1",
    "clsx": "^2.1.1",
    "i18next": "^26.3.3",
    "i18next-browser-languagedetector": "^8.2.1",
    "lucide-react": "^0.469.0",
    "react": "^18.3.1",
    "react-dom": "^18.3.1",
    "react-i18next": "^17.0.8",
    "react-router-dom": "^6.28.2",
    "tailwind-merge": "^2.6.0",
    "three": "^0.172.0"
+40 −8
Changes for panel/src/components/AppShell.tsx: 40 added lines, 8 removed lines.
Original line number Diff line number Diff line
import { useEffect } from "react";
import { NavLink, Outlet } from "react-router-dom";
import { Cat } from "lucide-react";
import { Cat, Globe } from "lucide-react";
import { useTranslation } from "react-i18next";
import * as SelectPrimitive from "@radix-ui/react-select";
import { cn } from "@/lib/utils";
import { useTier } from "@/lib/tier";
import { visibleSections } from "@/lib/nav";
import { Select, SelectContent, SelectItem } from "@/components/ui/select";

function LangToggle() {
  const { i18n } = useTranslation();

  return (
    <Select value={i18n.language} onValueChange={(v) => i18n.changeLanguage(v)}>
      <SelectPrimitive.Trigger className="h-7 w-7 p-0 flex items-center justify-center border border-border rounded-md bg-zinc-900/50 hover:bg-accent text-muted-foreground hover:text-foreground focus:outline-none transition-colors shrink-0">
        <Globe className="h-4 w-4" />
      </SelectPrimitive.Trigger>
      <SelectContent align="start" className="min-w-[6rem]">
        <SelectItem value="en-US">English</SelectItem>
        <SelectItem value="zh-CN">中文</SelectItem>
      </SelectContent>
    </Select>
  );
}

export function AppShell() {
  const { isAdmin } = useTier();
  const { t, i18n } = useTranslation("navigation");
  // Sections are derived purely from is_admin: User-Side always, Admin/SysAdmin
  // only for admins. isAdmin is fail-closed (false while /me loads or on failure),
  // so admin sections appear only once identity is confirmed.
  const sections = visibleSections(isAdmin);

  // Sync document metadata with the active language.
  useEffect(() => {
    document.documentElement.lang = i18n.language;
    document.title = t("common:page_title");
  }, [i18n.language, t]);

  return (
    <div className="flex min-h-screen">
      <aside className="hidden w-60 shrink-0 flex-col border-r border-border bg-card/40 p-4 md:flex">
        <div className="mb-6 flex items-center gap-2 px-2">
          <Cat className="h-6 w-6 text-primary" />
          <span className="text-lg font-semibold tracking-tight">Felis</span>
          <span className="text-lg font-semibold tracking-tight">{t("common:brand_name")}</span>
        </div>

        <nav className="flex flex-col gap-4">
          {sections.map((section) => (
            <div key={section.id} className="flex flex-col gap-1">
              {section.title && (
              {section.titleKey && (
                <div className="px-3 pb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground/70">
                  {section.title}
                  {t(section.titleKey)}
                </div>
              )}
              {section.items.map((item) => (
@@ -42,22 +69,27 @@ export function AppShell() {
                  }
                >
                  <item.icon className="h-4 w-4" />
                  {item.label}
                  {t(item.key)}
                </NavLink>
              ))}
            </div>
          ))}
        </nav>

        <div className="mt-auto px-3 pt-4 text-xs text-muted-foreground">
          K8s-native Minecraft orchestration
        <div className="mt-auto px-3 pt-4 border-t border-border/60">
          <div className="flex items-start gap-2.5">
            <LangToggle />
            <span className="text-[10px] text-muted-foreground/60 leading-tight pt-1">
              {t("common:brand_tagline")}
            </span>
          </div>
        </div>
      </aside>

      <div className="flex min-w-0 flex-1 flex-col">
        <header className="flex h-14 items-center gap-2 border-b border-border px-4 md:hidden">
          <Cat className="h-5 w-5 text-primary" />
          <span className="font-semibold">Felis</span>
          <span className="font-semibold">{t("common:brand_name")}</span>
        </header>
        <main className="flex-1 p-6">
          <Outlet />
+8 −1
Changes for panel/src/components/AuthLayout.tsx: 8 added lines, 1 removed line.
Original line number Diff line number Diff line
import type { ReactNode } from "react";
import { type ReactNode, useEffect } from "react";
import { Cat } from "lucide-react";
import { useTranslation } from "react-i18next";

// AuthLayout is the chrome for the pre-app auth surfaces (login, forced change).
// These live OUTSIDE AppShell — there is no nav, no tier context to honor yet —
@@ -13,6 +14,12 @@ export function AuthLayout({
  subtitle?: string;
  children: ReactNode;
}) {
  const { i18n } = useTranslation("common");

  useEffect(() => {
    document.documentElement.lang = i18n.language;
    document.title = title;
  }, [i18n.language, title]);
  return (
    <div className="flex min-h-screen flex-col items-center justify-center bg-background px-4 py-12">
      <div className="w-full max-w-sm space-y-6">
+33 −27
Changes for panel/src/components/CreateServerDialog.tsx: 33 added lines, 27 removed lines.
Original line number Diff line number Diff line
import { useState } from "react";
import { Plus } from "lucide-react";
import { useTranslation } from "react-i18next";
import {
  Dialog,
  DialogContent,
@@ -32,20 +33,24 @@ import type { AutostartPolicy, CreateServerRequest } from "@/lib/types";

const MEMORY_OPTIONS = ["2Gi", "4Gi", "6Gi", "8Gi"];
const STORAGE_OPTIONS = ["5Gi", "10Gi", "20Gi", "50Gi"];
const POLICIES: { value: AutostartPolicy; label: string }[] = [
  { value: "ownerOnly", label: "Owner only — wake from the panel" },
  { value: "public", label: "Public — any player join wakes it" },
  { value: "allowlist", label: "Allowlist — listed players wake it" },
];

const SUBDOMAIN_RE = /^[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?$/;

function policyOptions(t: (key: string) => string): { value: AutostartPolicy; label: string }[] {
  return [
    { value: "ownerOnly", label: t("create_server_policy_owner") },
    { value: "public", label: t("create_server_policy_public") },
    { value: "allowlist", label: t("create_server_policy_allowlist") },
  ];
}

interface Props {
  cfg: RuntimeConfig;
  onCreated: () => void;
}

export function CreateServerDialog({ cfg, onCreated }: Props) {
  const { t } = useTranslation("servers");
  const [open, setOpen] = useState(false);
  const images = useAsync(() => api.listImages(), []);

@@ -94,69 +99,70 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
    }
  }

  const policies = policyOptions(t);

  return (
    <Dialog open={open} onOpenChange={setOpen}>
      <DialogTrigger asChild>
        <Button>
          <Plus /> New server
          <Plus /> {t("create_server_btn")}
        </Button>
      </DialogTrigger>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>Create a server</DialogTitle>
          <DialogTitle>{t("create_server_title")}</DialogTitle>
          <DialogDescription>
            Pick from whitelisted images and sizes — the platform provisions the
            rest. No raw cluster config is exposed here.
            {t("create_server_desc")}
          </DialogDescription>
        </DialogHeader>

        <div className="grid gap-4">
          <div className="grid gap-2">
            <Label htmlFor="cs-name">Name</Label>
            <Label htmlFor="cs-name">{t("create_server_name")}</Label>
            <Input
              id="cs-name"
              placeholder="survival"
              placeholder={t("create_server_name_placeholder")}
              value={form.name}
              onChange={(e) => set("name", e.target.value)}
            />
          </div>

          <div className="grid gap-2">
            <Label htmlFor="cs-sub">Subdomain</Label>
            <Label htmlFor="cs-sub">{t("create_server_subdomain")}</Label>
            <Input
              id="cs-sub"
              placeholder="survival"
              placeholder={t("create_server_subdomain_placeholder")}
              value={form.subdomain}
              onChange={(e) => set("subdomain", e.target.value.toLowerCase())}
            />
            <p className="text-xs text-muted-foreground">
              {form.subdomain && !subdomainValid
                ? "Lowercase letters, digits and hyphens only."
                : `Will be reachable at ${hostFor(form.subdomain || "name", cfg)}`}
                ? t("create_server_subdomain_invalid")
                : t("create_server_subdomain_hint", { host: hostFor(form.subdomain || "name", cfg) })}
            </p>
          </div>

          <div className="grid gap-2">
            <Label htmlFor="cs-display">Display name (optional)</Label>
            <Label htmlFor="cs-display">{t("create_server_display_name")}</Label>
            <Input
              id="cs-display"
              placeholder="Survival World"
              placeholder={t("create_server_display_name_placeholder")}
              value={form.displayName ?? ""}
              onChange={(e) => set("displayName", e.target.value)}
            />
          </div>

          <div className="grid gap-2">
            <Label>Image</Label>
            <Label>{t("create_server_image")}</Label>
            <Select value={form.image} onValueChange={(v) => set("image", v)}>
              <SelectTrigger>
                <SelectValue
                  placeholder={
                    images.loading
                      ? "Loading images…"
                      ? t("create_server_image_loading")
                      : enabledImages.length
                        ? "Choose an image"
                        : "No whitelisted images"
                        ? t("create_server_image_choose")
                        : t("create_server_image_none")
                  }
                />
              </SelectTrigger>
@@ -172,7 +178,7 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {

          <div className="grid grid-cols-2 gap-4">
            <div className="grid gap-2">
              <Label>Memory</Label>
              <Label>{t("create_server_memory")}</Label>
              <Select value={form.memory} onValueChange={(v) => set("memory", v)}>
                <SelectTrigger>
                  <SelectValue />
@@ -187,7 +193,7 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
              </Select>
            </div>
            <div className="grid gap-2">
              <Label>Storage</Label>
              <Label>{t("create_server_storage")}</Label>
              <Select value={form.storage} onValueChange={(v) => set("storage", v)}>
                <SelectTrigger>
                  <SelectValue />
@@ -204,7 +210,7 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
          </div>

          <div className="grid gap-2">
            <Label>Autostart policy</Label>
            <Label>{t("create_server_policy")}</Label>
            <Select
              value={form.autostartPolicy}
              onValueChange={(v) => set("autostartPolicy", v as AutostartPolicy)}
@@ -213,7 +219,7 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
                <SelectValue />
              </SelectTrigger>
              <SelectContent>
                {POLICIES.map((p) => (
                {policies.map((p) => (
                  <SelectItem key={p.value} value={p.value}>
                    {p.label}
                  </SelectItem>
@@ -227,10 +233,10 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {

        <DialogFooter>
          <Button variant="ghost" onClick={() => setOpen(false)} disabled={submitting}>
            Cancel
            {t("create_server_cancel")}
          </Button>
          <Button onClick={submit} disabled={!canSubmit}>
            {submitting ? "Creating…" : "Create"}
            {submitting ? t("create_server_creating") : t("create_server_submit")}
          </Button>
        </DialogFooter>
      </DialogContent>
Loading