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:
32 files changed
+772
-1078
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
Reference in new issue
Block a user