refactor(panel): 抽取 10 个公共组件,消除 ~150 处重复代码

新增组件:
- PageHeader: 统一页面头部(14 个页面)
- StatCard: 统一数值统计卡片(6 个页面)
- MessageLine: 统一错误/成功提示条(16 处)
- ConfirmFooter: 统一 Dialog 底部按钮(8 个文件)
- SearchInput: 统一搜索框(5 个页面)
- SubmissionStatusBadge: 统一提交状态徽章
- RoleBadge / UserStatusBadge: 统一用户角色/状态徽章
- BackLink: 统一返回导航链接(5 个页面)
- InlineConfirm: 统一二次确认交互(3 个 section)

修复:
- Pagination 图标尺寸统一 h-4 w-4
- 右上角按钮统一 size=sm gap-1.5
- StatCard 默认值恢复 text-2xl(24px)

States.tsx 新增 NotYours / NotRunning 通用状态组件

已有文件 -659 行,新组件 +353 行,净减 306 行
This commit is contained in:
Lemon-miaow committed 2026-07-05 01:15:03 +08:00
1 parent 7d27640c07
commit bd49313e7e
32 files changed
+772 -1078

No files matched your search

+2 -9
View File
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Loading, ErrorState } from "@/components/States";
import { PageHeader } from "@/components/PageHeader";
import { api, humanizeError } from "@/lib/api";
import { useAsync } from "@/lib/hooks";
import { useTier } from "@/lib/tier";
@@ -229,15 +230,7 @@ export function Account() {
return (
<>
<div className="flex items-center gap-3">
<UserRound className="h-6 w-6 text-primary" />
<div>
<h1 className="text-2xl font-semibold tracking-tight">{t("title")}</h1>
<p className="text-sm text-muted-foreground">
{t("subtitle")}
</p>
</div>
</div>
<PageHeader icon={UserRound} title={t("title")} subtitle={t("subtitle")} className="mb-6" />
<Card>
<CardHeader>
+6 -34
View File
@@ -18,6 +18,8 @@ import { useTranslation } from "react-i18next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Loading, ErrorState } from "@/components/States";
import { StatCard } from "@/components/StatCard";
import { PageHeader } from "@/components/PageHeader";
import { api } from "@/lib/api";
import { useAsync, useConfig } from "@/lib/hooks";
import type { Phase, ServerInfo, WhitelistImage } from "@/lib/types";
@@ -28,30 +30,6 @@ const VoxelFleet = lazy(() =>
import("@/components/VoxelFleet").then((m) => ({ default: m.VoxelFleet })),
);
function Stat({
icon: Icon,
label,
value,
}: {
icon: typeof Server;
label: string;
value: number | string;
}) {
return (
<Card>
<CardContent className="flex items-center gap-3 p-4">
<div className="rounded-md bg-primary/15 p-2 text-primary">
<Icon className="h-5 w-5" />
</div>
<div>
<div className="text-2xl font-semibold leading-none">{value}</div>
<div className="mt-1 text-xs text-muted-foreground">{label}</div>
</div>
</CardContent>
</Card>
);
}
export function Dashboard() {
const cfg = useConfig();
const { data: servers, error: serversErr, loading: serversLoading, reload: reloadServers } = useAsync(() => api.myServers(), []);
@@ -83,13 +61,7 @@ export function Dashboard() {
return (
<>
<div className="flex items-center gap-3">
<LayoutDashboard className="h-6 w-6 text-primary" />
<div>
<h1 className="text-2xl font-semibold tracking-tight">{t("title")}</h1>
<p className="text-sm text-muted-foreground">{t("subtitle")}</p>
</div>
</div>
<PageHeader icon={LayoutDashboard} title={t("title")} subtitle={t("subtitle")} className="mb-6" />
{loading && !servers ? (
<Loading />
@@ -211,9 +183,9 @@ function FleetView({
<div className="space-y-6">
{/* 顶部 Stat */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<Stat icon={Server} label={t("stat_servers")} value={counts.total} />
<Stat icon={Play} label={t("stat_running")} value={counts.running} />
<Stat icon={Activity} label={t("stat_players_online")} value={counts.players} />
<StatCard icon={Server} label={t("stat_servers")} value={counts.total} accentClass="text-primary bg-primary/15" />
<StatCard icon={Play} label={t("stat_running")} value={counts.running} accentClass="text-primary bg-primary/15" />
<StatCard icon={Activity} label={t("stat_players_online")} value={counts.players} accentClass="text-primary bg-primary/15" />
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-12 items-stretch">
+29 -81
View File
@@ -2,7 +2,6 @@ import { useState, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import {
Upload,
Search,
Loader2,
AlertCircle,
FileText,
@@ -13,15 +12,14 @@ import {
CheckCircle,
CircleSlash,
ClipboardCheck,
type LucideIcon,
} from "lucide-react";
import { SearchInput } from "@/components/SearchInput";
import {
Card,
CardContent,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Label } from "@/components/ui/label";
import {
Dialog,
@@ -31,8 +29,12 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import { MessageLine } from "@/components/MessageLine";
import { Loading, ErrorState, EmptyState } from "@/components/States";
import { Pagination } from "@/components/Pagination";
import { StatCard } from "@/components/StatCard";
import { PageHeader } from "@/components/PageHeader";
import { SubmissionStatusBadge } from "@/components/SubmissionStatusBadge";
import { api, humanizeError } from "@/lib/api";
import { useAsync } from "@/lib/hooks";
import { formatRelative, formatAbsolute } from "@/lib/format";
@@ -49,32 +51,6 @@ function formatBytes(bytes: number, decimals = 2) {
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
}
function StatCard({
icon: Icon,
label,
value,
accentClass,
}: {
icon: LucideIcon;
label: string;
value: number;
accentClass?: string;
}) {
return (
<Card>
<CardContent className="flex items-center gap-3 p-4">
<div className={`rounded-md p-2 bg-muted/30 ${accentClass || "text-muted-foreground"}`}>
<Icon className="h-5 w-5" />
</div>
<div>
<div className="text-2xl font-bold font-mono leading-none">{value}</div>
<div className="mt-1 text-xs text-muted-foreground font-medium">{label}</div>
</div>
</CardContent>
</Card>
);
}
export function MySubmissionsPage() {
const { t, i18n } = useTranslation("submissions");
const locale = i18n.language;
@@ -230,38 +206,30 @@ export function MySubmissionsPage() {
}
};
const STATUS_BADGE_STYLE: Record<SubmissionStatus, string> = {
pending_review: "bg-amber-500/10 text-amber-500 border-amber-500/20",
approved: "bg-emerald-500/10 text-emerald-500 border-emerald-500/20",
rejected: "bg-rose-500/10 text-rose-500 border-rose-500/20",
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Upload className="h-6 w-6 text-primary" />
<div>
<h1 className="text-2xl font-semibold tracking-tight">{t("title")}</h1>
<p className="text-sm text-muted-foreground">{t("subtitle")}</p>
</div>
</div>
{/* Submit Button to Open Dialog */}
<Button
onClick={() => {
setError(null);
setDisplayName("");
setFile(null);
setDialogOpen(true);
}}
className="text-xs gap-1.5"
>
<Plus className="h-4 w-4" />
{t("submit_card_title")}
</Button>
</div>
<PageHeader
icon={Upload}
title={t("title")}
subtitle={t("subtitle")}
actions={
<Button
onClick={() => {
setError(null);
setDisplayName("");
setFile(null);
setDialogOpen(true);
}}
size="sm"
className="gap-1.5"
>
<Plus className="h-4 w-4" />
{t("submit_card_title")}
</Button>
}
className="mb-6"
/>
{/* Stats Cards Row */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
@@ -276,15 +244,7 @@ export function MySubmissionsPage() {
<CardContent className="p-0">
{/* Filters Bar */}
<div className="flex flex-col sm:flex-row gap-3 p-4 border-b">
<div className="relative flex-1 w-full">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder={t("search_placeholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<SearchInput value={search} onChange={setSearch} placeholder={t("search_placeholder")} />
<div className="inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground shrink-0 select-none border border-border/40">
<button
type="button"
@@ -403,14 +363,7 @@ export function MySubmissionsPage() {
{/* Status */}
<div className="col-span-1 md:col-span-3 md:text-center">
<Badge
variant="outline"
className={cn("text-[10px] px-2 py-0.5 font-semibold capitalize", STATUS_BADGE_STYLE[sub.status])}
>
{sub.status === "pending_review" && t("status_pending_review")}
{sub.status === "approved" && t("status_approved")}
{sub.status === "rejected" && t("status_rejected")}
</Badge>
<SubmissionStatusBadge status={sub.status} />
</div>
{/* Created At */}
@@ -494,12 +447,7 @@ export function MySubmissionsPage() {
<form onSubmit={handleFormSubmit} className="space-y-4 pt-2">
{/* Error Banner */}
{error && (
<div className="flex items-start gap-2 text-xs text-destructive font-medium bg-destructive/10 p-2.5 rounded-md">
<AlertCircle className="h-4 w-4 shrink-0 mt-0.5" />
<span>{error}</span>
</div>
)}
{error && <MessageLine kind="error" message={error} compact />}
{/* Display Name Input */}
<div className="space-y-1.5">
+21 -56
View File
@@ -1,30 +1,30 @@
import { useState } from "react";
import { Link, useParams } from "react-router-dom";
import { useParams } from "react-router-dom";
import {
ArrowLeft,
Archive,
CheckCircle2,
Clock,
HardDrive,
Loader2,
RotateCcw,
ShieldX,
UserMinus,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { BackLink } from "@/components/BackLink";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { ConfirmFooter } from "@/components/ConfirmFooter";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { PhaseBadge } from "@/components/PhaseBadge";
import { Loading, ErrorState, EmptyState } from "@/components/States";
import { Loading, ErrorState, EmptyState, NotYours } from "@/components/States";
import { PageHeader } from "@/components/PageHeader";
import { api, humanizeError } from "@/lib/api";
import { useAsync } from "@/lib/hooks";
import { useTier } from "@/lib/tier";
@@ -32,25 +32,6 @@ import { formatBytes, formatRelative, formatAbsolute, isExpired } from "@/lib/fo
import { cn } from "@/lib/utils";
import type { BackupView } from "@/lib/types";
/** NotYours mirrors ServerPlayers: viewing and restoring backups is owner-or-admin
* gated on the backend, but this is a real route anyone can type, so it says so
* plainly and offers a way back rather than rendering blank. */
function NotYours() {
const { t } = useTranslation("backups");
return (
<div className="mx-auto flex max-w-md flex-col items-center justify-center gap-3 py-24 text-center">
<ShieldX className="h-8 w-8 text-destructive" />
<div>
<p className="font-medium">{t("not_yours_title")}</p>
<p className="mt-1 text-sm text-muted-foreground">{t("not_yours_body")}</p>
</div>
<Link to="/servers" className="text-sm font-medium text-primary hover:underline">
{t("my_servers_breadcrumb", { ns: "servers" })}
</Link>
</div>
);
}
/** LatestBackupCard renders the most-recent backup as the restore card — the one a
* restore actually recovers (the list is created_at-descending and the backend's
* LatestBackup selects the same present row), with the restore action beneath. Older
@@ -298,19 +279,13 @@ function RestoreControls({
)}
{error && <p className="text-sm text-destructive">{error}</p>}
<DialogFooter>
<Button variant="ghost" onClick={() => setOpen(false)} disabled={submitting}>
{t("cancel")}
</Button>
<Button variant="destructive" onClick={confirmRestore} disabled={submitting}>
{submitting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RotateCcw className="h-4 w-4" />
)}
{t("restore_confirm_yes")}
</Button>
</DialogFooter>
<ConfirmFooter
onCancel={() => setOpen(false)}
onConfirm={confirmRestore}
loading={submitting}
cancelLabel={t("cancel")}
confirmLabel={t("restore_confirm_yes")}
/>
</DialogContent>
);
@@ -365,12 +340,7 @@ export function ServerBackups() {
const backupsQ = useAsync(() => api.listBackups(), []);
const back = (
<Link
to={`/servers/${name}`}
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-4 w-4" /> {t("back_to_console")}
</Link>
<BackLink to={`/servers/${name}`} label={t("back_to_console")} />
);
if (statusQ.loading && !statusQ.data) {
@@ -407,18 +377,13 @@ export function ServerBackups() {
.sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at));
const header = (
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Archive className="h-6 w-6 text-primary" />
<div>
<h1 className="text-2xl font-semibold tracking-tight">
{statusQ.data.displayName || statusQ.data.name}
</h1>
<p className="text-sm text-muted-foreground">{t("title")}</p>
</div>
</div>
<PhaseBadge phase={statusQ.data.phase} />
</div>
<PageHeader
icon={Archive}
title={statusQ.data.displayName || statusQ.data.name}
subtitle={t("title")}
actions={<PhaseBadge phase={statusQ.data.phase} />}
className="mb-6"
/>
);
return (
@@ -430,7 +395,7 @@ export function ServerBackups() {
) : mineQ.error ? (
<ErrorState error={mineQ.error} onRetry={mineQ.reload} />
) : !owned ? (
<NotYours />
<NotYours title={t("not_yours_title")} body={t("not_yours_body")} />
) : (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">{t("subtitle")}</p>
+26 -36
View File
@@ -1,10 +1,12 @@
import { useState, useRef, useCallback, useLayoutEffect, type KeyboardEvent } from "react";
import { Link, useParams } from "react-router-dom";
import { ArrowLeft, Terminal, Moon, Shield, ShieldAlert, HelpCircle, Loader2, Users, Archive, ChevronRight, type LucideIcon } from "lucide-react";
import { Terminal, Moon, Shield, ShieldAlert, HelpCircle, Loader2, Users, Archive, ChevronRight, type LucideIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { BackLink } from "@/components/BackLink";
import { PhaseBadge } from "@/components/PhaseBadge";
import { PageHeader } from "@/components/PageHeader";
import { LogConsole } from "@/components/LogConsole";
import { Loading, ErrorState } from "@/components/States";
import { api, consoleStreamURL, humanizeError } from "@/lib/api";
@@ -191,12 +193,7 @@ export function ServerConsole() {
return (
<div className="flex flex-col lg:h-[calc(100vh-3.5rem)] lg:min-h-[35rem] gap-4 min-h-0">
<div>
<Link
to="/servers"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-4 w-4" /> {t("my_servers_breadcrumb")}
</Link>
<BackLink to="/servers" label={t("my_servers_breadcrumb")} />
</div>
{loading && !data ? (
@@ -205,37 +202,30 @@ export function ServerConsole() {
<ErrorState error={error} onRetry={reload} />
) : data ? (
<>
<div className="flex flex-wrap items-center justify-between gap-3 shrink-0">
<div className="flex items-center gap-3">
<Terminal className="h-6 w-6 text-primary" />
<div>
<h1 className="text-2xl font-semibold tracking-tight">
{data.displayName || data.name}
</h1>
{cfg && (
<p className="text-sm text-muted-foreground">
{hostFor(data.subdomain, cfg)}
</p>
<PageHeader
icon={Terminal}
title={data.displayName || data.name}
subtitle={cfg ? hostFor(data.subdomain, cfg) : undefined}
actions={
<div className="flex items-center gap-2">
<PhaseBadge phase={data.phase} />
{streamable ? (
<Button
variant="destructive"
size="sm"
onClick={() => api.stop(name).then(reload)}
>
{t("stop")}
</Button>
) : (
<Button variant="default" size="sm" onClick={() => api.wake(name).then(reload)}>
{t("wake")}
</Button>
)}
</div>
</div>
<div className="flex items-center gap-2">
<PhaseBadge phase={data.phase} />
{streamable ? (
<Button
variant="destructive"
size="sm"
onClick={() => api.stop(name).then(reload)}
>
{t("stop")}
</Button>
) : (
<Button variant="default" size="sm" onClick={() => api.wake(name).then(reload)}>
{t("wake")}
</Button>
)}
</div>
</div>
}
className="mb-6"
/>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-4 flex-1 lg:min-h-0 min-h-0">
{/* Left/Main column: Console */}
+15 -57
View File
@@ -1,7 +1,8 @@
import { useState } from "react";
import { Link, useParams } from "react-router-dom";
import { ArrowLeft, Moon, Shield, ShieldX, Users, Search, Plus, Trash2, Undo, History, Loader2 } from "lucide-react";
import { useParams } from "react-router-dom";
import { Shield, Users, Search, Plus, Trash2, Undo, History, Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { BackLink } from "@/components/BackLink";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
@@ -15,7 +16,8 @@ import {
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { PhaseBadge } from "@/components/PhaseBadge";
import { Loading, ErrorState } from "@/components/States";
import { Loading, ErrorState, NotYours, NotRunning } from "@/components/States";
import { PageHeader } from "@/components/PageHeader";
import { api, humanizeError } from "@/lib/api";
import { useAsync } from "@/lib/hooks";
import { useTier } from "@/lib/tier";
@@ -51,40 +53,6 @@ interface ActionHistoryItem {
reverted?: boolean;
}
function NotYours() {
const { t } = useTranslation("servers");
return (
<div className="mx-auto flex max-w-md flex-col items-center justify-center gap-3 py-24 text-center">
<ShieldX className="h-8 w-8 text-destructive" />
<div>
<p className="font-medium">{t("players_not_yours_title")}</p>
<p className="mt-1 text-sm text-muted-foreground">{t("players_not_yours_body")}</p>
</div>
<Link to="/servers" className="text-sm font-medium text-primary hover:underline">
{t("my_servers_breadcrumb")}
</Link>
</div>
);
}
function NotRunning({ onWake }: { onWake: () => void }) {
const { t } = useTranslation("servers");
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" />
<div>
<p className="font-medium">{t("players_not_running_title")}</p>
<p className="mx-auto mt-1 max-w-sm text-sm text-muted-foreground">
{t("players_not_running_body")}
</p>
</div>
<Button size="sm" onClick={onWake}>
{t("wake")}
</Button>
</div>
);
}
export function ServerLuckPerms() {
const { name = "" } = useParams();
const { t } = useTranslation("servers");
@@ -298,12 +266,7 @@ export function ServerLuckPerms() {
};
const back = (
<Link
to={`/servers/${name}`}
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground mb-4"
>
<ArrowLeft className="h-4 w-4" /> {t("luckperms_back_to_console")}
</Link>
<BackLink to={`/servers/${name}`} label={t("luckperms_back_to_console")} />
);
if (loading && !data) {
@@ -329,18 +292,13 @@ export function ServerLuckPerms() {
const phase: Phase = data.phase;
const header = (
<div className="flex flex-wrap items-center justify-between gap-3 mb-6">
<div className="flex items-center gap-3">
<Shield className="h-6 w-6 text-primary" />
<div>
<h1 className="text-2xl font-semibold tracking-tight">
{data.displayName || data.name}
</h1>
<p className="text-sm text-muted-foreground">{t("luckperms_desc")}</p>
</div>
</div>
<PhaseBadge phase={phase} />
</div>
<PageHeader
icon={Shield}
title={data.displayName || data.name}
subtitle={t("luckperms_desc")}
actions={<PhaseBadge phase={phase} />}
className="mb-6"
/>
);
return (
@@ -352,9 +310,9 @@ export function ServerLuckPerms() {
) : mineError ? (
<ErrorState error={mineError} onRetry={reloadMine} />
) : !owned ? (
<NotYours />
<NotYours title={t("players_not_yours_title")} body={t("players_not_yours_body")} />
) : phase !== "Running" ? (
<NotRunning onWake={() => api.wake(name).then(reload)} />
<NotRunning title={t("players_not_running_title")} body={t("players_not_running_body")} wakeLabel={t("wake")} onWake={() => api.wake(name).then(reload)} />
) : (
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6 items-start">
{/* Left Directory Sidebar: Single card for Search + Online Players */}
+15 -65
View File
@@ -1,9 +1,11 @@
import { Link, useParams } from "react-router-dom";
import { ArrowLeft, Moon, ShieldX, Users } from "lucide-react";
import { useParams } from "react-router-dom";
import { Users } from "lucide-react";
import { useTranslation } from "react-i18next";
import { BackLink } from "@/components/BackLink";
import { Button } from "@/components/ui/button";
import { PhaseBadge } from "@/components/PhaseBadge";
import { Loading, ErrorState } from "@/components/States";
import { Loading, ErrorState, NotYours, NotRunning } from "@/components/States";
import { PageHeader } from "@/components/PageHeader";
import { OnlineSection } from "@/components/players/OnlineSection";
import { WhitelistSection } from "@/components/players/WhitelistSection";
import { BansSection } from "@/components/players/BansSection";
@@ -12,48 +14,6 @@ import { useAsync } from "@/lib/hooks";
import { useTier } from "@/lib/tier";
import type { Phase } from "@/lib/types";
/** NotYours is the explicit "this server isn't yours to manage" state. Player
* management is owner-or-admin gated on the backend, but this page is a real route:
* a non-owner (or anyone who types the URL) reaches it, so it must say so plainly
* and offer a way back — never render blank. */
function NotYours() {
const { t } = useTranslation("servers");
return (
<div className="mx-auto flex max-w-md flex-col items-center justify-center gap-3 py-24 text-center">
<ShieldX className="h-8 w-8 text-destructive" />
<div>
<p className="font-medium">{t("players_not_yours_title")}</p>
<p className="mt-1 text-sm text-muted-foreground">{t("players_not_yours_body")}</p>
</div>
<Link to="/servers" className="text-sm font-medium text-primary hover:underline">
{t("my_servers_breadcrumb")}
</Link>
</div>
);
}
/** NotRunning is the page-level asleep state. Every section here needs a live RCON
* connection, so a stopped server has nothing to manage — the whole page collapses
* to one honest "wake it first" panel with the wake control, rather than three
* separately-empty sections. */
function NotRunning({ onWake }: { onWake: () => void }) {
const { t } = useTranslation("servers");
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" />
<div>
<p className="font-medium">{t("players_not_running_title")}</p>
<p className="mx-auto mt-1 max-w-sm text-sm text-muted-foreground">
{t("players_not_running_body")}
</p>
</div>
<Button size="sm" onClick={onWake}>
{t("wake")}
</Button>
</div>
);
}
/** ServerPlayers is the per-server player-management subpage (/servers/:name/players):
* whitelist today, online roster and bans as they land. It owns its own gating —
* ownership (from /me/servers, since GET status never carries `owned`) and server
@@ -73,12 +33,7 @@ export function ServerPlayers() {
);
const back = (
<Link
to={`/servers/${name}`}
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-4 w-4" /> {t("players_back_to_console")}
</Link>
<BackLink to={`/servers/${name}`} label={t("players_back_to_console")} />
);
if (loading && !data) {
@@ -112,18 +67,13 @@ export function ServerPlayers() {
const phase: Phase = data.phase;
const header = (
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Users className="h-6 w-6 text-primary" />
<div>
<h1 className="text-2xl font-semibold tracking-tight">
{data.displayName || data.name}
</h1>
<p className="text-sm text-muted-foreground">{t("players_title")}</p>
</div>
</div>
<PhaseBadge phase={phase} />
</div>
<PageHeader
icon={Users}
title={data.displayName || data.name}
subtitle={t("players_title")}
actions={<PhaseBadge phase={phase} />}
className="mb-6"
/>
);
return (
@@ -135,9 +85,9 @@ export function ServerPlayers() {
) : mineError ? (
<ErrorState error={mineError} onRetry={reloadMine} />
) : !owned ? (
<NotYours />
<NotYours title={t("players_not_yours_title")} body={t("players_not_yours_body")} />
) : phase !== "Running" ? (
<NotRunning onWake={() => api.wake(name).then(reload)} />
<NotRunning title={t("players_not_running_title")} body={t("players_not_running_body")} wakeLabel={t("wake")} onWake={() => api.wake(name).then(reload)} />
) : (
<div className="space-y-4">
{/* Ordered as a who-may-be-here gradient: who is on right now → who may
+29 -88
View File
@@ -1,21 +1,25 @@
import { useState, useMemo } from "react";
import { Boxes, CheckCircle2, CircleSlash, Plus, Trash2, Loader2, Search, CheckCircle, type LucideIcon } from "lucide-react";
import { Boxes, CheckCircle2, CircleSlash, Plus, Trash2, Loader2, Wrench } from "lucide-react";
import { SearchInput } from "@/components/SearchInput";
import { StatCard } from "@/components/StatCard";
import { PageHeader } from "@/components/PageHeader";
import { useTranslation } from "react-i18next";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ConfirmFooter } from "@/components/ConfirmFooter";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import { MessageLine } from "@/components/MessageLine";
import { Loading, ErrorState, EmptyState } from "@/components/States";
import { Pagination } from "@/components/Pagination";
import { api, humanizeError } from "@/lib/api";
@@ -23,32 +27,6 @@ import { useAsync } from "@/lib/hooks";
const PAGE_SIZE = 10;
function StatCard({
icon: Icon,
label,
value,
accentClass,
}: {
icon: LucideIcon;
label: string;
value: number;
accentClass?: string;
}) {
return (
<Card>
<CardContent className="flex items-center gap-3 p-4">
<div className={`rounded-md p-2 bg-muted/30 ${accentClass || "text-muted-foreground"}`}>
<Icon className="h-5 w-5" />
</div>
<div>
<div className="text-2xl font-bold font-mono leading-none">{value}</div>
<div className="mt-1 text-xs text-muted-foreground font-medium">{label}</div>
</div>
</CardContent>
</Card>
);
}
export function ImageAdmin() {
const { t } = useTranslation("admin");
const { data, error, loading, reload } = useAsync(() => api.listImages(), []);
@@ -71,8 +49,10 @@ export function ImageAdmin() {
const total = images.length;
const enabled = images.filter((img) => img.enabled).length;
const disabled = total - enabled;
return { total, enabled, disabled };
const buildCount = total;
return { total, enabled, disabled, buildCount };
}, [images]);
const { total, enabled, disabled, buildCount } = stats;
// Filtered & Paginated Images
const filteredImages = useMemo(() => {
@@ -111,8 +91,8 @@ export function ImageAdmin() {
return filteredImages.slice(start, start + PAGE_SIZE);
}, [filteredImages, page]);
async function handleAdd(e: React.FormEvent) {
e.preventDefault();
async function handleAdd(e?: React.FormEvent) {
e?.preventDefault();
if (!newImageRef.trim()) return;
setSubmitting(true);
setSubmitError(null);
@@ -143,19 +123,7 @@ export function ImageAdmin() {
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Boxes className="h-6 w-6 text-primary" />
<div>
<h1 className="text-2xl font-semibold tracking-tight">{t("images_title")}</h1>
<p className="text-sm text-muted-foreground">
{t("images_subtitle")}
</p>
</div>
</div>
{/* Dialog for Add External Image */}
<PageHeader icon={Boxes} title={t("images_title")} subtitle={t("images_subtitle")} actions={
<Dialog open={dialogOpen} onOpenChange={(o) => {
setDialogOpen(o);
if (!o) {
@@ -164,7 +132,7 @@ export function ImageAdmin() {
}
}}>
<DialogTrigger asChild>
<Button className="gap-1.5 text-xs font-semibold shrink-0">
<Button size="sm" className="gap-1.5 shrink-0">
<Plus className="h-4 w-4" /> {t("add_image_btn")}
</Button>
</DialogTrigger>
@@ -189,45 +157,26 @@ export function ImageAdmin() {
required
/>
</div>
{submitError && (
<p className="text-xs text-destructive mt-1 font-medium bg-destructive/10 p-2.5 rounded-md">
{submitError}
</p>
)}
<DialogFooter className="gap-2 pt-2">
<Button
type="button"
variant="outline"
onClick={() => setDialogOpen(false)}
disabled={submitting}
className="text-xs"
>
{t("common:cancel")}
</Button>
<Button
type="submit"
variant={newImageRef.trim() ? "default" : "outline"}
disabled={submitting || !newImageRef.trim()}
className="text-xs gap-1.5"
>
{submitting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Plus className="h-4 w-4" />
)}
{t("add_image_btn")}
</Button>
</DialogFooter>
{submitError && <MessageLine kind="error" message={submitError} compact />}
<ConfirmFooter
onCancel={() => setDialogOpen(false)}
onConfirm={() => handleAdd()}
disabled={submitting || !newImageRef.trim()}
loading={submitting}
cancelLabel={t("common:cancel")}
confirmLabel={t("add_image_btn")}
/>
</form>
</DialogContent>
</Dialog>
</div>
} className="mb-6" />
{/* Stats Cards Row */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<StatCard icon={Boxes} label="总镜像数" value={stats.total} />
<StatCard icon={CheckCircle} label="已启用" value={stats.enabled} accentClass="text-primary bg-primary/10" />
<StatCard icon={CircleSlash} label="已禁用" value={stats.disabled} accentClass="text-muted-foreground bg-muted/20" />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
<StatCard icon={Boxes} label={t("filter_all")} value={total} accentClass="text-primary bg-primary/10" />
<StatCard icon={CheckCircle2} label={t("enabled")} value={enabled} accentClass="text-emerald-500 bg-emerald-500/10" />
<StatCard icon={CircleSlash} label={t("disabled")} value={disabled} accentClass="text-zinc-500 bg-zinc-500/10" />
<StatCard icon={Wrench} label={t("builds_title")} value={buildCount} accentClass="text-primary bg-primary/10" />
</div>
{/* Whitelist Table Card */}
@@ -235,15 +184,7 @@ export function ImageAdmin() {
<CardContent className="p-0">
{/* Filters Bar */}
<div className="flex flex-col sm:flex-row gap-3 p-4 border-b">
<div className="relative flex-1 w-full">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="搜索镜像名称或来源..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<SearchInput value={search} onChange={setSearch} placeholder="搜索镜像名称或来源..." />
<div className="inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground shrink-0 select-none border border-border/40">
<button
type="button"
+20 -56
View File
@@ -1,16 +1,17 @@
import { useEffect, useState, useMemo, Fragment } from "react";
import { Cpu, Play, Terminal, Loader2, XCircle, AlertCircle, Plus, Search, ChevronDown } from "lucide-react";
import { Cpu, Terminal, Loader2, XCircle, AlertCircle, Plus, ChevronDown } from "lucide-react";
import { useTranslation } from "react-i18next";
import { SearchInput } from "@/components/SearchInput";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ConfirmFooter } from "@/components/ConfirmFooter";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
@@ -18,6 +19,8 @@ import {
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { LogConsole } from "@/components/LogConsole";
import { cn } from "@/lib/utils";
import { PageHeader } from "@/components/PageHeader";
import { MessageLine } from "@/components/MessageLine";
import { Loading, EmptyState } from "@/components/States";
import { Pagination } from "@/components/Pagination";
import { api, buildLogsStreamURL, humanizeError } from "@/lib/api";
@@ -216,8 +219,8 @@ export function ImageBuildPage() {
}, [filteredBuilds, page]);
// Form submission handler
const handleTrigger = async (e: React.FormEvent) => {
e.preventDefault();
const handleTrigger = async (e?: React.FormEvent) => {
e?.preventDefault();
if (!imageRef.trim() || !contextRef.trim() || !dockerfile.trim()) return;
setTriggering(true);
@@ -269,17 +272,7 @@ export function ImageBuildPage() {
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Cpu className="h-6 w-6 text-primary" />
<div>
<h1 className="text-2xl font-semibold tracking-tight">{t("builds_title")}</h1>
<p className="text-sm text-muted-foreground">{t("builds_subtitle")}</p>
</div>
</div>
{/* Dialog for Trigger Build */}
<PageHeader icon={Cpu} title={t("builds_title")} subtitle={t("builds_subtitle")} actions={
<Dialog open={dialogOpen} onOpenChange={(o) => {
setDialogOpen(o);
if (!o) {
@@ -288,7 +281,7 @@ export function ImageBuildPage() {
}
}}>
<DialogTrigger asChild>
<Button className="gap-1.5 text-xs font-semibold shrink-0">
<Button size="sm" className="gap-1.5 shrink-0">
<Plus className="h-4 w-4" /> {t("trigger_build_title")}
</Button>
</DialogTrigger>
@@ -421,56 +414,27 @@ export function ImageBuildPage() {
/>
</div>
{triggerError && (
<div className="flex items-center gap-2 text-xs text-destructive bg-destructive/10 p-2.5 rounded-md font-medium">
<AlertCircle className="h-4 w-4 shrink-0" />
<span>{triggerError}</span>
</div>
)}
{triggerError && <MessageLine kind="error" message={triggerError} compact />}
<DialogFooter className="gap-2 pt-2">
<Button
type="button"
variant="outline"
onClick={() => setDialogOpen(false)}
disabled={triggering}
className="text-xs"
>
{t("common:cancel")}
</Button>
<Button
type="submit"
variant={imageRef.trim() && contextRef.trim() && dockerfile.trim() ? "default" : "outline"}
disabled={triggering || !imageRef.trim() || !contextRef.trim() || !dockerfile.trim()}
className="text-xs gap-1.5"
>
{triggering ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Play className="h-4 w-4" />
)}
{t("trigger_build_btn")}
</Button>
</DialogFooter>
<ConfirmFooter
onCancel={() => setDialogOpen(false)}
onConfirm={() => handleTrigger()}
disabled={triggering || !imageRef.trim() || !contextRef.trim() || !dockerfile.trim()}
loading={triggering}
cancelLabel={t("common:cancel")}
confirmLabel={t("trigger_build_btn")}
/>
</form>
</DialogContent>
</Dialog>
</div>
} className="mb-6" />
{/* Build History Table Card */}
<Card className="w-full">
<CardContent className="p-0">
{/* Filters Bar */}
<div className="p-4 border-b">
<div className="relative w-full">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="搜索构建 ID、镜像引用或状态..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<SearchInput value={search} onChange={setSearch} placeholder="搜索构建 ID、镜像引用或状态..." />
</div>
{/* List Content */}
+22 -95
View File
@@ -1,20 +1,24 @@
import { useState, useMemo } from "react";
import { ClipboardCheck, CheckCircle2, CircleSlash, Search, ChevronDown, ChevronUp, Check, X, Loader2 } from "lucide-react";
import { ClipboardCheck, CheckCircle2, CircleSlash, ChevronDown, ChevronUp, Check, X, Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { StatCard } from "@/components/StatCard";
import { PageHeader } from "@/components/PageHeader";
import { SubmissionStatusBadge } from "@/components/SubmissionStatusBadge";
import { SearchInput } from "@/components/SearchInput";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ConfirmFooter } from "@/components/ConfirmFooter";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import { MessageLine } from "@/components/MessageLine";
import { Loading, ErrorState, EmptyState } from "@/components/States";
import { Pagination } from "@/components/Pagination";
import { api, humanizeError } from "@/lib/api";
@@ -24,32 +28,6 @@ import type { Submission, SubmissionStatus } from "@/lib/types";
const PAGE_SIZE = 10;
function StatCard({
icon: Icon,
label,
value,
accentClass,
}: {
icon: any;
label: string;
value: number;
accentClass?: string;
}) {
return (
<Card>
<CardContent className="flex items-center gap-3 p-4">
<div className={cn("rounded-md p-2 bg-muted/30", accentClass || "text-muted-foreground")}>
<Icon className="h-5 w-5" />
</div>
<div>
<div className="text-2xl font-bold font-mono leading-none">{value}</div>
<div className="mt-1 text-xs text-muted-foreground font-medium">{label}</div>
</div>
</CardContent>
</Card>
);
}
export function SubmissionsPage() {
const { t, i18n } = useTranslation("admin");
const locale = i18n.language;
@@ -142,8 +120,8 @@ export function SubmissionsPage() {
setRejectDialogOpen(true);
}
async function handleRejectSubmit(e: React.FormEvent) {
e.preventDefault();
async function handleRejectSubmit(e?: React.FormEvent) {
e?.preventDefault();
if (!rejectingId || !rejectReason.trim()) return;
setBusyId(rejectingId);
setBusyType("reject");
@@ -160,33 +138,12 @@ export function SubmissionsPage() {
}
}
const STATUS_BADGE_STYLE: Record<SubmissionStatus, string> = {
pending_review: "bg-amber-500/10 text-amber-500 border-amber-500/20",
approved: "bg-emerald-500/10 text-emerald-500 border-emerald-500/20",
rejected: "bg-rose-500/10 text-rose-500 border-rose-500/20",
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<ClipboardCheck className="h-6 w-6 text-primary" />
<div>
<h1 className="text-2xl font-semibold tracking-tight">{t("submissions_title")}</h1>
<p className="text-sm text-muted-foreground">
{t("submissions_subtitle")}
</p>
</div>
</div>
</div>
<PageHeader icon={ClipboardCheck} title={t("submissions_title")} subtitle={t("submissions_subtitle")} className="mb-6" />
{/* Action Error Alert */}
{actionError && (
<p className="text-xs text-destructive font-medium bg-destructive/10 p-2.5 rounded-md">
{actionError}
</p>
)}
{actionError && <MessageLine kind="error" message={actionError} compact />}
{/* Stats Cards Row */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
@@ -201,15 +158,7 @@ export function SubmissionsPage() {
<CardContent className="p-0">
{/* Filters Bar */}
<div className="flex flex-col sm:flex-row gap-3 p-4 border-b">
<div className="relative flex-1 w-full">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="搜索模组包名称或提交人..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<SearchInput value={search} onChange={setSearch} placeholder="搜索模组包名称或提交人..." />
<div className="inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground shrink-0 select-none border border-border/40">
<button
type="button"
@@ -332,14 +281,7 @@ export function SubmissionsPage() {
{/* Status */}
<div className="col-span-2 md:text-center py-0.5">
<Badge
variant="outline"
className={cn("text-[10px] px-2 py-0.5 font-semibold capitalize", STATUS_BADGE_STYLE[sub.status])}
>
{sub.status === "pending_review" && t("status_pending_review")}
{sub.status === "approved" && t("status_approved")}
{sub.status === "rejected" && t("status_rejected")}
</Badge>
<SubmissionStatusBadge status={sub.status} />
</div>
{/* Created At */}
@@ -489,30 +431,15 @@ export function SubmissionsPage() {
className="flex min-h-[100px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
<DialogFooter className="gap-2 pt-2">
<Button
type="button"
variant="outline"
onClick={() => setRejectDialogOpen(false)}
disabled={!!busyId}
className="text-xs"
>
{t("common:cancel")}
</Button>
<Button
type="submit"
variant="destructive"
disabled={!!busyId || !rejectReason.trim()}
className="text-xs gap-1.5"
>
{busyId && busyType === "reject" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<X className="h-4 w-4" />
)}
{t("reject_dialog_submit")}
</Button>
</DialogFooter>
<ConfirmFooter
onCancel={() => setRejectDialogOpen(false)}
onConfirm={() => handleRejectSubmit()}
disabled={!!busyId || !rejectReason.trim()}
loading={!!busyId && busyType === "reject"}
cancelLabel={t("common:cancel")}
confirmLabel={t("reject_dialog_submit")}
confirmVariant="destructive"
/>
</form>
</DialogContent>
</Dialog>
+13 -52
View File
@@ -1,6 +1,10 @@
import React, { useState, useEffect } from "react";
import { Clock, Loader2, Calendar, AlertCircle, CheckCircle2, Globe, LucideIcon } from "lucide-react";
import { useState, useEffect } from "react";
import { Clock, Loader2, Calendar, Globe } from "lucide-react";
import { MessageLine } from "@/components/MessageLine";
import { StatCard } from "@/components/StatCard";
import { PageHeader } from "@/components/PageHeader";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -9,7 +13,6 @@ import { Loading, ErrorState } from "@/components/States";
import { api, humanizeError } from "@/lib/api";
import { useAsync } from "@/lib/hooks";
import { formatAbsolute } from "@/lib/format";
import { cn } from "@/lib/utils";
function toLocalDatetimeString(dateOrStr: Date | string | null | undefined): string {
if (!dateOrStr) return "";
@@ -24,36 +27,6 @@ function toLocalDatetimeString(dateOrStr: Date | string | null | undefined): str
return `${YYYY}-${MM}-${DD}T${hh}:${mm}`;
}
function StatCard({
icon: Icon,
label,
value,
accentClass,
valueClass,
}: {
icon: LucideIcon;
label: string;
value: React.ReactNode;
accentClass?: string;
valueClass?: string;
}) {
return (
<Card>
<CardContent className="flex items-center gap-3 p-4">
<div className={cn("rounded-md p-2 bg-muted/30", accentClass || "text-muted-foreground")}>
<Icon className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<div className={cn("text-sm sm:text-base font-bold font-mono leading-tight truncate", valueClass || "text-foreground")} title={typeof value === "string" ? value : undefined}>
{value}
</div>
<div className="mt-1 text-[11px] text-muted-foreground font-medium">{label}</div>
</div>
</CardContent>
</Card>
);
}
export function UpdatesPage() {
const { t, i18n } = useTranslation("admin");
const locale = i18n.language;
@@ -191,16 +164,7 @@ export function UpdatesPage() {
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<Clock className="h-6 w-6 text-primary" />
<div>
<h1 className="text-2xl font-semibold tracking-tight">{t("updates_title")}</h1>
<p className="text-sm text-muted-foreground">
{t("updates_subtitle")}
</p>
</div>
</div>
<PageHeader icon={Clock} title={t("updates_title")} subtitle={t("updates_subtitle")} className="mb-6" />
{/* Stats Cards Row */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
@@ -209,25 +173,28 @@ export function UpdatesPage() {
label={t("updates_stat_status")}
value={statusText}
accentClass={statusAccentClass}
valueClass={statusColorClass}
valueClass={cn("text-sm sm:text-base font-bold font-mono leading-tight truncate", statusColorClass)}
/>
<StatCard
icon={Calendar}
label={t("updates_stat_start")}
value={data?.start ? formatAbsolute(data.start, locale) : "—"}
accentClass="text-primary bg-primary/10"
valueClass="text-sm sm:text-base font-bold font-mono leading-tight truncate"
/>
<StatCard
icon={Calendar}
label={t("updates_stat_end")}
value={data?.end ? formatAbsolute(data.end, locale) : "—"}
accentClass="text-primary bg-primary/10"
valueClass="text-sm sm:text-base font-bold font-mono leading-tight truncate"
/>
<StatCard
icon={Globe}
label={t("updates_stat_timezone")}
value={tz}
accentClass="text-primary bg-primary/10"
valueClass="text-sm sm:text-base font-bold font-mono leading-tight truncate"
/>
</div>
@@ -278,17 +245,11 @@ export function UpdatesPage() {
</div>
{actionError && (
<div className="flex items-center gap-2 rounded-md border border-destructive/20 bg-destructive/10 p-3 text-sm text-destructive">
<AlertCircle className="h-4 w-4 shrink-0" />
<p>{actionError}</p>
</div>
<MessageLine kind="error" message={actionError} />
)}
{successMsg && (
<div className="flex items-center gap-2 rounded-md border border-emerald-500/20 bg-emerald-500/10 p-3 text-sm text-emerald-500">
<CheckCircle2 className="h-4 w-4 shrink-0" />
<p>{successMsg}</p>
</div>
<MessageLine kind="success" message={successMsg} />
)}
<div className="flex flex-wrap gap-3 pt-2">
+45 -154
View File
@@ -1,13 +1,12 @@
import { useState, useEffect } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { MessageLine } from "@/components/MessageLine";
import { RoleBadge } from "@/components/RoleBadge";
import { UserStatusBadge } from "@/components/UserStatusBadge";
import {
ArrowLeft,
UserRound,
Mail,
Shield,
Crown,
Calendar,
Circle,
Key,
Clock,
Trash2,
@@ -15,7 +14,6 @@ import {
PowerOff,
Save,
Loader2,
AlertCircle,
CheckCircle2,
RefreshCw,
Unlink,
@@ -25,6 +23,7 @@ import {
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { BackLink } from "@/components/BackLink";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -35,15 +34,16 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ConfirmFooter } from "@/components/ConfirmFooter";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Loading, ErrorState } from "@/components/States";
import { PageHeader } from "@/components/PageHeader";
import { api, humanizeError } from "@/lib/api";
import { useAsync } from "@/lib/hooks";
import { useTier } from "@/lib/tier";
@@ -70,71 +70,43 @@ export function UserDetailPage() {
return (
<div className="space-y-6">
{/* Back link */}
<button
onClick={() => navigate("/admin/users")}
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-4 w-4" />
{t("users_back_to_list")}
</button>
<BackLink to="/admin/users" label={t("users_back_to_list")} />
{/* Header */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<PageHeader
icon={(
<div className={cn(
"rounded-full p-2",
user.role === "admin" ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground",
)}>
<UserRound className="h-6 w-6" />
</div>
<div>
<h1 className="text-2xl font-semibold tracking-tight">{user.username}</h1>
<div className="flex flex-wrap items-center gap-2 mt-1 text-sm text-muted-foreground">
{user.email && (
<span className="inline-flex items-center gap-1">
<Mail className="h-3.5 w-3.5" />
{user.email}
{user.email_verified && (
<CheckCircle2 className="h-3 w-3 text-emerald-500" />
)}
</span>
)}
)}
title={user.username}
subtitle={(
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
{user.email && (
<span className="inline-flex items-center gap-1">
<Calendar className="h-3.5 w-3.5" />
{formatAbsolute(user.created_at, locale)}
<Mail className="h-3.5 w-3.5" />
{user.email}
{user.email_verified && (
<CheckCircle2 className="h-3 w-3 text-emerald-500" />
)}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
{user.disabled ? (
<span className="inline-flex items-center gap-1 rounded-full bg-destructive/10 px-3 py-1 text-xs font-medium text-destructive">
<Circle className="h-2 w-2 fill-destructive" />
{t("users_status_disabled")}
</span>
) : (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-3 py-1 text-xs font-medium text-emerald-500">
<Circle className="h-2 w-2 fill-emerald-500" />
{t("users_status_active")}
</span>
)}
<span className={cn(
"inline-flex items-center gap-1 rounded-full px-3 py-1 text-xs font-medium",
user.role === "owner"
? "bg-yellow-500/10 text-yellow-600"
: user.role === "admin"
? "bg-primary/10 text-primary"
: "bg-muted text-muted-foreground",
)}>
{user.role === "owner" ? (
<Crown className="h-3 w-3" />
) : (
<Shield className="h-3 w-3" />
)}
{user.role === "owner" ? t("users_role_owner") : user.role === "admin" ? t("users_role_admin") : t("users_role_user")}
</span>
</div>
</div>
<span className="inline-flex items-center gap-1">
<Calendar className="h-3.5 w-3.5" />
{formatAbsolute(user.created_at, locale)}
</span>
</div>
)}
actions={(
<div className="flex items-center gap-2">
<UserStatusBadge disabled={user.disabled} />
<RoleBadge role={user.role} />
</div>
)}
className="mb-6"
/>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* Edit profile */}
@@ -227,16 +199,10 @@ function EditProfileCard({ user, onSaved, isSelf }: { user: UserDetail; onSaved:
)}
{err && (
<div className="flex items-center gap-2 rounded-md border border-destructive/20 bg-destructive/10 p-3 text-sm text-destructive">
<AlertCircle className="h-4 w-4 shrink-0" />
<p>{err}</p>
</div>
<MessageLine kind="error" message={err} />
)}
{ok && (
<div className="flex items-center gap-2 rounded-md border border-emerald-500/20 bg-emerald-500/10 p-3 text-sm text-emerald-500">
<CheckCircle2 className="h-4 w-4 shrink-0" />
<p>{ok}</p>
</div>
<MessageLine kind="success" message={ok} />
)}
<Button
@@ -395,14 +361,7 @@ function LinkedAccountsCard({ user, onChanged }: { user: UserDetail; onChanged:
<DialogTitle>{t("users_unlink_dlg_title")}</DialogTitle>
<DialogDescription>{t("users_unlink_dlg_desc")}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" size="sm" onClick={() => setUnlinkDlg(null)}>
{t("common:cancel")}
</Button>
<Button variant="destructive" size="sm" onClick={handleUnlinkConfirm}>
{t("users_unlink_btn")}
</Button>
</DialogFooter>
<ConfirmFooter onCancel={() => setUnlinkDlg(null)} onConfirm={handleUnlinkConfirm} cancelLabel={t("common:cancel")} confirmLabel={t("users_unlink_btn")} />
</DialogContent>
</Dialog>
</>
@@ -512,16 +471,10 @@ function QuotasCard({ userId }: { userId: string }) {
</div>
{err && (
<div className="flex items-center gap-2 rounded-md border border-destructive/20 bg-destructive/10 p-3 text-sm text-destructive">
<AlertCircle className="h-4 w-4 shrink-0" />
<p>{err}</p>
</div>
<MessageLine kind="error" message={err} />
)}
{ok && (
<div className="flex items-center gap-2 rounded-md border border-emerald-500/20 bg-emerald-500/10 p-3 text-sm text-emerald-500">
<CheckCircle2 className="h-4 w-4 shrink-0" />
<p>{ok}</p>
</div>
<MessageLine kind="success" message={ok} />
)}
<Button onClick={handleSave} disabled={saving} size="sm" className="gap-1.5">
@@ -604,16 +557,10 @@ function SessionsCard({ userId, onChanged }: { userId: string; onChanged: () =>
</CardHeader>
<CardContent>
{err && (
<div className="flex items-center gap-2 rounded-md border border-destructive/20 bg-destructive/10 p-3 text-sm text-destructive mb-3">
<AlertCircle className="h-4 w-4 shrink-0" />
<p>{err}</p>
</div>
<MessageLine kind="error" message={err} />
)}
{ok && (
<div className="flex items-center gap-2 rounded-md border border-emerald-500/20 bg-emerald-500/10 p-3 text-sm text-emerald-500 mb-3">
<CheckCircle2 className="h-4 w-4 shrink-0" />
<p>{ok}</p>
</div>
<MessageLine kind="success" message={ok} />
)}
{loading ? (
<Loading label="" />
@@ -665,14 +612,7 @@ function SessionsCard({ userId, onChanged }: { userId: string; onChanged: () =>
<DialogTitle>{t("users_session_revoke_one_dlg_title")}</DialogTitle>
<DialogDescription>{t("users_session_revoke_one_dlg_desc")}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" size="sm" onClick={() => setRevokeOneDlg(null)}>
{t("common:cancel")}
</Button>
<Button variant="destructive" size="sm" onClick={handleRevokeOne}>
{t("users_session_revoke_confirm")}
</Button>
</DialogFooter>
<ConfirmFooter onCancel={() => setRevokeOneDlg(null)} onConfirm={handleRevokeOne} cancelLabel={t("common:cancel")} confirmLabel={t("users_session_revoke_confirm")} />
</DialogContent>
</Dialog>
@@ -683,14 +623,7 @@ function SessionsCard({ userId, onChanged }: { userId: string; onChanged: () =>
<DialogTitle>{t("users_session_revoke_all_dlg_title")}</DialogTitle>
<DialogDescription>{t("users_session_revoke_all_dlg_desc")}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" size="sm" onClick={() => setRevokeAllDlg(false)}>
{t("common:cancel")}
</Button>
<Button variant="destructive" size="sm" onClick={handleRevokeAll}>
{t("users_session_revoke_confirm")}
</Button>
</DialogFooter>
<ConfirmFooter onCancel={() => setRevokeAllDlg(false)} onConfirm={handleRevokeAll} cancelLabel={t("common:cancel")} confirmLabel={t("users_session_revoke_confirm")} />
</DialogContent>
</Dialog>
</>
@@ -864,21 +797,7 @@ function DangerDialogs({
</DialogDescription>
</DialogHeader>
{err && <p className="text-sm text-destructive">{err}</p>}
<DialogFooter>
<Button variant="outline" size="sm" onClick={close} disabled={loading}>
{t("common:cancel")}
</Button>
<Button
variant={user.disabled ? "default" : "destructive"}
size="sm"
disabled={loading}
onClick={handleDisable}
className="gap-1.5"
>
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
{user.disabled ? t("users_danger_enable_btn") : t("users_danger_disable_btn")}
</Button>
</DialogFooter>
<ConfirmFooter onCancel={close} onConfirm={handleDisable} loading={loading} cancelLabel={t("common:cancel")} confirmLabel={user.disabled ? t("users_danger_enable_btn") : t("users_danger_disable_btn")} confirmVariant={user.disabled ? "default" : "destructive"} />
</DialogContent>
</Dialog>
@@ -903,20 +822,7 @@ function DangerDialogs({
{ok}
</p>
)}
<DialogFooter>
<Button variant="outline" size="sm" onClick={close} disabled={loading}>
{t("common:cancel")}
</Button>
<Button
size="sm"
disabled={loading || ok !== null}
onClick={handleResetPassword}
className="gap-1.5"
>
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
{t("users_danger_reset_pw_confirm")}
</Button>
</DialogFooter>
<ConfirmFooter onCancel={close} onConfirm={handleResetPassword} loading={loading} disabled={loading || ok !== null} cancelLabel={t("common:cancel")} confirmLabel={t("users_danger_reset_pw_confirm")} />
</DialogContent>
</Dialog>
@@ -933,22 +839,7 @@ function DangerDialogs({
</DialogDescription>
</DialogHeader>
{err && <p className="text-sm text-destructive">{err}</p>}
<DialogFooter>
<Button variant="outline" size="sm" onClick={close} disabled={loading}>
{t("common:cancel")}
</Button>
<Button
variant="destructive"
size="sm"
disabled={loading}
onClick={handleDelete}
className="gap-1.5"
>
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
<Trash2 className="h-4 w-4" />
{t("users_danger_delete_yes")}
</Button>
</DialogFooter>
<ConfirmFooter onCancel={close} onConfirm={handleDelete} loading={loading} cancelLabel={t("common:cancel")} confirmLabel={t("users_danger_delete_yes")} />
</DialogContent>
</Dialog>
</>
+8 -75
View File
@@ -1,12 +1,10 @@
import { useState, useCallback } from "react";
import { Link, useNavigate } from "react-router-dom";
import { RoleBadge } from "@/components/RoleBadge";
import { UserStatusBadge } from "@/components/UserStatusBadge";
import {
Users,
Search,
Circle,
Shield,
UserRound,
Crown,
Server,
Mail,
ChevronRight,
@@ -23,6 +21,8 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Loading, ErrorState, EmptyState } from "@/components/States";
import { PageHeader } from "@/components/PageHeader";
import { Pagination } from "@/components/Pagination";
import { CreateUserDialog } from "@/components/CreateUserDialog";
import { api } from "@/lib/api";
import { useAsync } from "@/lib/hooks";
@@ -65,22 +65,7 @@ export function UsersPage() {
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Users className="h-6 w-6 text-primary" />
<div>
<h1 className="text-2xl font-semibold tracking-tight">{t("users_title")}</h1>
<p className="text-sm text-muted-foreground">{t("users_subtitle")}</p>
</div>
</div>
<CreateUserDialog
onCreated={(id) => {
reload();
navigate(`/admin/users/${id}`);
}}
/>
</div>
<PageHeader icon={Users} title={t("users_title")} subtitle={t("users_subtitle")} actions={<CreateUserDialog onCreated={(id) => { reload(); navigate(`/admin/users/${id}`); }} />} className="mb-6" />
{/* Filters */}
<Card>
@@ -166,34 +151,8 @@ export function UsersPage() {
</CardContent>
</Card>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
{t("users_total_count", { count: data.total })}
</span>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
disabled={page === 0}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
{t("pagination_prev")}
</Button>
<span className="px-2 text-muted-foreground">
{page + 1} / {totalPages}
</span>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages - 1}
onClick={() => setPage((p) => p + 1)}
>
{t("pagination_next")}
</Button>
</div>
</div>
<Pagination page={page + 1} pageSize={pageSize} total={data.total} onChange={(p) => setPage(p - 1)} />
)}
</>
)}
@@ -226,23 +185,7 @@ function UserRow({ user, locale }: { user: UserView; locale: string }) {
)}
</td>
<td className="px-4 py-3 hidden sm:table-cell">
<span className={cn(
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium",
user.role === "owner"
? "bg-yellow-500/10 text-yellow-600"
: user.role === "admin"
? "bg-primary/10 text-primary"
: "bg-muted text-muted-foreground",
)}>
{user.role === "owner" ? (
<Crown className="h-3 w-3" />
) : user.role === "admin" ? (
<Shield className="h-3 w-3" />
) : (
<UserRound className="h-3 w-3" />
)}
{user.role === "owner" ? t("users_role_owner") : user.role === "admin" ? t("users_role_admin") : t("users_role_user")}
</span>
<RoleBadge role={user.role} />
</td>
<td className="px-4 py-3 hidden md:table-cell">
<span className="inline-flex items-center gap-1 text-muted-foreground">
@@ -251,17 +194,7 @@ function UserRow({ user, locale }: { user: UserView; locale: string }) {
</span>
</td>
<td className="px-4 py-3 hidden lg:table-cell">
{user.disabled ? (
<span className="inline-flex items-center gap-1 rounded-full bg-destructive/10 px-2 py-0.5 text-xs font-medium text-destructive">
<Circle className="h-2 w-2 fill-destructive" />
{t("users_status_disabled")}
</span>
) : (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-2 py-0.5 text-xs font-medium text-emerald-500">
<Circle className="h-2 w-2 fill-emerald-500" />
{t("users_status_active")}
</span>
)}
<UserStatusBadge disabled={user.disabled} />
</td>
<td className="px-4 py-3 text-muted-foreground hidden lg:table-cell text-xs">
{formatAbsolute(user.created_at, locale)}
+53 -97
View File
@@ -6,7 +6,6 @@ import {
Square,
Terminal,
ExternalLink,
Search,
RefreshCw,
Server,
Users,
@@ -17,12 +16,12 @@ import {
import { useTranslation } from "react-i18next";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { SearchInput } from "@/components/SearchInput";
import { ConfirmFooter } from "@/components/ConfirmFooter";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
@@ -37,6 +36,8 @@ import { PhaseBadge, PHASE_KEY, PHASE_COLOR } from "@/components/PhaseBadge";
import { Loading, ErrorState, EmptyState } from "@/components/States";
import { Pagination } from "@/components/Pagination";
import { CreateServerDialog } from "@/components/CreateServerDialog";
import { StatCard } from "@/components/StatCard";
import { PageHeader } from "@/components/PageHeader";
import { api, humanizeError } from "@/lib/api";
import { useAsync, useConfig } from "@/lib/hooks";
import { useTier } from "@/lib/tier";
@@ -83,38 +84,6 @@ interface UnifiedServer {
owned?: boolean;
}
function Stat({
icon: Icon,
label,
value,
accent,
}: {
icon: typeof Server;
label: string;
value: number | string;
accent?: string;
}) {
return (
<Card>
<CardContent className="flex items-center gap-3 p-4">
<div
className="rounded-md p-2"
style={{
backgroundColor: accent ? `${accent}26` : undefined,
color: accent,
}}
>
<Icon className="h-5 w-5" />
</div>
<div className="min-w-0">
<div className="text-2xl font-semibold leading-none tabular-nums text-left">{value}</div>
<div className="mt-1.5 truncate text-xs text-muted-foreground text-left">{label}</div>
</div>
</CardContent>
</Card>
);
}
export function ServersPage() {
const { t } = useTranslation(["ops", "servers"]);
const { isAdmin, identity } = useTier();
@@ -218,35 +187,30 @@ export function ServersPage() {
return (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex items-center gap-3">
<Network className="h-6 w-6 text-primary" />
<div className="text-left">
<h1 className="text-2xl font-semibold tracking-tight">
{isAdmin ? t("fleet_title") : t("servers:my_servers_title")}
</h1>
<p className="text-sm text-muted-foreground">
{isAdmin ? t("fleet_subtitle") : t("servers:my_servers_subtitle")}
</p>
<PageHeader
icon={Network}
title={isAdmin ? t("fleet_title") : t("servers:my_servers_title")}
subtitle={isAdmin ? t("fleet_subtitle") : t("servers:my_servers_subtitle")}
actions={
<div className="flex items-center gap-3">
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="h-1.5 w-1.5 rounded-full bg-[#22c55e] animate-pulse" />
{t("fleet_live")}
</span>
<Button
variant="outline"
size="sm"
onClick={reload}
disabled={loading}
title={t("fleet_refresh")}
>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
</Button>
{isAdmin && cfg && <CreateServerDialog cfg={cfg} onCreated={reload} />}
</div>
</div>
<div className="flex items-center gap-3">
{isAdmin && cfg && <CreateServerDialog cfg={cfg} onCreated={reload} />}
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="h-1.5 w-1.5 rounded-full bg-[#22c55e] animate-pulse" />
{t("fleet_live")}
</span>
<Button
variant="outline"
size="sm"
onClick={reload}
disabled={loading}
title={t("fleet_refresh")}
>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
</Button>
</div>
</div>
}
className="mb-6"
/>
{showInitialLoading ? (
<Loading />
@@ -265,23 +229,23 @@ export function ServersPage() {
{/* Stats Cards in a full grid row */}
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<Stat icon={Server} label={t("fleet_stat_total")} value={stats.total} />
<Stat
<StatCard icon={Server} label={t("fleet_stat_total")} value={stats.total} />
<StatCard
icon={Play}
label={t("fleet_stat_running")}
value={stats.counts.Running}
accent={PHASE_COLOR.Running}
accentColor={PHASE_COLOR.Running}
/>
<Stat
<StatCard
icon={Users}
label={t("fleet_stat_players")}
value={`${stats.playersOnline} / ${stats.playersMax}`}
/>
<Stat
<StatCard
icon={AlertTriangle}
label={t("fleet_stat_failed")}
value={stats.counts.Failed}
accent={stats.counts.Failed > 0 ? PHASE_COLOR.Failed : undefined}
accentColor={stats.counts.Failed > 0 ? PHASE_COLOR.Failed : undefined}
/>
</div>
@@ -326,18 +290,14 @@ export function ServersPage() {
{/* Independent Search & Filter bar */}
<div className="flex flex-wrap items-center gap-3">
<div className="relative min-w-[14rem] flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(e) => {
setQuery(e.target.value);
setPage(1);
}}
placeholder={isAdmin ? t("fleet_search_placeholder") : t("servers:search_placeholder")}
className="pl-9"
/>
</div>
<SearchInput
value={query}
onChange={(v) => {
setQuery(v);
setPage(1);
}}
placeholder={isAdmin ? t("fleet_search_placeholder") : t("servers:search_placeholder")}
/>
<Select
value={phaseFilter}
onValueChange={(v) => {
@@ -549,22 +509,18 @@ function ServerRow({
{ts("claim_server_desc", { name: server.name })}
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="ghost" onClick={() => setConfirmOpen(false)} disabled={busy !== null}>
{ts("access_cancel")}
</Button>
<Button
variant="default"
onClick={async () => {
await act("claim", () => api.claim(server.name));
setConfirmOpen(false);
}}
disabled={busy !== null}
>
{busy === "claim" && <RefreshCw className="h-4 w-4 animate-spin mr-1" />}
{ts("claim")}
</Button>
</DialogFooter>
<ConfirmFooter
onCancel={() => 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"
/>
</DialogContent>
</Dialog>
</>