Loading panel/package-lock.json +104 −1 Changes for panel/package-lock.json: 104 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -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" Loading Loading @@ -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", Loading Loading @@ -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", Loading Loading @@ -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", Loading Loading @@ -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", Loading Loading @@ -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", Loading Loading @@ -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", Loading panel/package.json +3 −0 Changes for panel/package.json: 3 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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" Loading panel/src/components/AppShell.tsx +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) => ( Loading @@ -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 /> Loading panel/src/components/AuthLayout.tsx +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 — Loading @@ -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"> Loading panel/src/components/CreateServerDialog.tsx +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, Loading Loading @@ -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(), []); Loading Loading @@ -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> Loading @@ -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 /> Loading @@ -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 /> Loading @@ -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)} Loading @@ -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> Loading @@ -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 Loading
panel/package-lock.json +104 −1 Changes for panel/package-lock.json: 104 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -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" Loading Loading @@ -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", Loading Loading @@ -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", Loading Loading @@ -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", Loading Loading @@ -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", Loading Loading @@ -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", Loading Loading @@ -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", Loading
panel/package.json +3 −0 Changes for panel/package.json: 3 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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" Loading
panel/src/components/AppShell.tsx +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) => ( Loading @@ -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 /> Loading
panel/src/components/AuthLayout.tsx +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 — Loading @@ -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"> Loading
panel/src/components/CreateServerDialog.tsx +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, Loading Loading @@ -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(), []); Loading Loading @@ -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> Loading @@ -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 /> Loading @@ -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 /> Loading @@ -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)} Loading @@ -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> Loading @@ -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