diff --git a/panel/src/components/AppShell.tsx b/panel/src/components/AppShell.tsx index cf487ae..d91dbf0 100644 --- a/panel/src/components/AppShell.tsx +++ b/panel/src/components/AppShell.tsx @@ -1,8 +1,9 @@ import { useEffect, useState } from "react"; 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 * as SelectPrimitive from "@radix-ui/react-select"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; import { cn } from "@/lib/utils"; import { useTier } from "@/lib/tier"; 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 { 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"); return ( @@ -34,6 +43,7 @@ function SectionGroup({ section, isFirst }: { section: NavSection; isFirst: bool key={item.to} to={item.to} end={item.end} + onClick={onNavigate} className={({ isActive }) => cn( "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" onClick={signOut} 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")} 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 ( + + + + + + + +
+ + + {t("brand_name")} + + + + +
+ +
+ {/* The strip's sign-out button grows to a thumb-sized target below md. */} + +
+
+
+ + ); +} + export function AppShell() { const { isAdmin, isOwner } = useTier(); const { pathname } = useLocation(); @@ -222,8 +286,9 @@ export function AppShell() {
-
+
+ {t("common:brand_name")}
@@ -232,7 +297,7 @@ export function AppShell() {
-
+
{/* A crash on one page leaves the navigation usable; moving to another route clears it. */} diff --git a/panel/src/components/PhaseBadge.tsx b/panel/src/components/PhaseBadge.tsx index 15c1b1e..fae5b50 100644 --- a/panel/src/components/PhaseBadge.tsx +++ b/panel/src/components/PhaseBadge.tsx @@ -55,7 +55,7 @@ export function phaseVariant(phase: Phase): BadgeVariant { export function PhaseBadge({ phase }: { phase: Phase }) { const { t } = useTranslation(); return ( - + - + {/* Two cards share a phone's width, so padding, icon and figure step down + below sm to keep a value like "50 / 240" whole. */} +
- +
- + +
- + {formatBytes(b.size_bytes)} - + {expired ? t("expired") : t("expires_in", { when: formatRelative(b.expires_at, now, locale) })} {showOwner && ( - + {b.former_owner ? ( @@ -119,7 +124,7 @@ function BackupRow({ )} )} - + {b.corrupt ? ( {t("corrupt_short")} @@ -644,8 +649,8 @@ export function ServerBackups() { <>
- - +
+ @@ -654,7 +659,7 @@ export function ServerBackups() { - + {all.map((b) => ( +
) : ( <> - {/* Clean Table Card */} - + {/* Cards below xl (two per row from md); the table needs about + 1000px of content width for all its columns, which the page + only has from xl beside the sidebar. */} +
    + {paged.map((s) => ( + + ))} +
+
-
{t("col_created")} {t("col_size")}{t("col_actions")}
+
- - + {isAdmin && } @@ -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, - cfg, isAdmin, onChanged, + className, }: { server: UnifiedServer; - cfg: RuntimeConfig; isAdmin: boolean; onChanged: () => void; + className?: string; }) { - const { t } = useTranslation("ops"); const { t: ts } = useTranslation("servers"); const [busy, setBusy] = useState(null); const [confirmOpen, setConfirmOpen] = useState(false); @@ -421,152 +434,255 @@ function ServerRow({ } } - const live = isLive(server.phase); - const host = server.subdomain ? hostFor(server.subdomain, cfg) : ""; - const endpoint = server.ready && server.endpointAddress ? server.endpointAddress : null; + if (server.system) { + // A system service carries a reserved name that every per-server route + // rejects, so offer no actions — just the honest label. + return ( + + {ts("system_service")} + + ); + } + const live = isLive(server.phase); return ( - <> - - - - {isAdmin && ( - - )} - - - - + + {isAdmin && ( + + )} + + + + + + ); +} + +// 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 ( +
  • + + +
    +
    +
    {server.name}
    + {host && } +
    + +
    +
    + {isAdmin && ( <> - - {(server.owned || isAdmin) && ( - - - - )} +
    {t("fleet_col_owner")}
    +
    + +
    )} +
    {t("fleet_col_players")}
    +
    + +
    + {server.autostartPolicy && ( + <> +
    {t("fleet_col_policy")}
    +
    {t(POLICY_KEY[server.autostartPolicy])}
    + + )} +
    {t("fleet_col_endpoint")}
    +
    + +
    +
    +
    +
    - - - {error && ( -
  • - - - )} - + + + ); }
    {t("fleet_col_status")}
    {t("fleet_col_server")}{t("fleet_col_owner")}{t("fleet_col_players")}
    - - -
    {server.name}
    - {host && ( - +
    - {server.system ? ( - - {ts("system_service")} - - ) : server.owner ? ( - - - - {server.owner} - - - ) : ( - {t("fleet_unclaimed")} - )} - - {live ? ( - - - {server.playersOnline} - / {server.playersMax} - - ) : ( - — - )} - - {server.autostartPolicy ? ( - - {t(POLICY_KEY[server.autostartPolicy])} - - ) : ( - — - )} - - {endpoint ? ( - - {endpoint} - - ) : ( - {t("fleet_endpoint_idle")} - )} - -
    - {server.system ? ( - // A system service carries a reserved name that every per-server route - // rejects, so offer no actions — just the honest label. - - {ts("system_service")} - - ) : server.claimable && !server.owned ? ( - <> - + + + + + + {ts("claim_server_title")} + + {ts("claim_server_desc", { name: server.name })} + + + setConfirmOpen(false)} + onConfirm={async () => { + await act("claim", () => api.claim(server.name)); + setConfirmOpen(false); + }} disabled={busy !== null} - onClick={() => setConfirmOpen(true)} - className="text-primary hover:text-primary hover:bg-primary/5 border-primary/20" - > - {ts("claim")} - - + + ) : ( + <> + + {(server.owned || isAdmin) && ( + + + + )} + + )} +
    + {error && ( +

    + {error} +

    + )} + + ); +} - - - - {ts("claim_server_title")} - - {ts("claim_server_desc", { name: server.name })} - - - setConfirmOpen(false)} - onConfirm={async () => { - await act("claim", () => api.claim(server.name)); - setConfirmOpen(false); - }} - disabled={busy !== null} - loading={busy === "claim"} - cancelLabel={ts("access_cancel")} - confirmLabel={ts("claim")} - confirmVariant="default" - /> - - - - ) : ( +function HostLink({ host }: { host: string }) { + return ( + + {host} + + + ); +} + +function OwnerLabel({ server }: { server: UnifiedServer }) { + const { t } = useTranslation("ops"); + const { t: ts } = useTranslation("servers"); + if (server.system) { + return ( + + {ts("system_service")} + + ); + } + if (server.owner) { + return ( + + + + {server.owner} + + + ); + } + return {t("fleet_unclaimed")}; +} + +function PlayersLabel({ server }: { server: UnifiedServer }) { + if (!isLive(server.phase)) return —; + return ( + + + {server.playersOnline} + / {server.playersMax} + + ); +} + +function EndpointLabel({ server }: { server: UnifiedServer }) { + const { t } = useTranslation("ops"); + const endpoint = server.ready && server.endpointAddress ? server.endpointAddress : null; + if (!endpoint) { + return {t("fleet_endpoint_idle")}; + } + return ( + + {endpoint} + + ); +} + +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 ( +
    +
    + {server.name} + +
    + {host && } +
    + + + + + {server.autostartPolicy ? ( + + {t(POLICY_KEY[server.autostartPolicy])} + + ) : ( + — + )} + + + + +
    - {error} -