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"
|
||||
|
||||
Reference in new issue
Block a user