Loading panel/src/components/PowerButton.tsx 0 → 100644 +107 −0 Changes for panel/src/components/PowerButton.tsx: 107 added lines, 0 removed lines. Original line number Diff line number Diff line import { useState } from "react"; import { Loader2, Play, Square } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { api, humanizeError } from "@/lib/api"; import { cn } from "@/lib/utils"; interface Props { name: string; /** A pod is up or on its way (Starting/Running/Stopping): offer Stop. */ live: boolean; playersOnline?: number; /** The operator cannot read the player count, so players may be online. */ playerCountUnknown?: boolean; /** Called after the wake or stop was accepted, so the parent refetches. */ onChanged: () => void; size?: "sm" | "default"; className?: string; } // PowerButton starts or stops one server. It is busy while the call runs (no // double send), shows why a call was refused (quota, cooldown, a phase that // moved on), and asks before a stop that would disconnect players: the count // is in the question, and an unreadable count asks too. export function PowerButton({ name, live, playersOnline, playerCountUnknown, onChanged, size = "sm", className, }: Props) { const { t } = useTranslation("servers"); const [busy, setBusy] = useState(false); const [confirming, setConfirming] = useState(false); const [error, setError] = useState<string | null>(null); async function run(kind: "wake" | "stop") { if (busy) return; setBusy(true); setError(null); try { await (kind === "wake" ? api.wake(name) : api.stop(name)); setConfirming(false); onChanged(); } catch (e) { setError(humanizeError(e)); } finally { setBusy(false); } } const players = playersOnline ?? 0; const askFirst = players > 0 || playerCountUnknown === true; const spinner = <Loader2 className="animate-spin" />; let control; if (!live) { control = ( <Button size={size} onClick={() => void run("wake")} disabled={busy}> {busy ? spinner : <Play />} {busy ? t("waking") : t("wake")} </Button> ); } else if (confirming) { control = ( <div role="alert" className="flex flex-wrap items-center justify-end gap-2"> <span className="text-xs font-medium text-foreground"> {players > 0 ? t("stop_confirm_players", { count: players }) : t("stop_confirm_unknown")} </span> <Button size={size} variant="ghost" onClick={() => setConfirming(false)} disabled={busy}> {t("common:cancel")} </Button> <Button size={size} variant="destructive" onClick={() => void run("stop")} disabled={busy}> {busy ? spinner : <Square />} {busy ? t("stopping") : t("stop")} </Button> </div> ); } else { control = ( <Button size={size} variant="destructive" onClick={() => (askFirst ? setConfirming(true) : void run("stop"))} disabled={busy} > {busy ? spinner : <Square />} {busy ? t("stopping") : t("stop")} </Button> ); } return ( <div className={cn("flex flex-col items-end gap-1", className)}> {control} {error && ( <p role="alert" className="max-w-xs text-right text-xs text-destructive"> {error} </p> )} </div> ); } panel/src/components/ServerCard.tsx +13 −23 Changes for panel/src/components/ServerCard.tsx: 13 added lines, 23 removed lines. Original line number Diff line number Diff line import { useState } from "react"; import { Link } from "react-router-dom"; import { Play, Square, Hand, Users, ExternalLink, Terminal } from "lucide-react"; import { Hand, Users, ExternalLink, Terminal } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Card } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { PhaseBadge } from "@/components/PhaseBadge"; import { PowerButton } from "@/components/PowerButton"; import { api, humanizeError } from "@/lib/api"; import { hostFor, type RuntimeConfig } from "@/lib/config"; import type { ServerInfo } from "@/lib/types"; Loading @@ -18,11 +19,11 @@ interface Props { export function ServerCard({ server, cfg, onChanged }: Props) { const { t } = useTranslation("servers"); const [busy, setBusy] = useState<null | "wake" | "stop" | "claim">(null); const [busy, setBusy] = useState<null | "claim">(null); const [error, setError] = useState<string | null>(null); const host = hostFor(server.subdomain, cfg); async function act(kind: "wake" | "stop" | "claim", fn: () => Promise<unknown>) { async function act(kind: "claim", fn: () => Promise<unknown>) { setBusy(kind); setError(null); try { Loading Loading @@ -93,26 +94,15 @@ export function ServerCard({ server, cfg, onChanged }: Props) { <Hand /> {busy === "claim" ? t("claiming") : t("claim")} </Button> )} {server.owned && (running || transitioning ? ( <Button size="sm" variant="destructive" disabled={busy !== null} onClick={() => act("stop", () => api.stop(server.name))} > <Square /> {busy === "stop" ? t("stopping") : t("stop")} </Button> ) : ( <Button size="sm" variant="default" disabled={busy !== null} onClick={() => act("wake", () => api.wake(server.name))} > <Play /> {busy === "wake" ? t("waking") : t("wake")} </Button> ))} {server.owned && ( <PowerButton name={server.name} live={running || transitioning} playersOnline={server.playersOnline} playerCountUnknown={server.playerCountUnknown} onChanged={onChanged} /> )} </div> </Card> ); Loading panel/src/components/States.tsx +7 −7 Changes for panel/src/components/States.tsx: 7 added lines, 7 removed lines. Original line number Diff line number Diff line import { Loader2, AlertTriangle, Inbox, ShieldX, Construction, Moon, SearchX } from "lucide-react"; import { Link } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { PowerButton } from "@/components/PowerButton"; import { humanizeError } from "@/lib/api"; export function Loading({ label }: { label?: string }) { Loading Loading @@ -136,11 +136,13 @@ export function NotYours({ title, body, linkTo = "/servers", linkLabel }: NotYou export interface NotRunningProps { title: string; body: string; wakeLabel: string; onWake: () => void; /** The server to wake from here. */ serverName: string; /** Called once the wake was accepted, so the page refetches its status. */ onWoken: () => void; } export function NotRunning({ title, body, wakeLabel, onWake }: NotRunningProps) { export function NotRunning({ title, body, serverName, onWoken }: NotRunningProps) { return ( <div className="flex flex-col items-center justify-center gap-4 rounded-lg border border-dashed border-border bg-muted/20 py-16 text-center"> <Moon className="h-8 w-8 text-muted-foreground/70" /> Loading @@ -148,9 +150,7 @@ export function NotRunning({ title, body, wakeLabel, onWake }: NotRunningProps) <p className="font-medium">{title}</p> <p className="mx-auto mt-1 max-w-sm text-sm text-muted-foreground">{body}</p> </div> <Button size="sm" onClick={onWake}> {wakeLabel} </Button> <PowerButton name={serverName} live={false} onChanged={onWoken} className="items-center" /> </div> ); } panel/src/i18n/resources/en-US/servers.json +4 −1 Changes for panel/src/i18n/resources/en-US/servers.json: 4 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -178,5 +178,8 @@ "idle_stop_never": "Never", "idle_stop_seconds": "{{count}} s", "idle_stop_minutes": "{{count}} min", "idle_stop_hours": "{{count}} h" "idle_stop_hours": "{{count}} h", "stop_confirm_players_one": "1 player is online and will be disconnected. Stop anyway?", "stop_confirm_players_other": "{{count}} players are online and will be disconnected. Stop anyway?", "stop_confirm_unknown": "The player count can't be read, so players may be online. Stop anyway?" } panel/src/i18n/resources/zh-CN/servers.json +3 −1 Changes for panel/src/i18n/resources/zh-CN/servers.json: 3 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -178,5 +178,7 @@ "idle_stop_never": "从不", "idle_stop_seconds": "{{count}} 秒", "idle_stop_minutes": "{{count}} 分钟", "idle_stop_hours": "{{count}} 小时" "idle_stop_hours": "{{count}} 小时", "stop_confirm_players": "{{count}} 名玩家在线,停服会断开他们。确定停止?", "stop_confirm_unknown": "读不到在线人数,可能有玩家在线。确定停止?" } Loading
panel/src/components/PowerButton.tsx 0 → 100644 +107 −0 Changes for panel/src/components/PowerButton.tsx: 107 added lines, 0 removed lines. Original line number Diff line number Diff line import { useState } from "react"; import { Loader2, Play, Square } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { api, humanizeError } from "@/lib/api"; import { cn } from "@/lib/utils"; interface Props { name: string; /** A pod is up or on its way (Starting/Running/Stopping): offer Stop. */ live: boolean; playersOnline?: number; /** The operator cannot read the player count, so players may be online. */ playerCountUnknown?: boolean; /** Called after the wake or stop was accepted, so the parent refetches. */ onChanged: () => void; size?: "sm" | "default"; className?: string; } // PowerButton starts or stops one server. It is busy while the call runs (no // double send), shows why a call was refused (quota, cooldown, a phase that // moved on), and asks before a stop that would disconnect players: the count // is in the question, and an unreadable count asks too. export function PowerButton({ name, live, playersOnline, playerCountUnknown, onChanged, size = "sm", className, }: Props) { const { t } = useTranslation("servers"); const [busy, setBusy] = useState(false); const [confirming, setConfirming] = useState(false); const [error, setError] = useState<string | null>(null); async function run(kind: "wake" | "stop") { if (busy) return; setBusy(true); setError(null); try { await (kind === "wake" ? api.wake(name) : api.stop(name)); setConfirming(false); onChanged(); } catch (e) { setError(humanizeError(e)); } finally { setBusy(false); } } const players = playersOnline ?? 0; const askFirst = players > 0 || playerCountUnknown === true; const spinner = <Loader2 className="animate-spin" />; let control; if (!live) { control = ( <Button size={size} onClick={() => void run("wake")} disabled={busy}> {busy ? spinner : <Play />} {busy ? t("waking") : t("wake")} </Button> ); } else if (confirming) { control = ( <div role="alert" className="flex flex-wrap items-center justify-end gap-2"> <span className="text-xs font-medium text-foreground"> {players > 0 ? t("stop_confirm_players", { count: players }) : t("stop_confirm_unknown")} </span> <Button size={size} variant="ghost" onClick={() => setConfirming(false)} disabled={busy}> {t("common:cancel")} </Button> <Button size={size} variant="destructive" onClick={() => void run("stop")} disabled={busy}> {busy ? spinner : <Square />} {busy ? t("stopping") : t("stop")} </Button> </div> ); } else { control = ( <Button size={size} variant="destructive" onClick={() => (askFirst ? setConfirming(true) : void run("stop"))} disabled={busy} > {busy ? spinner : <Square />} {busy ? t("stopping") : t("stop")} </Button> ); } return ( <div className={cn("flex flex-col items-end gap-1", className)}> {control} {error && ( <p role="alert" className="max-w-xs text-right text-xs text-destructive"> {error} </p> )} </div> ); }
panel/src/components/ServerCard.tsx +13 −23 Changes for panel/src/components/ServerCard.tsx: 13 added lines, 23 removed lines. Original line number Diff line number Diff line import { useState } from "react"; import { Link } from "react-router-dom"; import { Play, Square, Hand, Users, ExternalLink, Terminal } from "lucide-react"; import { Hand, Users, ExternalLink, Terminal } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Card } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { PhaseBadge } from "@/components/PhaseBadge"; import { PowerButton } from "@/components/PowerButton"; import { api, humanizeError } from "@/lib/api"; import { hostFor, type RuntimeConfig } from "@/lib/config"; import type { ServerInfo } from "@/lib/types"; Loading @@ -18,11 +19,11 @@ interface Props { export function ServerCard({ server, cfg, onChanged }: Props) { const { t } = useTranslation("servers"); const [busy, setBusy] = useState<null | "wake" | "stop" | "claim">(null); const [busy, setBusy] = useState<null | "claim">(null); const [error, setError] = useState<string | null>(null); const host = hostFor(server.subdomain, cfg); async function act(kind: "wake" | "stop" | "claim", fn: () => Promise<unknown>) { async function act(kind: "claim", fn: () => Promise<unknown>) { setBusy(kind); setError(null); try { Loading Loading @@ -93,26 +94,15 @@ export function ServerCard({ server, cfg, onChanged }: Props) { <Hand /> {busy === "claim" ? t("claiming") : t("claim")} </Button> )} {server.owned && (running || transitioning ? ( <Button size="sm" variant="destructive" disabled={busy !== null} onClick={() => act("stop", () => api.stop(server.name))} > <Square /> {busy === "stop" ? t("stopping") : t("stop")} </Button> ) : ( <Button size="sm" variant="default" disabled={busy !== null} onClick={() => act("wake", () => api.wake(server.name))} > <Play /> {busy === "wake" ? t("waking") : t("wake")} </Button> ))} {server.owned && ( <PowerButton name={server.name} live={running || transitioning} playersOnline={server.playersOnline} playerCountUnknown={server.playerCountUnknown} onChanged={onChanged} /> )} </div> </Card> ); Loading
panel/src/components/States.tsx +7 −7 Changes for panel/src/components/States.tsx: 7 added lines, 7 removed lines. Original line number Diff line number Diff line import { Loader2, AlertTriangle, Inbox, ShieldX, Construction, Moon, SearchX } from "lucide-react"; import { Link } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { PowerButton } from "@/components/PowerButton"; import { humanizeError } from "@/lib/api"; export function Loading({ label }: { label?: string }) { Loading Loading @@ -136,11 +136,13 @@ export function NotYours({ title, body, linkTo = "/servers", linkLabel }: NotYou export interface NotRunningProps { title: string; body: string; wakeLabel: string; onWake: () => void; /** The server to wake from here. */ serverName: string; /** Called once the wake was accepted, so the page refetches its status. */ onWoken: () => void; } export function NotRunning({ title, body, wakeLabel, onWake }: NotRunningProps) { export function NotRunning({ title, body, serverName, onWoken }: NotRunningProps) { return ( <div className="flex flex-col items-center justify-center gap-4 rounded-lg border border-dashed border-border bg-muted/20 py-16 text-center"> <Moon className="h-8 w-8 text-muted-foreground/70" /> Loading @@ -148,9 +150,7 @@ export function NotRunning({ title, body, wakeLabel, onWake }: NotRunningProps) <p className="font-medium">{title}</p> <p className="mx-auto mt-1 max-w-sm text-sm text-muted-foreground">{body}</p> </div> <Button size="sm" onClick={onWake}> {wakeLabel} </Button> <PowerButton name={serverName} live={false} onChanged={onWoken} className="items-center" /> </div> ); }
panel/src/i18n/resources/en-US/servers.json +4 −1 Changes for panel/src/i18n/resources/en-US/servers.json: 4 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -178,5 +178,8 @@ "idle_stop_never": "Never", "idle_stop_seconds": "{{count}} s", "idle_stop_minutes": "{{count}} min", "idle_stop_hours": "{{count}} h" "idle_stop_hours": "{{count}} h", "stop_confirm_players_one": "1 player is online and will be disconnected. Stop anyway?", "stop_confirm_players_other": "{{count}} players are online and will be disconnected. Stop anyway?", "stop_confirm_unknown": "The player count can't be read, so players may be online. Stop anyway?" }
panel/src/i18n/resources/zh-CN/servers.json +3 −1 Changes for panel/src/i18n/resources/zh-CN/servers.json: 3 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -178,5 +178,7 @@ "idle_stop_never": "从不", "idle_stop_seconds": "{{count}} 秒", "idle_stop_minutes": "{{count}} 分钟", "idle_stop_hours": "{{count}} 小时" "idle_stop_hours": "{{count}} 小时", "stop_confirm_players": "{{count}} 名玩家在线,停服会断开他们。确定停止?", "stop_confirm_unknown": "读不到在线人数,可能有玩家在线。确定停止?" }