fix(panel): 手机端加导航抽屉和登出入口,服务器与备份列表在窄屏改为卡片,桌面服务器表不再挤成竖排
This commit is contained in:
7 files changed
+356
-166
No files matched your search
@@ -1,8 +1,9 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { NavLink, Outlet, useLocation } from "react-router-dom";
|
import { NavLink, Outlet, useLocation } from "react-router-dom";
|
||||||
import { Cat, Globe, Sun, Moon, LogOut, WifiOff } from "lucide-react";
|
import { Cat, Globe, Sun, Moon, LogOut, WifiOff, Menu, X } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useTier } from "@/lib/tier";
|
import { useTier } from "@/lib/tier";
|
||||||
import { visibleSections, type NavSection } from "@/lib/nav";
|
import { visibleSections, type NavSection } from "@/lib/nav";
|
||||||
@@ -12,7 +13,15 @@ import { api, CONNECTION_EVENT, isConnectionLost } from "@/lib/api";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ErrorBoundary } from "@/components/ErrorBoundary";
|
import { ErrorBoundary } from "@/components/ErrorBoundary";
|
||||||
|
|
||||||
function SectionGroup({ section, isFirst }: { section: NavSection; isFirst: boolean }) {
|
function SectionGroup({
|
||||||
|
section,
|
||||||
|
isFirst,
|
||||||
|
onNavigate,
|
||||||
|
}: {
|
||||||
|
section: NavSection;
|
||||||
|
isFirst: boolean;
|
||||||
|
onNavigate?: () => void;
|
||||||
|
}) {
|
||||||
const { t } = useTranslation("navigation");
|
const { t } = useTranslation("navigation");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -34,6 +43,7 @@ function SectionGroup({ section, isFirst }: { section: NavSection; isFirst: bool
|
|||||||
key={item.to}
|
key={item.to}
|
||||||
to={item.to}
|
to={item.to}
|
||||||
end={item.end}
|
end={item.end}
|
||||||
|
onClick={onNavigate}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
cn(
|
cn(
|
||||||
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
|
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
|
||||||
@@ -95,7 +105,7 @@ function UserStrip() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={signOut}
|
onClick={signOut}
|
||||||
disabled={signingOut || !identity}
|
disabled={signingOut || !identity}
|
||||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-background hover:text-destructive border border-transparent hover:border-border/50 focus:outline-none transition-all active:scale-95 disabled:opacity-50"
|
className="flex h-10 w-10 md:h-7 md:w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-background hover:text-destructive border border-transparent hover:border-border/50 focus:outline-none transition-all active:scale-95 disabled:opacity-50"
|
||||||
aria-label={t("sign_out")}
|
aria-label={t("sign_out")}
|
||||||
title={t("sign_out")}
|
title={t("sign_out")}
|
||||||
>
|
>
|
||||||
@@ -168,6 +178,60 @@ function ConnectionBanner() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MobileNav is the phone-width way into every page: the sidebar is md-only, so
|
||||||
|
// below md a menu button opens the same sections (and the same sign-out strip)
|
||||||
|
// as a drawer from the left. Following a link closes it; so does a route change
|
||||||
|
// from anywhere else (back button, a link inside the page).
|
||||||
|
function MobileNav({ sections }: { sections: NavSection[] }) {
|
||||||
|
const { t } = useTranslation("common");
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
useEffect(() => setOpen(false), [pathname]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Root open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogPrimitive.Trigger
|
||||||
|
className={FOOT_ICON_BTN}
|
||||||
|
aria-label={t("open_menu")}
|
||||||
|
title={t("open_menu")}
|
||||||
|
>
|
||||||
|
<Menu className="h-4 w-4" />
|
||||||
|
</DialogPrimitive.Trigger>
|
||||||
|
<DialogPrimitive.Portal>
|
||||||
|
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 md:hidden" />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
aria-describedby={undefined}
|
||||||
|
className="fixed inset-y-0 left-0 z-50 flex w-72 max-w-[85vw] flex-col border-r border-border bg-card p-4 shadow-xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left md:hidden"
|
||||||
|
>
|
||||||
|
<div className="mb-5 flex items-center justify-between px-2">
|
||||||
|
<DialogPrimitive.Title className="flex items-center gap-2 text-lg font-semibold tracking-tight">
|
||||||
|
<Cat className="h-6 w-6 text-primary" />
|
||||||
|
{t("brand_name")}
|
||||||
|
</DialogPrimitive.Title>
|
||||||
|
<DialogPrimitive.Close className={FOOT_ICON_BTN} aria-label={t("close_sr")}>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</div>
|
||||||
|
<nav className="flex flex-col overflow-y-auto">
|
||||||
|
{sections.map((section, i) => (
|
||||||
|
<SectionGroup
|
||||||
|
key={section.id}
|
||||||
|
section={section}
|
||||||
|
isFirst={i === 0}
|
||||||
|
onNavigate={() => setOpen(false)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<div className="mt-auto border-t border-border/50 pt-3">
|
||||||
|
{/* The strip's sign-out button grows to a thumb-sized target below md. */}
|
||||||
|
<UserStrip />
|
||||||
|
</div>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPrimitive.Portal>
|
||||||
|
</DialogPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
const { isAdmin, isOwner } = useTier();
|
const { isAdmin, isOwner } = useTier();
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
@@ -222,8 +286,9 @@ export function AppShell() {
|
|||||||
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col h-full overflow-y-auto">
|
<div className="flex min-w-0 flex-1 flex-col h-full overflow-y-auto">
|
||||||
<ConnectionBanner />
|
<ConnectionBanner />
|
||||||
<header className="flex h-14 items-center justify-between border-b border-border px-4 md:hidden">
|
<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">
|
<div className="flex items-center gap-2">
|
||||||
|
<MobileNav sections={sections} />
|
||||||
<Cat className="h-5 w-5 text-primary" />
|
<Cat className="h-5 w-5 text-primary" />
|
||||||
<span className="font-semibold">{t("common:brand_name")}</span>
|
<span className="font-semibold">{t("common:brand_name")}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -232,7 +297,7 @@ export function AppShell() {
|
|||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="flex flex-1 flex-col p-6">
|
<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">
|
<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
|
{/* A crash on one page leaves the navigation usable; moving to
|
||||||
another route clears it. */}
|
another route clears it. */}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export function phaseVariant(phase: Phase): BadgeVariant {
|
|||||||
export function PhaseBadge({ phase }: { phase: Phase }) {
|
export function PhaseBadge({ phase }: { phase: Phase }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Badge variant={phaseVariant(phase)} className="gap-1.5">
|
<Badge variant={phaseVariant(phase)} className="gap-1.5 whitespace-nowrap">
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-1.5 w-1.5 rounded-full",
|
"h-1.5 w-1.5 rounded-full",
|
||||||
|
|||||||
@@ -14,23 +14,25 @@ export interface StatCardProps {
|
|||||||
valueClass?: string;
|
valueClass?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_ICON_CONTAINER = "rounded-md p-2 bg-muted/30 text-muted-foreground";
|
const DEFAULT_ICON_CONTAINER = "shrink-0 rounded-md p-1.5 sm:p-2 bg-muted/30 text-muted-foreground";
|
||||||
|
|
||||||
export function StatCard({ icon: Icon, label, value, accentClass, accentColor, valueClass }: StatCardProps) {
|
export function StatCard({ icon: Icon, label, value, accentClass, accentColor, valueClass }: StatCardProps) {
|
||||||
const containerStyle = accentColor ? { backgroundColor: `${accentColor}26`, color: accentColor } : undefined;
|
const containerStyle = accentColor ? { backgroundColor: `${accentColor}26`, color: accentColor } : undefined;
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex items-center gap-3 p-4">
|
{/* Two cards share a phone's width, so padding, icon and figure step down
|
||||||
|
below sm to keep a value like "50 / 240" whole. */}
|
||||||
|
<CardContent className="flex items-center gap-2.5 p-3 sm:gap-3 sm:p-4">
|
||||||
<div
|
<div
|
||||||
className={cn(DEFAULT_ICON_CONTAINER, !accentColor && accentClass)}
|
className={cn(DEFAULT_ICON_CONTAINER, !accentColor && accentClass)}
|
||||||
style={containerStyle}
|
style={containerStyle}
|
||||||
>
|
>
|
||||||
<Icon className="h-5 w-5" />
|
<Icon className="h-4 w-4 sm:h-5 sm:w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-2xl font-bold font-mono leading-none truncate text-foreground",
|
"text-xl sm:text-2xl font-bold font-mono leading-none truncate text-foreground",
|
||||||
valueClass,
|
valueClass,
|
||||||
)}
|
)}
|
||||||
title={typeof value === "string" ? value : undefined}
|
title={typeof value === "string" ? value : undefined}
|
||||||
|
|||||||
@@ -30,5 +30,6 @@
|
|||||||
"not_found_title": "Page not found",
|
"not_found_title": "Page not found",
|
||||||
"not_found_body": "The name in the link is not valid, or the page does not exist.",
|
"not_found_body": "The name in the link is not valid, or the page does not exist.",
|
||||||
"connection_lost": "Can't reach Felis: the network is down, or your Cloudflare Access sign-in expired. This clears by itself once the network is back; an expired Access sign-in needs a page reload.",
|
"connection_lost": "Can't reach Felis: the network is down, or your Cloudflare Access sign-in expired. This clears by itself once the network is back; an expired Access sign-in needs a page reload.",
|
||||||
"reload_page": "Reload page"
|
"reload_page": "Reload page",
|
||||||
|
"open_menu": "Open menu"
|
||||||
}
|
}
|
||||||
@@ -30,5 +30,6 @@
|
|||||||
"not_found_title": "找不到这个页面",
|
"not_found_title": "找不到这个页面",
|
||||||
"not_found_body": "链接里的名称不合法,或者这个页面不存在。",
|
"not_found_body": "链接里的名称不合法,或者这个页面不存在。",
|
||||||
"connection_lost": "连不上 Felis:网络断开了,或者 Cloudflare Access 的登录已过期。网络恢复后会自动消失;Access 过期需要刷新页面重新登录。",
|
"connection_lost": "连不上 Felis:网络断开了,或者 Cloudflare Access 的登录已过期。网络恢复后会自动消失;Access 过期需要刷新页面重新登录。",
|
||||||
"reload_page": "刷新页面"
|
"reload_page": "刷新页面",
|
||||||
|
"open_menu": "打开菜单"
|
||||||
}
|
}
|
||||||
@@ -38,6 +38,8 @@ import { formatBytes, formatRelative, formatAbsolute, isExpired } from "@/lib/fo
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { BackupView, ServerJob } from "@/lib/types";
|
import type { BackupView, ServerJob } from "@/lib/types";
|
||||||
|
|
||||||
|
const CELL = "whitespace-nowrap md:px-4 md:py-3.5";
|
||||||
|
|
||||||
/** BackupRow is one backup in the table, with its own restore action. `isLatest`
|
/** BackupRow is one backup in the table, with its own restore action. `isLatest`
|
||||||
* marks the row a restore with no pick recovers: the newest one that is not
|
* marks the row a restore with no pick recovers: the newest one that is not
|
||||||
* corrupt, which is what the backend's LatestBackup selects. Under the reason it
|
* corrupt, which is what the backend's LatestBackup selects. Under the reason it
|
||||||
@@ -72,9 +74,12 @@ function BackupRow({
|
|||||||
? t("reason_pre_restore")
|
? t("reason_pre_restore")
|
||||||
: t("reason_label", { reason: b.reason });
|
: t("reason_label", { reason: b.reason });
|
||||||
|
|
||||||
|
// Below md the row stops being a table row: the when/why block takes the full
|
||||||
|
// width, size and expiry follow on one line and the restore button sits at the
|
||||||
|
// right, so a phone never has to scroll sideways to reach it.
|
||||||
return (
|
return (
|
||||||
<tr className="hover:bg-muted/30 transition-colors">
|
<tr className="flex flex-wrap items-center gap-x-4 gap-y-2 px-4 py-3 transition-colors hover:bg-muted/30 md:table-row md:p-0">
|
||||||
<td className="px-4 py-3 whitespace-nowrap">
|
<td className="w-full md:w-auto md:px-4 md:py-3 md:whitespace-nowrap">
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
@@ -96,19 +101,19 @@ function BackupRow({
|
|||||||
<IntegrityNote b={b} now={now} locale={locale} />
|
<IntegrityNote b={b} now={now} locale={locale} />
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3.5 whitespace-nowrap text-muted-foreground">
|
<td className={cn(CELL, "text-muted-foreground")}>
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<HardDrive className="h-3.5 w-3.5 shrink-0" />
|
<HardDrive className="h-3.5 w-3.5 shrink-0" />
|
||||||
{formatBytes(b.size_bytes)}
|
{formatBytes(b.size_bytes)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3.5 whitespace-nowrap">
|
<td className={CELL}>
|
||||||
<span className={cn("text-muted-foreground", expired && "font-medium text-destructive")}>
|
<span className={cn("text-muted-foreground", expired && "font-medium text-destructive")}>
|
||||||
{expired ? t("expired") : t("expires_in", { when: formatRelative(b.expires_at, now, locale) })}
|
{expired ? t("expired") : t("expires_in", { when: formatRelative(b.expires_at, now, locale) })}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
{showOwner && (
|
{showOwner && (
|
||||||
<td className="px-4 py-3.5 whitespace-nowrap text-muted-foreground">
|
<td className={cn(CELL, "min-w-0 text-muted-foreground")}>
|
||||||
{b.former_owner ? (
|
{b.former_owner ? (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<UserMinus className="h-3 w-3 shrink-0" />
|
<UserMinus className="h-3 w-3 shrink-0" />
|
||||||
@@ -119,7 +124,7 @@ function BackupRow({
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
<td className="px-4 py-3.5 whitespace-nowrap text-right">
|
<td className={cn(CELL, "ml-auto text-right")}>
|
||||||
{b.corrupt ? (
|
{b.corrupt ? (
|
||||||
<span className="text-xs text-destructive/70 font-medium px-3 py-1.5">
|
<span className="text-xs text-destructive/70 font-medium px-3 py-1.5">
|
||||||
{t("corrupt_short")}
|
{t("corrupt_short")}
|
||||||
@@ -644,8 +649,8 @@ export function ServerBackups() {
|
|||||||
<>
|
<>
|
||||||
<Card className="overflow-hidden">
|
<Card className="overflow-hidden">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full border-collapse text-sm">
|
<table className="block w-full border-collapse text-sm md:table">
|
||||||
<thead>
|
<thead className="hidden md:table-header-group">
|
||||||
<tr className="border-b border-border bg-muted/40 text-left text-[11px] uppercase tracking-wider text-muted-foreground">
|
<tr className="border-b border-border bg-muted/40 text-left text-[11px] uppercase tracking-wider text-muted-foreground">
|
||||||
<th className="px-4 py-2.5 font-medium">{t("col_created")}</th>
|
<th className="px-4 py-2.5 font-medium">{t("col_created")}</th>
|
||||||
<th className="px-4 py-2.5 font-medium">{t("col_size")}</th>
|
<th className="px-4 py-2.5 font-medium">{t("col_size")}</th>
|
||||||
@@ -654,7 +659,7 @@ export function ServerBackups() {
|
|||||||
<th className="px-4 py-2.5 text-right font-medium">{t("col_actions")}</th>
|
<th className="px-4 py-2.5 text-right font-medium">{t("col_actions")}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border">
|
<tbody className="block divide-y divide-border md:table-row-group">
|
||||||
{all.map((b) => (
|
{all.map((b) => (
|
||||||
<BackupRow
|
<BackupRow
|
||||||
key={b.id}
|
key={b.id}
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ export function ServersPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Stats Cards in a full grid row */}
|
{/* Stats Cards in a full grid row */}
|
||||||
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
<div className="grid grid-cols-2 gap-4 xl:grid-cols-4">
|
||||||
<StatCard icon={Server} label={t("fleet_stat_total")} value={stats.total} />
|
<StatCard icon={Server} label={t("fleet_stat_total")} value={stats.total} />
|
||||||
<StatCard
|
<StatCard
|
||||||
icon={Play}
|
icon={Play}
|
||||||
@@ -343,13 +343,25 @@ export function ServersPage() {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Clean Table Card */}
|
{/* Cards below xl (two per row from md); the table needs about
|
||||||
<Card className="overflow-hidden border border-border/80">
|
1000px of content width for all its columns, which the page
|
||||||
|
only has from xl beside the sidebar. */}
|
||||||
|
<ul className="grid gap-3 md:grid-cols-2 xl:hidden">
|
||||||
|
{paged.map((s) => (
|
||||||
|
<ServerMobileCard
|
||||||
|
key={s.name}
|
||||||
|
server={s}
|
||||||
|
cfg={cfg}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
onChanged={reload}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<Card className="hidden overflow-hidden border border-border/80 xl:block">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full min-w-[56rem] border-collapse text-sm">
|
<table className="w-full border-collapse text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-border bg-muted/40 text-left text-[11px] uppercase tracking-wider text-muted-foreground">
|
<tr className="whitespace-nowrap border-b border-border bg-muted/40 text-left text-[11px] uppercase tracking-wider text-muted-foreground">
|
||||||
<th className="px-4 py-2.5 font-medium">{t("fleet_col_status")}</th>
|
|
||||||
<th className="px-4 py-2.5 font-medium">{t("fleet_col_server")}</th>
|
<th className="px-4 py-2.5 font-medium">{t("fleet_col_server")}</th>
|
||||||
{isAdmin && <th className="px-4 py-2.5 font-medium">{t("fleet_col_owner")}</th>}
|
{isAdmin && <th className="px-4 py-2.5 font-medium">{t("fleet_col_owner")}</th>}
|
||||||
<th className="px-4 py-2.5 font-medium">{t("fleet_col_players")}</th>
|
<th className="px-4 py-2.5 font-medium">{t("fleet_col_players")}</th>
|
||||||
@@ -391,18 +403,19 @@ export function ServersPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ServerRow({
|
// ServerActions is the action cluster of one server — claim, start/stop, console —
|
||||||
|
// shared by the desktop table row and the phone card so both offer the same things.
|
||||||
|
function ServerActions({
|
||||||
server,
|
server,
|
||||||
cfg,
|
|
||||||
isAdmin,
|
isAdmin,
|
||||||
onChanged,
|
onChanged,
|
||||||
|
className,
|
||||||
}: {
|
}: {
|
||||||
server: UnifiedServer;
|
server: UnifiedServer;
|
||||||
cfg: RuntimeConfig;
|
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
onChanged: () => void;
|
onChanged: () => void;
|
||||||
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("ops");
|
|
||||||
const { t: ts } = useTranslation("servers");
|
const { t: ts } = useTranslation("servers");
|
||||||
const [busy, setBusy] = useState<null | "claim">(null);
|
const [busy, setBusy] = useState<null | "claim">(null);
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
@@ -421,152 +434,255 @@ function ServerRow({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const live = isLive(server.phase);
|
if (server.system) {
|
||||||
const host = server.subdomain ? hostFor(server.subdomain, cfg) : "";
|
// A system service carries a reserved name that every per-server route
|
||||||
const endpoint = server.ready && server.endpointAddress ? server.endpointAddress : null;
|
// rejects, so offer no actions — just the honest label.
|
||||||
|
return (
|
||||||
|
<span className="text-xs text-muted-foreground/70" title={ts("system_service_hint")}>
|
||||||
|
{ts("system_service")}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const live = isLive(server.phase);
|
||||||
return (
|
return (
|
||||||
<>
|
<div className={cn("flex flex-col items-end gap-1", className)}>
|
||||||
<tr className="border-b border-border/50 transition-colors last:border-0 hover:bg-muted/40">
|
<div className="flex flex-wrap items-start justify-end gap-2 xl:flex-nowrap">
|
||||||
<td className="px-4 py-3 align-middle text-left">
|
{server.claimable && !server.owned ? (
|
||||||
<PhaseBadge phase={server.phase} />
|
<>
|
||||||
</td>
|
<Button
|
||||||
<td className="px-4 py-3 align-middle text-left">
|
size="sm"
|
||||||
<div className="font-medium text-foreground">{server.name}</div>
|
variant="outline"
|
||||||
{host && (
|
disabled={busy !== null}
|
||||||
<a
|
onClick={() => setConfirmOpen(true)}
|
||||||
href={`https://${host}`}
|
className="text-primary hover:text-primary hover:bg-primary/5 border-primary/20"
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
>
|
||||||
{host}
|
<Hand /> {ts("claim")}
|
||||||
<ExternalLink className="h-3 w-3" />
|
</Button>
|
||||||
</a>
|
<Button size="sm" variant="outline" disabled>
|
||||||
)}
|
<Terminal /> {ts("console")}
|
||||||
</td>
|
</Button>
|
||||||
{isAdmin && (
|
|
||||||
<td className="px-4 py-3 align-middle text-left">
|
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||||
{server.system ? (
|
<DialogContent className="sm:max-w-md">
|
||||||
<span className="text-xs text-muted-foreground/70" title={ts("system_service_hint")}>
|
<DialogHeader>
|
||||||
{ts("system_service")}
|
<DialogTitle>{ts("claim_server_title")}</DialogTitle>
|
||||||
</span>
|
<DialogDescription>
|
||||||
) : server.owner ? (
|
{ts("claim_server_desc", { name: server.name })}
|
||||||
<span className="inline-flex max-w-[16rem] items-center gap-1.5 truncate">
|
</DialogDescription>
|
||||||
<UserRound className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
</DialogHeader>
|
||||||
<span className="truncate" title={server.owner}>
|
<ConfirmFooter
|
||||||
{server.owner}
|
onCancel={() => setConfirmOpen(false)}
|
||||||
</span>
|
onConfirm={async () => {
|
||||||
</span>
|
await act("claim", () => api.claim(server.name));
|
||||||
) : (
|
setConfirmOpen(false);
|
||||||
<span className="text-xs text-muted-foreground/70">{t("fleet_unclaimed")}</span>
|
}}
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
)}
|
|
||||||
<td className="px-4 py-3 align-middle tabular-nums text-left">
|
|
||||||
{live ? (
|
|
||||||
<span className="inline-flex items-center gap-1.5">
|
|
||||||
<Users className="h-3.5 w-3.5 text-muted-foreground" />
|
|
||||||
{server.playersOnline}
|
|
||||||
<span className="text-muted-foreground">/ {server.playersMax}</span>
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-muted-foreground">—</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 align-middle text-left">
|
|
||||||
{server.autostartPolicy ? (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{t(POLICY_KEY[server.autostartPolicy])}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-muted-foreground">—</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 align-middle text-left">
|
|
||||||
{endpoint ? (
|
|
||||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground">
|
|
||||||
{endpoint}
|
|
||||||
</code>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-muted-foreground/70">{t("fleet_endpoint_idle")}</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 align-middle">
|
|
||||||
<div className="flex items-center justify-end gap-2">
|
|
||||||
{server.system ? (
|
|
||||||
// A system service carries a reserved name that every per-server route
|
|
||||||
// rejects, so offer no actions — just the honest label.
|
|
||||||
<span className="text-xs text-muted-foreground/70" title={ts("system_service_hint")}>
|
|
||||||
{ts("system_service")}
|
|
||||||
</span>
|
|
||||||
) : server.claimable && !server.owned ? (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
disabled={busy !== null}
|
disabled={busy !== null}
|
||||||
onClick={() => setConfirmOpen(true)}
|
loading={busy === "claim"}
|
||||||
className="text-primary hover:text-primary hover:bg-primary/5 border-primary/20"
|
cancelLabel={ts("access_cancel")}
|
||||||
>
|
confirmLabel={ts("claim")}
|
||||||
<Hand /> {ts("claim")}
|
confirmVariant="default"
|
||||||
</Button>
|
/>
|
||||||
<Button size="sm" variant="outline" disabled>
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<PowerButton
|
||||||
|
name={server.name}
|
||||||
|
live={live}
|
||||||
|
playersOnline={server.playersOnline}
|
||||||
|
playerCountUnknown={server.playerCountUnknown}
|
||||||
|
onChanged={onChanged}
|
||||||
|
/>
|
||||||
|
{(server.owned || isAdmin) && (
|
||||||
|
<Link to={`/servers/${server.name}`}>
|
||||||
|
<Button size="sm" variant="outline">
|
||||||
<Terminal /> {ts("console")}
|
<Terminal /> {ts("console")}
|
||||||
</Button>
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="max-w-xs text-right text-xs text-destructive">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
function HostLink({ host }: { host: string }) {
|
||||||
<DialogContent className="sm:max-w-md">
|
return (
|
||||||
<DialogHeader>
|
<a
|
||||||
<DialogTitle>{ts("claim_server_title")}</DialogTitle>
|
href={`https://${host}`}
|
||||||
<DialogDescription>
|
target="_blank"
|
||||||
{ts("claim_server_desc", { name: server.name })}
|
rel="noreferrer"
|
||||||
</DialogDescription>
|
className="inline-flex min-w-0 max-w-full items-center gap-1 text-xs text-muted-foreground hover:text-foreground md:max-w-[15rem]"
|
||||||
</DialogHeader>
|
>
|
||||||
<ConfirmFooter
|
<span className="truncate">{host}</span>
|
||||||
onCancel={() => setConfirmOpen(false)}
|
<ExternalLink className="h-3 w-3 shrink-0" />
|
||||||
onConfirm={async () => {
|
</a>
|
||||||
await act("claim", () => api.claim(server.name));
|
);
|
||||||
setConfirmOpen(false);
|
}
|
||||||
}}
|
|
||||||
disabled={busy !== null}
|
function OwnerLabel({ server }: { server: UnifiedServer }) {
|
||||||
loading={busy === "claim"}
|
const { t } = useTranslation("ops");
|
||||||
cancelLabel={ts("access_cancel")}
|
const { t: ts } = useTranslation("servers");
|
||||||
confirmLabel={ts("claim")}
|
if (server.system) {
|
||||||
confirmVariant="default"
|
return (
|
||||||
/>
|
<span className="text-xs text-muted-foreground/70" title={ts("system_service_hint")}>
|
||||||
</DialogContent>
|
{ts("system_service")}
|
||||||
</Dialog>
|
</span>
|
||||||
</>
|
);
|
||||||
) : (
|
}
|
||||||
|
if (server.owner) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex min-w-0 max-w-full items-center gap-1.5 md:max-w-[13rem]">
|
||||||
|
<UserRound className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="truncate" title={server.owner}>
|
||||||
|
{server.owner}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <span className="text-xs text-muted-foreground/70">{t("fleet_unclaimed")}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlayersLabel({ server }: { server: UnifiedServer }) {
|
||||||
|
if (!isLive(server.phase)) return <span className="text-muted-foreground">—</span>;
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1.5 whitespace-nowrap tabular-nums">
|
||||||
|
<Users className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
{server.playersOnline}
|
||||||
|
<span className="text-muted-foreground">/ {server.playersMax}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EndpointLabel({ server }: { server: UnifiedServer }) {
|
||||||
|
const { t } = useTranslation("ops");
|
||||||
|
const endpoint = server.ready && server.endpointAddress ? server.endpointAddress : null;
|
||||||
|
if (!endpoint) {
|
||||||
|
return <span className="text-xs text-muted-foreground/70">{t("fleet_endpoint_idle")}</span>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<code className="break-all rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground xl:whitespace-nowrap xl:break-normal">
|
||||||
|
{endpoint}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ServerRow({
|
||||||
|
server,
|
||||||
|
cfg,
|
||||||
|
isAdmin,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
server: UnifiedServer;
|
||||||
|
cfg: RuntimeConfig;
|
||||||
|
isAdmin: boolean;
|
||||||
|
onChanged: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("ops");
|
||||||
|
const host = server.subdomain ? hostFor(server.subdomain, cfg) : "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr className="border-b border-border/50 transition-colors last:border-0 hover:bg-muted/40">
|
||||||
|
<td className="px-4 py-3 align-middle text-left">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-foreground">{server.name}</span>
|
||||||
|
<PhaseBadge phase={server.phase} />
|
||||||
|
</div>
|
||||||
|
{host && <HostLink host={host} />}
|
||||||
|
</td>
|
||||||
|
{isAdmin && (
|
||||||
|
<td className="px-4 py-3 align-middle text-left">
|
||||||
|
<OwnerLabel server={server} />
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
<td className="px-4 py-3 align-middle text-left">
|
||||||
|
<PlayersLabel server={server} />
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 align-middle text-left">
|
||||||
|
{server.autostartPolicy ? (
|
||||||
|
<span className="whitespace-nowrap text-xs text-muted-foreground">
|
||||||
|
{t(POLICY_KEY[server.autostartPolicy])}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 align-middle text-left">
|
||||||
|
<EndpointLabel server={server} />
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 align-middle">
|
||||||
|
<ServerActions server={server} isAdmin={isAdmin} onChanged={onChanged} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServerMobileCard is one server below xl, where the table's columns would not
|
||||||
|
// fit: name and phase on top, the facts as a short list, the actions at the
|
||||||
|
// bottom within thumb reach.
|
||||||
|
function ServerMobileCard({
|
||||||
|
server,
|
||||||
|
cfg,
|
||||||
|
isAdmin,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
server: UnifiedServer;
|
||||||
|
cfg: RuntimeConfig;
|
||||||
|
isAdmin: boolean;
|
||||||
|
onChanged: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("ops");
|
||||||
|
const host = server.subdomain ? hostFor(server.subdomain, cfg) : "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="flex">
|
||||||
|
<Card className="flex flex-1 flex-col border border-border/80">
|
||||||
|
<CardContent className="flex flex-1 flex-col gap-3 p-4">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate font-medium text-foreground">{server.name}</div>
|
||||||
|
{host && <HostLink host={host} />}
|
||||||
|
</div>
|
||||||
|
<PhaseBadge phase={server.phase} />
|
||||||
|
</div>
|
||||||
|
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-sm">
|
||||||
|
{isAdmin && (
|
||||||
<>
|
<>
|
||||||
<PowerButton
|
<dt className="text-xs text-muted-foreground">{t("fleet_col_owner")}</dt>
|
||||||
name={server.name}
|
<dd className="min-w-0">
|
||||||
live={live}
|
<OwnerLabel server={server} />
|
||||||
playersOnline={server.playersOnline}
|
</dd>
|
||||||
playerCountUnknown={server.playerCountUnknown}
|
|
||||||
onChanged={onChanged}
|
|
||||||
/>
|
|
||||||
{(server.owned || isAdmin) && (
|
|
||||||
<Link to={`/servers/${server.name}`}>
|
|
||||||
<Button size="sm" variant="outline">
|
|
||||||
<Terminal /> {ts("console")}
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<dt className="text-xs text-muted-foreground">{t("fleet_col_players")}</dt>
|
||||||
|
<dd>
|
||||||
|
<PlayersLabel server={server} />
|
||||||
|
</dd>
|
||||||
|
{server.autostartPolicy && (
|
||||||
|
<>
|
||||||
|
<dt className="text-xs text-muted-foreground">{t("fleet_col_policy")}</dt>
|
||||||
|
<dd className="text-xs">{t(POLICY_KEY[server.autostartPolicy])}</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<dt className="text-xs text-muted-foreground">{t("fleet_col_endpoint")}</dt>
|
||||||
|
<dd className="min-w-0">
|
||||||
|
<EndpointLabel server={server} />
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
<div className="mt-auto border-t border-border/50 pt-3">
|
||||||
|
<ServerActions server={server} isAdmin={isAdmin} onChanged={onChanged} />
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</CardContent>
|
||||||
</tr>
|
</Card>
|
||||||
{error && (
|
</li>
|
||||||
<tr className="bg-destructive/5">
|
|
||||||
<td colSpan={isAdmin ? 7 : 6} className="px-4 py-1.5 text-xs text-destructive text-left">
|
|
||||||
{error}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user