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
@@ -0,0 +1,18 @@
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
interface Props {
|
||||
to: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function BackLink({ to, label }: Props) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" /> {label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DialogFooter } from "@/components/ui/dialog";
|
||||
|
||||
interface Props {
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
cancelLabel: string;
|
||||
confirmLabel: string;
|
||||
confirmVariant?: "destructive" | "default" | "outline";
|
||||
/** Additional content rendered between the message and buttons */
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ConfirmFooter({
|
||||
onCancel,
|
||||
onConfirm,
|
||||
disabled,
|
||||
loading,
|
||||
cancelLabel,
|
||||
confirmLabel,
|
||||
confirmVariant = "destructive",
|
||||
children,
|
||||
className,
|
||||
}: Props) {
|
||||
return (
|
||||
<DialogFooter className={className}>
|
||||
{children}
|
||||
<Button variant="outline" size="sm" onClick={onCancel} disabled={disabled ?? loading}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button variant={confirmVariant} size="sm" onClick={onConfirm} disabled={disabled ?? loading}>
|
||||
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useState } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ConfirmFooter } from "@/components/ConfirmFooter";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
@@ -104,8 +104,9 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus /> {t("create_server_btn")}
|
||||
<Button size="sm" className="gap-1.5">
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("create_server_btn")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
@@ -231,14 +232,15 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setOpen(false)} disabled={submitting}>
|
||||
{t("create_server_cancel")}
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={!canSubmit}>
|
||||
{submitting ? t("create_server_creating") : t("create_server_submit")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
<ConfirmFooter
|
||||
onCancel={() => setOpen(false)}
|
||||
onConfirm={submit}
|
||||
disabled={!canSubmit}
|
||||
loading={submitting}
|
||||
cancelLabel={t("create_server_cancel")}
|
||||
confirmLabel={t("create_server_submit")}
|
||||
confirmVariant="default"
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { MessageLine } from "@/components/MessageLine";
|
||||
import { api, humanizeError } from "@/lib/api";
|
||||
|
||||
interface Props {
|
||||
@@ -217,11 +218,7 @@ export function CreateUserDialog({ onCreated }: Props) {
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{err && (
|
||||
<p className="rounded-md border border-destructive/20 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{err}
|
||||
</p>
|
||||
)}
|
||||
{err && <MessageLine kind="error" message={err} />}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={submitting} className="gap-1.5">
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Settings, ChevronRight } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ConfirmFooter } from "@/components/ConfirmFooter";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
@@ -269,14 +269,15 @@ export function EditServerDialog({
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setOpen(false)} disabled={submitting}>
|
||||
{t("create_server_cancel")}
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={!canSubmit}>
|
||||
{submitting ? t("edit_server_updating") : t("edit_server_submit")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
<ConfirmFooter
|
||||
onCancel={() => setOpen(false)}
|
||||
onConfirm={submit}
|
||||
disabled={!canSubmit}
|
||||
loading={submitting}
|
||||
cancelLabel={t("create_server_cancel")}
|
||||
confirmLabel={t("edit_server_submit")}
|
||||
confirmVariant="default"
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
confirming: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
confirmLabel: string;
|
||||
cancelLabel: string;
|
||||
confirmVariant?: "destructive" | "default";
|
||||
size?: "default" | "sm";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function InlineConfirm({
|
||||
open,
|
||||
confirming,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
confirmLabel,
|
||||
cancelLabel,
|
||||
confirmVariant = "destructive",
|
||||
size = "sm",
|
||||
className,
|
||||
}: Props) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div className={className}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size={size}
|
||||
onClick={onCancel}
|
||||
disabled={confirming}
|
||||
>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button
|
||||
variant={confirmVariant}
|
||||
size={size}
|
||||
onClick={onConfirm}
|
||||
disabled={confirming}
|
||||
>
|
||||
{confirming ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
confirmLabel
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { AlertCircle, CheckCircle2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Props {
|
||||
kind: "error" | "success";
|
||||
message: string;
|
||||
/** Compact variant: smaller text, no border, no icon. Error-only. */
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ICON = { error: AlertCircle, success: CheckCircle2 };
|
||||
|
||||
const STYLE = {
|
||||
error: "border-destructive/20 bg-destructive/10 text-destructive",
|
||||
success: "border-emerald-500/20 bg-emerald-500/10 text-emerald-500",
|
||||
};
|
||||
|
||||
const COMPACT_STYLE = "bg-destructive/10 text-destructive";
|
||||
|
||||
export function MessageLine({ kind, message, compact, className }: Props) {
|
||||
if (compact) {
|
||||
return (
|
||||
<p
|
||||
className={cn(
|
||||
"text-xs font-medium rounded-md p-2.5",
|
||||
COMPACT_STYLE,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{message}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
const Icon = ICON[kind];
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md border p-3 text-sm",
|
||||
STYLE[kind],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
<p>{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from "react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface PageHeaderProps {
|
||||
icon?: LucideIcon | React.ReactNode;
|
||||
title: React.ReactNode;
|
||||
subtitle?: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PageHeader({ icon: Icon, title, subtitle, actions, className }: PageHeaderProps) {
|
||||
return (
|
||||
<div className={cn("flex flex-wrap items-center justify-between gap-3", className)}>
|
||||
<div className="flex items-center gap-3">
|
||||
{Icon && (
|
||||
<div className="shrink-0 text-primary">
|
||||
{React.isValidElement(Icon)
|
||||
? Icon
|
||||
: React.createElement(Icon as React.ComponentType<{ className?: string }>, {
|
||||
className: "h-6 w-6",
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-left">
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-foreground">{title}</h1>
|
||||
{subtitle && <div className="text-sm text-muted-foreground mt-0.5">{subtitle}</div>}
|
||||
</div>
|
||||
</div>
|
||||
{actions && <div className="flex items-center gap-3 shrink-0">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,7 @@ export function Pagination({ page, pageSize, total, onChange }: Props) {
|
||||
disabled={page <= 1}
|
||||
onClick={() => onChange(page - 1)}
|
||||
>
|
||||
<ChevronLeft />
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
{t("pagination_prev")}
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground min-w-[6rem] text-center">
|
||||
@@ -34,7 +34,7 @@ export function Pagination({ page, pageSize, total, onChange }: Props) {
|
||||
onClick={() => onChange(page + 1)}
|
||||
>
|
||||
{t("pagination_next")}
|
||||
<ChevronRight />
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Crown, Shield, UserRound } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ICON_MAP = { owner: Crown, admin: Shield, user: UserRound };
|
||||
|
||||
const STYLE_MAP = {
|
||||
owner: "bg-yellow-500/10 text-yellow-600",
|
||||
admin: "bg-primary/10 text-primary",
|
||||
user: "bg-muted text-muted-foreground",
|
||||
};
|
||||
|
||||
const I18N_KEY = {
|
||||
owner: "users_role_owner",
|
||||
admin: "users_role_admin",
|
||||
user: "users_role_user",
|
||||
};
|
||||
|
||||
export function RoleBadge({ role }: { role: "owner" | "admin" | "user" }) {
|
||||
const { t } = useTranslation("admin");
|
||||
const Icon = ICON_MAP[role];
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full px-3 py-1 text-xs font-medium",
|
||||
STYLE_MAP[role],
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3 w-3" />
|
||||
{t(I18N_KEY[role])}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Search } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SearchInput({ value, onChange, placeholder, className }: Props) {
|
||||
return (
|
||||
<div className={cn("relative min-w-[14rem] flex-1", className)}>
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface StatCardProps {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
/** Tailwind classes applied to the icon container (e.g. "text-amber-500 bg-amber-500/10") */
|
||||
accentClass?: string;
|
||||
/** A hex colour string; applied as `color` on the icon container. Takes precedence over accentClass for the colour. */
|
||||
accentColor?: string;
|
||||
/** Tailwind classes applied to the value text */
|
||||
valueClass?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_ICON_CONTAINER = "rounded-md p-2 bg-muted/30 text-muted-foreground";
|
||||
|
||||
export function StatCard({ icon: Icon, label, value, accentClass, accentColor, valueClass }: StatCardProps) {
|
||||
const containerStyle = accentColor ? { backgroundColor: `${accentColor}26`, color: accentColor } : undefined;
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<div
|
||||
className={cn(DEFAULT_ICON_CONTAINER, !accentColor && accentClass)}
|
||||
style={containerStyle}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className={cn(
|
||||
"text-2xl font-bold font-mono leading-none truncate text-foreground",
|
||||
valueClass,
|
||||
)}
|
||||
title={typeof value === "string" ? value : undefined}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-muted-foreground font-medium">{label}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Loader2, AlertTriangle, Inbox, ShieldX, Construction } from "lucide-react";
|
||||
import { Loader2, AlertTriangle, Inbox, ShieldX, Construction, Moon } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { humanizeError } from "@/lib/api";
|
||||
|
||||
export function Loading({ label }: { label?: string }) {
|
||||
@@ -92,3 +93,48 @@ export function PendingBackend({ endpoint, note }: { endpoint: string; note: str
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface NotYoursProps {
|
||||
title: string;
|
||||
body: string;
|
||||
linkTo?: string;
|
||||
linkLabel?: string;
|
||||
}
|
||||
|
||||
export function NotYours({ title, body, linkTo = "/servers", linkLabel }: NotYoursProps) {
|
||||
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">{title}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{body}</p>
|
||||
</div>
|
||||
<Link to={linkTo} className="text-sm font-medium text-primary hover:underline">
|
||||
{linkLabel ?? t("my_servers_breadcrumb")}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface NotRunningProps {
|
||||
title: string;
|
||||
body: string;
|
||||
wakeLabel: string;
|
||||
onWake: () => void;
|
||||
}
|
||||
|
||||
export function NotRunning({ title, body, wakeLabel, onWake }: NotRunningProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 rounded-lg border border-dashed border-border bg-muted/20 py-16 text-center">
|
||||
<Moon className="h-8 w-8 text-muted-foreground/70" />
|
||||
<div>
|
||||
<p className="font-medium">{title}</p>
|
||||
<p className="mx-auto mt-1 max-w-sm text-sm text-muted-foreground">{body}</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={onWake}>
|
||||
{wakeLabel}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { SubmissionStatus } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const STATUS_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",
|
||||
};
|
||||
|
||||
const STATUS_I18N_KEY: Record<SubmissionStatus, string> = {
|
||||
pending_review: "status_pending_review",
|
||||
approved: "status_approved",
|
||||
rejected: "status_rejected",
|
||||
};
|
||||
|
||||
interface Props {
|
||||
status: SubmissionStatus;
|
||||
}
|
||||
|
||||
export function SubmissionStatusBadge({ status }: Props) {
|
||||
const { t } = useTranslation("submissions");
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn("font-medium select-none pointer-events-none", STATUS_STYLE[status])}
|
||||
>
|
||||
{t(STATUS_I18N_KEY[status])}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Circle } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Props {
|
||||
disabled: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function UserStatusBadge({ disabled, className }: Props) {
|
||||
const { t } = useTranslation("admin");
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full px-3 py-1 text-xs font-medium",
|
||||
disabled
|
||||
? "bg-destructive/10 text-destructive"
|
||||
: "bg-emerald-500/10 text-emerald-500",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Circle className={cn("h-2 w-2", disabled ? "fill-destructive" : "fill-emerald-500")} />
|
||||
{disabled ? t("users_status_disabled") : t("users_status_active")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Ban, Loader2, RotateCw, Undo2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { InlineConfirm } from "@/components/InlineConfirm";
|
||||
import { api, humanizeError } from "@/lib/api";
|
||||
import { useAsync } from "@/lib/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -155,28 +156,15 @@ export function BansSection({ name }: { name: string }) {
|
||||
<p className="min-w-0 flex-1 text-xs text-foreground">
|
||||
{t("access_ban_confirm", { player })}
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
<InlineConfirm
|
||||
open={true}
|
||||
confirming={banning}
|
||||
onConfirm={ban}
|
||||
onCancel={() => setArmed(false)}
|
||||
confirmLabel={t("access_ban_confirm_yes")}
|
||||
cancelLabel={t("access_cancel")}
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
onClick={() => setArmed(false)}
|
||||
disabled={banning}
|
||||
>
|
||||
{t("access_cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
onClick={ban}
|
||||
disabled={banning}
|
||||
>
|
||||
{banning ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
t("access_ban_confirm_yes")
|
||||
)}
|
||||
</Button>
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -215,29 +203,16 @@ export function BansSection({ name }: { name: string }) {
|
||||
row (取消 / 解封), a second confirms. Lighter than the ban-by-name
|
||||
confirm because a mistaken pardon is re-bannable in one tap. */}
|
||||
{confirming === p ? (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-1.5 text-[10px]"
|
||||
onClick={() => setConfirming(null)}
|
||||
disabled={pardoning === p}
|
||||
>
|
||||
{t("access_cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-6 px-1.5 text-[10px]"
|
||||
onClick={() => pardon(p)}
|
||||
disabled={pardoning !== null}
|
||||
>
|
||||
{pardoning === p ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
t("access_pardon_btn")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<InlineConfirm
|
||||
open={true}
|
||||
confirming={pardoning !== null}
|
||||
onConfirm={() => pardon(p)}
|
||||
onCancel={() => setConfirming(null)}
|
||||
confirmLabel={t("access_pardon_btn")}
|
||||
cancelLabel={t("access_cancel")}
|
||||
size="sm"
|
||||
className="flex shrink-0 items-center gap-1"
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Ban, Loader2, LogOut, RotateCw, Users } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { InlineConfirm } from "@/components/InlineConfirm";
|
||||
import { api, humanizeError } from "@/lib/api";
|
||||
import { useAsync } from "@/lib/hooks";
|
||||
import {
|
||||
@@ -134,7 +135,6 @@ export function OnlineSection({ name, defaultOpen = true }: { name: string; defa
|
||||
pageItems.map((p) => {
|
||||
// Narrow here so confirming.action is non-null inside the branch.
|
||||
const c = confirming && confirming.player === p ? confirming : null;
|
||||
const isPending = pending?.player === p;
|
||||
return (
|
||||
<div
|
||||
key={p}
|
||||
@@ -150,32 +150,16 @@ export function OnlineSection({ name, defaultOpen = true }: { name: string; defa
|
||||
{/* Both kick and ban are disruptive, so each arms a one-step
|
||||
inline confirm before it fires (no native confirm()). */}
|
||||
{c ? (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-1.5 text-[10px]"
|
||||
onClick={() => setConfirming(null)}
|
||||
disabled={isPending}
|
||||
>
|
||||
{t("access_cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-6 px-1.5 text-[10px]"
|
||||
onClick={() => run(p, c.action)}
|
||||
disabled={pending !== null}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : c.action === "kick" ? (
|
||||
t("access_kick_btn")
|
||||
) : (
|
||||
t("access_ban_btn")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<InlineConfirm
|
||||
open={true}
|
||||
confirming={pending !== null}
|
||||
onConfirm={() => run(p, c.action)}
|
||||
onCancel={() => setConfirming(null)}
|
||||
confirmLabel={c.action === "kick" ? t("access_kick_btn") : t("access_ban_btn")}
|
||||
cancelLabel={t("access_cancel")}
|
||||
size="sm"
|
||||
className="flex shrink-0 items-center gap-1"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ListChecks, Loader2, Plus, RotateCw, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { InlineConfirm } from "@/components/InlineConfirm";
|
||||
import { api, humanizeError } from "@/lib/api";
|
||||
import { useAsync } from "@/lib/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -165,30 +166,16 @@ export function WhitelistSection({ name, defaultOpen = false }: { name: string;
|
||||
取消 / 移除), a second confirms. Recoverable, but a name gone on
|
||||
a single stray tap is exactly the surprise to avoid. */}
|
||||
{confirming === p ? (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-1.5 text-[10px]"
|
||||
onClick={() => setConfirming(null)}
|
||||
disabled={removing === p}
|
||||
>
|
||||
{t("access_cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-6 px-1.5 text-[10px]"
|
||||
onClick={() => remove(p)}
|
||||
disabled={removing !== null}
|
||||
>
|
||||
{removing === p ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
t("access_remove")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<InlineConfirm
|
||||
open={true}
|
||||
confirming={removing !== null}
|
||||
onConfirm={() => remove(p)}
|
||||
onCancel={() => setConfirming(null)}
|
||||
confirmLabel={t("access_remove")}
|
||||
cancelLabel={t("access_cancel")}
|
||||
size="sm"
|
||||
className="flex shrink-0 items-center gap-1"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -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