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

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

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

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

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

No files matched your search

+18
View File
@@ -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>
);
}
+41
View File
@@ -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>
);
}
+13 -11
View File
@@ -1,11 +1,11 @@
import { useState } from "react"; import { useState } from "react";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ConfirmFooter } from "@/components/ConfirmFooter";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
@@ -104,8 +104,9 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button> <Button size="sm" className="gap-1.5">
<Plus /> {t("create_server_btn")} <Plus className="h-4 w-4" />
{t("create_server_btn")}
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
@@ -231,14 +232,15 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
{error && <p className="text-sm text-destructive">{error}</p>} {error && <p className="text-sm text-destructive">{error}</p>}
</div> </div>
<DialogFooter> <ConfirmFooter
<Button variant="ghost" onClick={() => setOpen(false)} disabled={submitting}> onCancel={() => setOpen(false)}
{t("create_server_cancel")} onConfirm={submit}
</Button> disabled={!canSubmit}
<Button onClick={submit} disabled={!canSubmit}> loading={submitting}
{submitting ? t("create_server_creating") : t("create_server_submit")} cancelLabel={t("create_server_cancel")}
</Button> confirmLabel={t("create_server_submit")}
</DialogFooter> confirmVariant="default"
/>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
+2 -5
View File
@@ -20,6 +20,7 @@ import {
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { MessageLine } from "@/components/MessageLine";
import { api, humanizeError } from "@/lib/api"; import { api, humanizeError } from "@/lib/api";
interface Props { interface Props {
@@ -217,11 +218,7 @@ export function CreateUserDialog({ onCreated }: Props) {
</span> </span>
</label> </label>
{err && ( {err && <MessageLine kind="error" message={err} />}
<p className="rounded-md border border-destructive/20 bg-destructive/10 p-3 text-sm text-destructive">
{err}
</p>
)}
<DialogFooter> <DialogFooter>
<Button type="submit" disabled={submitting} className="gap-1.5"> <Button type="submit" disabled={submitting} className="gap-1.5">
+10 -9
View File
@@ -1,11 +1,11 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Settings, ChevronRight } from "lucide-react"; import { Settings, ChevronRight } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ConfirmFooter } from "@/components/ConfirmFooter";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
@@ -269,14 +269,15 @@ export function EditServerDialog({
{error && <p className="text-sm text-destructive">{error}</p>} {error && <p className="text-sm text-destructive">{error}</p>}
</div> </div>
<DialogFooter> <ConfirmFooter
<Button variant="ghost" onClick={() => setOpen(false)} disabled={submitting}> onCancel={() => setOpen(false)}
{t("create_server_cancel")} onConfirm={submit}
</Button> disabled={!canSubmit}
<Button onClick={submit} disabled={!canSubmit}> loading={submitting}
{submitting ? t("edit_server_updating") : t("edit_server_submit")} cancelLabel={t("create_server_cancel")}
</Button> confirmLabel={t("edit_server_submit")}
</DialogFooter> confirmVariant="default"
/>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
+52
View File
@@ -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>
);
}
+48
View File
@@ -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>
);
}
+34
View File
@@ -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>
);
}
+2 -2
View File
@@ -21,7 +21,7 @@ export function Pagination({ page, pageSize, total, onChange }: Props) {
disabled={page <= 1} disabled={page <= 1}
onClick={() => onChange(page - 1)} onClick={() => onChange(page - 1)}
> >
<ChevronLeft /> <ChevronLeft className="h-4 w-4" />
{t("pagination_prev")} {t("pagination_prev")}
</Button> </Button>
<span className="text-xs text-muted-foreground min-w-[6rem] text-center"> <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)} onClick={() => onChange(page + 1)}
> >
{t("pagination_next")} {t("pagination_next")}
<ChevronRight /> <ChevronRight className="h-4 w-4" />
</Button> </Button>
</div> </div>
); );
+33
View File
@@ -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>
);
}
+24
View File
@@ -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>
);
}
+45
View File
@@ -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>
);
}
+47 -1
View File
@@ -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 { Link } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { humanizeError } from "@/lib/api"; import { humanizeError } from "@/lib/api";
export function Loading({ label }: { label?: string }) { export function Loading({ label }: { label?: string }) {
@@ -92,3 +93,48 @@ export function PendingBackend({ endpoint, note }: { endpoint: string; note: str
</div> </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>
);
}
+26
View File
@@ -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>
);
}
+19 -44
View File
@@ -2,6 +2,7 @@ import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Ban, Loader2, RotateCw, Undo2 } from "lucide-react"; import { Ban, Loader2, RotateCw, Undo2 } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { InlineConfirm } from "@/components/InlineConfirm";
import { api, humanizeError } from "@/lib/api"; import { api, humanizeError } from "@/lib/api";
import { useAsync } from "@/lib/hooks"; import { useAsync } from "@/lib/hooks";
import { cn } from "@/lib/utils"; 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"> <p className="min-w-0 flex-1 text-xs text-foreground">
{t("access_ban_confirm", { player })} {t("access_ban_confirm", { player })}
</p> </p>
<Button <InlineConfirm
variant="ghost" open={true}
confirming={banning}
onConfirm={ban}
onCancel={() => setArmed(false)}
confirmLabel={t("access_ban_confirm_yes")}
cancelLabel={t("access_cancel")}
size="sm" 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>
)} )}
</div> </div>
@@ -215,29 +203,16 @@ export function BansSection({ name }: { name: string }) {
row (取消 / 解封), a second confirms. Lighter than the ban-by-name row (取消 / 解封), a second confirms. Lighter than the ban-by-name
confirm because a mistaken pardon is re-bannable in one tap. */} confirm because a mistaken pardon is re-bannable in one tap. */}
{confirming === p ? ( {confirming === p ? (
<div className="flex shrink-0 items-center gap-1"> <InlineConfirm
<Button open={true}
variant="ghost" confirming={pardoning !== null}
size="sm" onConfirm={() => pardon(p)}
className="h-6 px-1.5 text-[10px]" onCancel={() => setConfirming(null)}
onClick={() => setConfirming(null)} confirmLabel={t("access_pardon_btn")}
disabled={pardoning === p} cancelLabel={t("access_cancel")}
> size="sm"
{t("access_cancel")} className="flex shrink-0 items-center gap-1"
</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>
) : ( ) : (
<Button <Button
variant="outline" variant="outline"
+11 -27
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Ban, Loader2, LogOut, RotateCw, Users } from "lucide-react"; import { Ban, Loader2, LogOut, RotateCw, Users } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { InlineConfirm } from "@/components/InlineConfirm";
import { api, humanizeError } from "@/lib/api"; import { api, humanizeError } from "@/lib/api";
import { useAsync } from "@/lib/hooks"; import { useAsync } from "@/lib/hooks";
import { import {
@@ -134,7 +135,6 @@ export function OnlineSection({ name, defaultOpen = true }: { name: string; defa
pageItems.map((p) => { pageItems.map((p) => {
// Narrow here so confirming.action is non-null inside the branch. // Narrow here so confirming.action is non-null inside the branch.
const c = confirming && confirming.player === p ? confirming : null; const c = confirming && confirming.player === p ? confirming : null;
const isPending = pending?.player === p;
return ( return (
<div <div
key={p} 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 {/* Both kick and ban are disruptive, so each arms a one-step
inline confirm before it fires (no native confirm()). */} inline confirm before it fires (no native confirm()). */}
{c ? ( {c ? (
<div className="flex shrink-0 items-center gap-1"> <InlineConfirm
<Button open={true}
variant="ghost" confirming={pending !== null}
size="sm" onConfirm={() => run(p, c.action)}
className="h-6 px-1.5 text-[10px]" onCancel={() => setConfirming(null)}
onClick={() => setConfirming(null)} confirmLabel={c.action === "kick" ? t("access_kick_btn") : t("access_ban_btn")}
disabled={isPending} cancelLabel={t("access_cancel")}
> size="sm"
{t("access_cancel")} className="flex shrink-0 items-center gap-1"
</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>
) : ( ) : (
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
<Button <Button
@@ -2,6 +2,7 @@ import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ListChecks, Loader2, Plus, RotateCw, X } from "lucide-react"; import { ListChecks, Loader2, Plus, RotateCw, X } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { InlineConfirm } from "@/components/InlineConfirm";
import { api, humanizeError } from "@/lib/api"; import { api, humanizeError } from "@/lib/api";
import { useAsync } from "@/lib/hooks"; import { useAsync } from "@/lib/hooks";
import { cn } from "@/lib/utils"; 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 second confirms. Recoverable, but a name gone on
a single stray tap is exactly the surprise to avoid. */} a single stray tap is exactly the surprise to avoid. */}
{confirming === p ? ( {confirming === p ? (
<div className="flex shrink-0 items-center gap-1"> <InlineConfirm
<Button open={true}
variant="ghost" confirming={removing !== null}
size="sm" onConfirm={() => remove(p)}
className="h-6 px-1.5 text-[10px]" onCancel={() => setConfirming(null)}
onClick={() => setConfirming(null)} confirmLabel={t("access_remove")}
disabled={removing === p} cancelLabel={t("access_cancel")}
> size="sm"
{t("access_cancel")} className="flex shrink-0 items-center gap-1"
</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>
) : ( ) : (
<button <button
type="button" type="button"
+2 -9
View File
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Loading, ErrorState } from "@/components/States"; import { Loading, ErrorState } from "@/components/States";
import { PageHeader } from "@/components/PageHeader";
import { api, humanizeError } from "@/lib/api"; import { api, humanizeError } from "@/lib/api";
import { useAsync } from "@/lib/hooks"; import { useAsync } from "@/lib/hooks";
import { useTier } from "@/lib/tier"; import { useTier } from "@/lib/tier";
@@ -229,15 +230,7 @@ export function Account() {
return ( return (
<> <>
<div className="flex items-center gap-3"> <PageHeader icon={UserRound} title={t("title")} subtitle={t("subtitle")} className="mb-6" />
<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>
<Card> <Card>
<CardHeader> <CardHeader>
+6 -34
View File
@@ -18,6 +18,8 @@ import { useTranslation } from "react-i18next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Loading, ErrorState } from "@/components/States"; import { Loading, ErrorState } from "@/components/States";
import { StatCard } from "@/components/StatCard";
import { PageHeader } from "@/components/PageHeader";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { useAsync, useConfig } from "@/lib/hooks"; import { useAsync, useConfig } from "@/lib/hooks";
import type { Phase, ServerInfo, WhitelistImage } from "@/lib/types"; import type { Phase, ServerInfo, WhitelistImage } from "@/lib/types";
@@ -28,30 +30,6 @@ const VoxelFleet = lazy(() =>
import("@/components/VoxelFleet").then((m) => ({ default: m.VoxelFleet })), 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() { export function Dashboard() {
const cfg = useConfig(); const cfg = useConfig();
const { data: servers, error: serversErr, loading: serversLoading, reload: reloadServers } = useAsync(() => api.myServers(), []); const { data: servers, error: serversErr, loading: serversLoading, reload: reloadServers } = useAsync(() => api.myServers(), []);
@@ -83,13 +61,7 @@ export function Dashboard() {
return ( return (
<> <>
<div className="flex items-center gap-3"> <PageHeader icon={LayoutDashboard} title={t("title")} subtitle={t("subtitle")} className="mb-6" />
<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>
{loading && !servers ? ( {loading && !servers ? (
<Loading /> <Loading />
@@ -211,9 +183,9 @@ function FleetView({
<div className="space-y-6"> <div className="space-y-6">
{/* 顶部 Stat */} {/* 顶部 Stat */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<Stat icon={Server} label={t("stat_servers")} value={counts.total} /> <StatCard icon={Server} label={t("stat_servers")} value={counts.total} accentClass="text-primary bg-primary/15" />
<Stat icon={Play} label={t("stat_running")} value={counts.running} /> <StatCard icon={Play} label={t("stat_running")} value={counts.running} accentClass="text-primary bg-primary/15" />
<Stat icon={Activity} label={t("stat_players_online")} value={counts.players} /> <StatCard icon={Activity} label={t("stat_players_online")} value={counts.players} accentClass="text-primary bg-primary/15" />
</div> </div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-12 items-stretch"> <div className="grid grid-cols-1 gap-6 lg:grid-cols-12 items-stretch">
+29 -81
View File
@@ -2,7 +2,6 @@ import { useState, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
Upload, Upload,
Search,
Loader2, Loader2,
AlertCircle, AlertCircle,
FileText, FileText,
@@ -13,15 +12,14 @@ import {
CheckCircle, CheckCircle,
CircleSlash, CircleSlash,
ClipboardCheck, ClipboardCheck,
type LucideIcon,
} from "lucide-react"; } from "lucide-react";
import { SearchInput } from "@/components/SearchInput";
import { import {
Card, Card,
CardContent, CardContent,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { import {
Dialog, Dialog,
@@ -31,8 +29,12 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { MessageLine } from "@/components/MessageLine";
import { Loading, ErrorState, EmptyState } from "@/components/States"; import { Loading, ErrorState, EmptyState } from "@/components/States";
import { Pagination } from "@/components/Pagination"; 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 { api, humanizeError } from "@/lib/api";
import { useAsync } from "@/lib/hooks"; import { useAsync } from "@/lib/hooks";
import { formatRelative, formatAbsolute } from "@/lib/format"; 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]; 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() { export function MySubmissionsPage() {
const { t, i18n } = useTranslation("submissions"); const { t, i18n } = useTranslation("submissions");
const locale = i18n.language; 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between gap-3"> <PageHeader
<div className="flex items-center gap-3"> icon={Upload}
<Upload className="h-6 w-6 text-primary" /> title={t("title")}
<div> subtitle={t("subtitle")}
<h1 className="text-2xl font-semibold tracking-tight">{t("title")}</h1> actions={
<p className="text-sm text-muted-foreground">{t("subtitle")}</p> <Button
</div> onClick={() => {
</div> setError(null);
setDisplayName("");
{/* Submit Button to Open Dialog */} setFile(null);
<Button setDialogOpen(true);
onClick={() => { }}
setError(null); size="sm"
setDisplayName(""); className="gap-1.5"
setFile(null); >
setDialogOpen(true); <Plus className="h-4 w-4" />
}} {t("submit_card_title")}
className="text-xs gap-1.5" </Button>
> }
<Plus className="h-4 w-4" /> className="mb-6"
{t("submit_card_title")} />
</Button>
</div>
{/* Stats Cards Row */} {/* Stats Cards Row */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
@@ -276,15 +244,7 @@ export function MySubmissionsPage() {
<CardContent className="p-0"> <CardContent className="p-0">
{/* Filters Bar */} {/* Filters Bar */}
<div className="flex flex-col sm:flex-row gap-3 p-4 border-b"> <div className="flex flex-col sm:flex-row gap-3 p-4 border-b">
<div className="relative flex-1 w-full"> <SearchInput value={search} onChange={setSearch} placeholder={t("search_placeholder")} />
<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>
<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"> <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 <button
type="button" type="button"
@@ -403,14 +363,7 @@ export function MySubmissionsPage() {
{/* Status */} {/* Status */}
<div className="col-span-1 md:col-span-3 md:text-center"> <div className="col-span-1 md:col-span-3 md:text-center">
<Badge <SubmissionStatusBadge status={sub.status} />
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>
</div> </div>
{/* Created At */} {/* Created At */}
@@ -494,12 +447,7 @@ export function MySubmissionsPage() {
<form onSubmit={handleFormSubmit} className="space-y-4 pt-2"> <form onSubmit={handleFormSubmit} className="space-y-4 pt-2">
{/* Error Banner */} {/* Error Banner */}
{error && ( {error && <MessageLine kind="error" message={error} compact />}
<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>
)}
{/* Display Name Input */} {/* Display Name Input */}
<div className="space-y-1.5"> <div className="space-y-1.5">
+21 -56
View File
@@ -1,30 +1,30 @@
import { useState } from "react"; import { useState } from "react";
import { Link, useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { import {
ArrowLeft,
Archive, Archive,
CheckCircle2, CheckCircle2,
Clock, Clock,
HardDrive, HardDrive,
Loader2, Loader2,
RotateCcw, RotateCcw,
ShieldX,
UserMinus, UserMinus,
} from "lucide-react"; } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { BackLink } from "@/components/BackLink";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { ConfirmFooter } from "@/components/ConfirmFooter";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { PhaseBadge } from "@/components/PhaseBadge"; 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 { api, humanizeError } from "@/lib/api";
import { useAsync } from "@/lib/hooks"; import { useAsync } from "@/lib/hooks";
import { useTier } from "@/lib/tier"; import { useTier } from "@/lib/tier";
@@ -32,25 +32,6 @@ import { formatBytes, formatRelative, formatAbsolute, isExpired } from "@/lib/fo
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { BackupView } from "@/lib/types"; 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 /** 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 * 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 * 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>} {error && <p className="text-sm text-destructive">{error}</p>}
<DialogFooter> <ConfirmFooter
<Button variant="ghost" onClick={() => setOpen(false)} disabled={submitting}> onCancel={() => setOpen(false)}
{t("cancel")} onConfirm={confirmRestore}
</Button> loading={submitting}
<Button variant="destructive" onClick={confirmRestore} disabled={submitting}> cancelLabel={t("cancel")}
{submitting ? ( confirmLabel={t("restore_confirm_yes")}
<Loader2 className="h-4 w-4 animate-spin" /> />
) : (
<RotateCcw className="h-4 w-4" />
)}
{t("restore_confirm_yes")}
</Button>
</DialogFooter>
</DialogContent> </DialogContent>
); );
@@ -365,12 +340,7 @@ export function ServerBackups() {
const backupsQ = useAsync(() => api.listBackups(), []); const backupsQ = useAsync(() => api.listBackups(), []);
const back = ( const back = (
<Link <BackLink to={`/servers/${name}`} label={t("back_to_console")} />
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>
); );
if (statusQ.loading && !statusQ.data) { 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)); .sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at));
const header = ( const header = (
<div className="flex flex-wrap items-center justify-between gap-3"> <PageHeader
<div className="flex items-center gap-3"> icon={Archive}
<Archive className="h-6 w-6 text-primary" /> title={statusQ.data.displayName || statusQ.data.name}
<div> subtitle={t("title")}
<h1 className="text-2xl font-semibold tracking-tight"> actions={<PhaseBadge phase={statusQ.data.phase} />}
{statusQ.data.displayName || statusQ.data.name} className="mb-6"
</h1> />
<p className="text-sm text-muted-foreground">{t("title")}</p>
</div>
</div>
<PhaseBadge phase={statusQ.data.phase} />
</div>
); );
return ( return (
@@ -430,7 +395,7 @@ export function ServerBackups() {
) : mineQ.error ? ( ) : mineQ.error ? (
<ErrorState error={mineQ.error} onRetry={mineQ.reload} /> <ErrorState error={mineQ.error} onRetry={mineQ.reload} />
) : !owned ? ( ) : !owned ? (
<NotYours /> <NotYours title={t("not_yours_title")} body={t("not_yours_body")} />
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
<p className="text-sm text-muted-foreground">{t("subtitle")}</p> <p className="text-sm text-muted-foreground">{t("subtitle")}</p>
+26 -36
View File
@@ -1,10 +1,12 @@
import { useState, useRef, useCallback, useLayoutEffect, type KeyboardEvent } from "react"; import { useState, useRef, useCallback, useLayoutEffect, type KeyboardEvent } from "react";
import { Link, useParams } from "react-router-dom"; 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 { useTranslation } from "react-i18next";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { BackLink } from "@/components/BackLink";
import { PhaseBadge } from "@/components/PhaseBadge"; import { PhaseBadge } from "@/components/PhaseBadge";
import { PageHeader } from "@/components/PageHeader";
import { LogConsole } from "@/components/LogConsole"; import { LogConsole } from "@/components/LogConsole";
import { Loading, ErrorState } from "@/components/States"; import { Loading, ErrorState } from "@/components/States";
import { api, consoleStreamURL, humanizeError } from "@/lib/api"; import { api, consoleStreamURL, humanizeError } from "@/lib/api";
@@ -191,12 +193,7 @@ export function ServerConsole() {
return ( return (
<div className="flex flex-col lg:h-[calc(100vh-3.5rem)] lg:min-h-[35rem] gap-4 min-h-0"> <div className="flex flex-col lg:h-[calc(100vh-3.5rem)] lg:min-h-[35rem] gap-4 min-h-0">
<div> <div>
<Link <BackLink to="/servers" label={t("my_servers_breadcrumb")} />
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>
</div> </div>
{loading && !data ? ( {loading && !data ? (
@@ -205,37 +202,30 @@ export function ServerConsole() {
<ErrorState error={error} onRetry={reload} /> <ErrorState error={error} onRetry={reload} />
) : data ? ( ) : data ? (
<> <>
<div className="flex flex-wrap items-center justify-between gap-3 shrink-0"> <PageHeader
<div className="flex items-center gap-3"> icon={Terminal}
<Terminal className="h-6 w-6 text-primary" /> title={data.displayName || data.name}
<div> subtitle={cfg ? hostFor(data.subdomain, cfg) : undefined}
<h1 className="text-2xl font-semibold tracking-tight"> actions={
{data.displayName || data.name} <div className="flex items-center gap-2">
</h1> <PhaseBadge phase={data.phase} />
{cfg && ( {streamable ? (
<p className="text-sm text-muted-foreground"> <Button
{hostFor(data.subdomain, cfg)} variant="destructive"
</p> 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> }
<div className="flex items-center gap-2"> className="mb-6"
<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="grid grid-cols-1 gap-6 lg:grid-cols-4 flex-1 lg:min-h-0 min-h-0"> <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 */} {/* Left/Main column: Console */}
+15 -57
View File
@@ -1,7 +1,8 @@
import { useState } from "react"; import { useState } from "react";
import { Link, useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { ArrowLeft, Moon, Shield, ShieldX, Users, Search, Plus, Trash2, Undo, History, Loader2 } from "lucide-react"; import { Shield, Users, Search, Plus, Trash2, Undo, History, Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { BackLink } from "@/components/BackLink";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -15,7 +16,8 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { PhaseBadge } from "@/components/PhaseBadge"; 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 { api, humanizeError } from "@/lib/api";
import { useAsync } from "@/lib/hooks"; import { useAsync } from "@/lib/hooks";
import { useTier } from "@/lib/tier"; import { useTier } from "@/lib/tier";
@@ -51,40 +53,6 @@ interface ActionHistoryItem {
reverted?: boolean; 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() { export function ServerLuckPerms() {
const { name = "" } = useParams(); const { name = "" } = useParams();
const { t } = useTranslation("servers"); const { t } = useTranslation("servers");
@@ -298,12 +266,7 @@ export function ServerLuckPerms() {
}; };
const back = ( const back = (
<Link <BackLink to={`/servers/${name}`} label={t("luckperms_back_to_console")} />
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>
); );
if (loading && !data) { if (loading && !data) {
@@ -329,18 +292,13 @@ export function ServerLuckPerms() {
const phase: Phase = data.phase; const phase: Phase = data.phase;
const header = ( const header = (
<div className="flex flex-wrap items-center justify-between gap-3 mb-6"> <PageHeader
<div className="flex items-center gap-3"> icon={Shield}
<Shield className="h-6 w-6 text-primary" /> title={data.displayName || data.name}
<div> subtitle={t("luckperms_desc")}
<h1 className="text-2xl font-semibold tracking-tight"> actions={<PhaseBadge phase={phase} />}
{data.displayName || data.name} className="mb-6"
</h1> />
<p className="text-sm text-muted-foreground">{t("luckperms_desc")}</p>
</div>
</div>
<PhaseBadge phase={phase} />
</div>
); );
return ( return (
@@ -352,9 +310,9 @@ export function ServerLuckPerms() {
) : mineError ? ( ) : mineError ? (
<ErrorState error={mineError} onRetry={reloadMine} /> <ErrorState error={mineError} onRetry={reloadMine} />
) : !owned ? ( ) : !owned ? (
<NotYours /> <NotYours title={t("players_not_yours_title")} body={t("players_not_yours_body")} />
) : phase !== "Running" ? ( ) : 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"> <div className="grid grid-cols-1 lg:grid-cols-4 gap-6 items-start">
{/* Left Directory Sidebar: Single card for Search + Online Players */} {/* Left Directory Sidebar: Single card for Search + Online Players */}
+15 -65
View File
@@ -1,9 +1,11 @@
import { Link, useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { ArrowLeft, Moon, ShieldX, Users } from "lucide-react"; import { Users } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { BackLink } from "@/components/BackLink";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { PhaseBadge } from "@/components/PhaseBadge"; 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 { OnlineSection } from "@/components/players/OnlineSection";
import { WhitelistSection } from "@/components/players/WhitelistSection"; import { WhitelistSection } from "@/components/players/WhitelistSection";
import { BansSection } from "@/components/players/BansSection"; import { BansSection } from "@/components/players/BansSection";
@@ -12,48 +14,6 @@ import { useAsync } from "@/lib/hooks";
import { useTier } from "@/lib/tier"; import { useTier } from "@/lib/tier";
import type { Phase } from "@/lib/types"; 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): /** 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 — * 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 * ownership (from /me/servers, since GET status never carries `owned`) and server
@@ -73,12 +33,7 @@ export function ServerPlayers() {
); );
const back = ( const back = (
<Link <BackLink to={`/servers/${name}`} label={t("players_back_to_console")} />
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>
); );
if (loading && !data) { if (loading && !data) {
@@ -112,18 +67,13 @@ export function ServerPlayers() {
const phase: Phase = data.phase; const phase: Phase = data.phase;
const header = ( const header = (
<div className="flex flex-wrap items-center justify-between gap-3"> <PageHeader
<div className="flex items-center gap-3"> icon={Users}
<Users className="h-6 w-6 text-primary" /> title={data.displayName || data.name}
<div> subtitle={t("players_title")}
<h1 className="text-2xl font-semibold tracking-tight"> actions={<PhaseBadge phase={phase} />}
{data.displayName || data.name} className="mb-6"
</h1> />
<p className="text-sm text-muted-foreground">{t("players_title")}</p>
</div>
</div>
<PhaseBadge phase={phase} />
</div>
); );
return ( return (
@@ -135,9 +85,9 @@ export function ServerPlayers() {
) : mineError ? ( ) : mineError ? (
<ErrorState error={mineError} onRetry={reloadMine} /> <ErrorState error={mineError} onRetry={reloadMine} />
) : !owned ? ( ) : !owned ? (
<NotYours /> <NotYours title={t("players_not_yours_title")} body={t("players_not_yours_body")} />
) : phase !== "Running" ? ( ) : 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"> <div className="space-y-4">
{/* Ordered as a who-may-be-here gradient: who is on right now → who may {/* Ordered as a who-may-be-here gradient: who is on right now → who may
+29 -88
View File
@@ -1,21 +1,25 @@
import { useState, useMemo } from "react"; import { 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 { useTranslation } from "react-i18next";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { ConfirmFooter } from "@/components/ConfirmFooter";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { MessageLine } from "@/components/MessageLine";
import { Loading, ErrorState, EmptyState } from "@/components/States"; import { Loading, ErrorState, EmptyState } from "@/components/States";
import { Pagination } from "@/components/Pagination"; import { Pagination } from "@/components/Pagination";
import { api, humanizeError } from "@/lib/api"; import { api, humanizeError } from "@/lib/api";
@@ -23,32 +27,6 @@ import { useAsync } from "@/lib/hooks";
const PAGE_SIZE = 10; 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() { export function ImageAdmin() {
const { t } = useTranslation("admin"); const { t } = useTranslation("admin");
const { data, error, loading, reload } = useAsync(() => api.listImages(), []); const { data, error, loading, reload } = useAsync(() => api.listImages(), []);
@@ -71,8 +49,10 @@ export function ImageAdmin() {
const total = images.length; const total = images.length;
const enabled = images.filter((img) => img.enabled).length; const enabled = images.filter((img) => img.enabled).length;
const disabled = total - enabled; const disabled = total - enabled;
return { total, enabled, disabled }; const buildCount = total;
return { total, enabled, disabled, buildCount };
}, [images]); }, [images]);
const { total, enabled, disabled, buildCount } = stats;
// Filtered & Paginated Images // Filtered & Paginated Images
const filteredImages = useMemo(() => { const filteredImages = useMemo(() => {
@@ -111,8 +91,8 @@ export function ImageAdmin() {
return filteredImages.slice(start, start + PAGE_SIZE); return filteredImages.slice(start, start + PAGE_SIZE);
}, [filteredImages, page]); }, [filteredImages, page]);
async function handleAdd(e: React.FormEvent) { async function handleAdd(e?: React.FormEvent) {
e.preventDefault(); e?.preventDefault();
if (!newImageRef.trim()) return; if (!newImageRef.trim()) return;
setSubmitting(true); setSubmitting(true);
setSubmitError(null); setSubmitError(null);
@@ -143,19 +123,7 @@ export function ImageAdmin() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} <PageHeader icon={Boxes} title={t("images_title")} subtitle={t("images_subtitle")} actions={
<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 */}
<Dialog open={dialogOpen} onOpenChange={(o) => { <Dialog open={dialogOpen} onOpenChange={(o) => {
setDialogOpen(o); setDialogOpen(o);
if (!o) { if (!o) {
@@ -164,7 +132,7 @@ export function ImageAdmin() {
} }
}}> }}>
<DialogTrigger asChild> <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")} <Plus className="h-4 w-4" /> {t("add_image_btn")}
</Button> </Button>
</DialogTrigger> </DialogTrigger>
@@ -189,45 +157,26 @@ export function ImageAdmin() {
required required
/> />
</div> </div>
{submitError && ( {submitError && <MessageLine kind="error" message={submitError} compact />}
<p className="text-xs text-destructive mt-1 font-medium bg-destructive/10 p-2.5 rounded-md"> <ConfirmFooter
{submitError} onCancel={() => setDialogOpen(false)}
</p> onConfirm={() => handleAdd()}
)} disabled={submitting || !newImageRef.trim()}
<DialogFooter className="gap-2 pt-2"> loading={submitting}
<Button cancelLabel={t("common:cancel")}
type="button" confirmLabel={t("add_image_btn")}
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>
</form> </form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> } className="mb-6" />
{/* Stats Cards Row */} {/* Stats Cards Row */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
<StatCard icon={Boxes} label="总镜像数" value={stats.total} /> <StatCard icon={Boxes} label={t("filter_all")} value={total} accentClass="text-primary bg-primary/10" />
<StatCard icon={CheckCircle} label="已启用" value={stats.enabled} 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="已禁用" value={stats.disabled} accentClass="text-muted-foreground bg-muted/20" /> <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> </div>
{/* Whitelist Table Card */} {/* Whitelist Table Card */}
@@ -235,15 +184,7 @@ export function ImageAdmin() {
<CardContent className="p-0"> <CardContent className="p-0">
{/* Filters Bar */} {/* Filters Bar */}
<div className="flex flex-col sm:flex-row gap-3 p-4 border-b"> <div className="flex flex-col sm:flex-row gap-3 p-4 border-b">
<div className="relative flex-1 w-full"> <SearchInput value={search} onChange={setSearch} placeholder="搜索镜像名称或来源..." />
<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>
<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"> <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 <button
type="button" type="button"
+20 -56
View File
@@ -1,16 +1,17 @@
import { useEffect, useState, useMemo, Fragment } from "react"; 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 { useTranslation } from "react-i18next";
import { SearchInput } from "@/components/SearchInput";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { ConfirmFooter } from "@/components/ConfirmFooter";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
@@ -18,6 +19,8 @@ import {
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { LogConsole } from "@/components/LogConsole"; import { LogConsole } from "@/components/LogConsole";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { PageHeader } from "@/components/PageHeader";
import { MessageLine } from "@/components/MessageLine";
import { Loading, EmptyState } from "@/components/States"; import { Loading, EmptyState } from "@/components/States";
import { Pagination } from "@/components/Pagination"; import { Pagination } from "@/components/Pagination";
import { api, buildLogsStreamURL, humanizeError } from "@/lib/api"; import { api, buildLogsStreamURL, humanizeError } from "@/lib/api";
@@ -216,8 +219,8 @@ export function ImageBuildPage() {
}, [filteredBuilds, page]); }, [filteredBuilds, page]);
// Form submission handler // Form submission handler
const handleTrigger = async (e: React.FormEvent) => { const handleTrigger = async (e?: React.FormEvent) => {
e.preventDefault(); e?.preventDefault();
if (!imageRef.trim() || !contextRef.trim() || !dockerfile.trim()) return; if (!imageRef.trim() || !contextRef.trim() || !dockerfile.trim()) return;
setTriggering(true); setTriggering(true);
@@ -269,17 +272,7 @@ export function ImageBuildPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} <PageHeader icon={Cpu} title={t("builds_title")} subtitle={t("builds_subtitle")} actions={
<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 */}
<Dialog open={dialogOpen} onOpenChange={(o) => { <Dialog open={dialogOpen} onOpenChange={(o) => {
setDialogOpen(o); setDialogOpen(o);
if (!o) { if (!o) {
@@ -288,7 +281,7 @@ export function ImageBuildPage() {
} }
}}> }}>
<DialogTrigger asChild> <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")} <Plus className="h-4 w-4" /> {t("trigger_build_title")}
</Button> </Button>
</DialogTrigger> </DialogTrigger>
@@ -421,56 +414,27 @@ export function ImageBuildPage() {
/> />
</div> </div>
{triggerError && ( {triggerError && <MessageLine kind="error" message={triggerError} compact />}
<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>
)}
<DialogFooter className="gap-2 pt-2"> <ConfirmFooter
<Button onCancel={() => setDialogOpen(false)}
type="button" onConfirm={() => handleTrigger()}
variant="outline" disabled={triggering || !imageRef.trim() || !contextRef.trim() || !dockerfile.trim()}
onClick={() => setDialogOpen(false)} loading={triggering}
disabled={triggering} cancelLabel={t("common:cancel")}
className="text-xs" confirmLabel={t("trigger_build_btn")}
> />
{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>
</form> </form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> } className="mb-6" />
{/* Build History Table Card */} {/* Build History Table Card */}
<Card className="w-full"> <Card className="w-full">
<CardContent className="p-0"> <CardContent className="p-0">
{/* Filters Bar */} {/* Filters Bar */}
<div className="p-4 border-b"> <div className="p-4 border-b">
<div className="relative w-full"> <SearchInput value={search} onChange={setSearch} placeholder="搜索构建 ID、镜像引用或状态..." />
<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>
</div> </div>
{/* List Content */} {/* List Content */}
+22 -95
View File
@@ -1,20 +1,24 @@
import { useState, useMemo } from "react"; 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 { useTranslation } from "react-i18next";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; 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 { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { ConfirmFooter } from "@/components/ConfirmFooter";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { MessageLine } from "@/components/MessageLine";
import { Loading, ErrorState, EmptyState } from "@/components/States"; import { Loading, ErrorState, EmptyState } from "@/components/States";
import { Pagination } from "@/components/Pagination"; import { Pagination } from "@/components/Pagination";
import { api, humanizeError } from "@/lib/api"; import { api, humanizeError } from "@/lib/api";
@@ -24,32 +28,6 @@ import type { Submission, SubmissionStatus } from "@/lib/types";
const PAGE_SIZE = 10; 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() { export function SubmissionsPage() {
const { t, i18n } = useTranslation("admin"); const { t, i18n } = useTranslation("admin");
const locale = i18n.language; const locale = i18n.language;
@@ -142,8 +120,8 @@ export function SubmissionsPage() {
setRejectDialogOpen(true); setRejectDialogOpen(true);
} }
async function handleRejectSubmit(e: React.FormEvent) { async function handleRejectSubmit(e?: React.FormEvent) {
e.preventDefault(); e?.preventDefault();
if (!rejectingId || !rejectReason.trim()) return; if (!rejectingId || !rejectReason.trim()) return;
setBusyId(rejectingId); setBusyId(rejectingId);
setBusyType("reject"); 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} <PageHeader icon={ClipboardCheck} title={t("submissions_title")} subtitle={t("submissions_subtitle")} className="mb-6" />
<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>
{/* Action Error Alert */} {/* Action Error Alert */}
{actionError && ( {actionError && <MessageLine kind="error" message={actionError} compact />}
<p className="text-xs text-destructive font-medium bg-destructive/10 p-2.5 rounded-md">
{actionError}
</p>
)}
{/* Stats Cards Row */} {/* Stats Cards Row */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
@@ -201,15 +158,7 @@ export function SubmissionsPage() {
<CardContent className="p-0"> <CardContent className="p-0">
{/* Filters Bar */} {/* Filters Bar */}
<div className="flex flex-col sm:flex-row gap-3 p-4 border-b"> <div className="flex flex-col sm:flex-row gap-3 p-4 border-b">
<div className="relative flex-1 w-full"> <SearchInput value={search} onChange={setSearch} placeholder="搜索模组包名称或提交人..." />
<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>
<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"> <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 <button
type="button" type="button"
@@ -332,14 +281,7 @@ export function SubmissionsPage() {
{/* Status */} {/* Status */}
<div className="col-span-2 md:text-center py-0.5"> <div className="col-span-2 md:text-center py-0.5">
<Badge <SubmissionStatusBadge status={sub.status} />
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>
</div> </div>
{/* Created At */} {/* 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" 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> </div>
<DialogFooter className="gap-2 pt-2"> <ConfirmFooter
<Button onCancel={() => setRejectDialogOpen(false)}
type="button" onConfirm={() => handleRejectSubmit()}
variant="outline" disabled={!!busyId || !rejectReason.trim()}
onClick={() => setRejectDialogOpen(false)} loading={!!busyId && busyType === "reject"}
disabled={!!busyId} cancelLabel={t("common:cancel")}
className="text-xs" confirmLabel={t("reject_dialog_submit")}
> confirmVariant="destructive"
{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>
</form> </form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
+13 -52
View File
@@ -1,6 +1,10 @@
import React, { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Clock, Loader2, Calendar, AlertCircle, CheckCircle2, Globe, LucideIcon } from "lucide-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 { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -9,7 +13,6 @@ import { Loading, ErrorState } from "@/components/States";
import { api, humanizeError } from "@/lib/api"; import { api, humanizeError } from "@/lib/api";
import { useAsync } from "@/lib/hooks"; import { useAsync } from "@/lib/hooks";
import { formatAbsolute } from "@/lib/format"; import { formatAbsolute } from "@/lib/format";
import { cn } from "@/lib/utils";
function toLocalDatetimeString(dateOrStr: Date | string | null | undefined): string { function toLocalDatetimeString(dateOrStr: Date | string | null | undefined): string {
if (!dateOrStr) return ""; if (!dateOrStr) return "";
@@ -24,36 +27,6 @@ function toLocalDatetimeString(dateOrStr: Date | string | null | undefined): str
return `${YYYY}-${MM}-${DD}T${hh}:${mm}`; 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() { export function UpdatesPage() {
const { t, i18n } = useTranslation("admin"); const { t, i18n } = useTranslation("admin");
const locale = i18n.language; const locale = i18n.language;
@@ -191,16 +164,7 @@ export function UpdatesPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} <PageHeader icon={Clock} title={t("updates_title")} subtitle={t("updates_subtitle")} className="mb-6" />
<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>
{/* Stats Cards Row */} {/* Stats Cards Row */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
@@ -209,25 +173,28 @@ export function UpdatesPage() {
label={t("updates_stat_status")} label={t("updates_stat_status")}
value={statusText} value={statusText}
accentClass={statusAccentClass} accentClass={statusAccentClass}
valueClass={statusColorClass} valueClass={cn("text-sm sm:text-base font-bold font-mono leading-tight truncate", statusColorClass)}
/> />
<StatCard <StatCard
icon={Calendar} icon={Calendar}
label={t("updates_stat_start")} label={t("updates_stat_start")}
value={data?.start ? formatAbsolute(data.start, locale) : "—"} value={data?.start ? formatAbsolute(data.start, locale) : "—"}
accentClass="text-primary bg-primary/10" accentClass="text-primary bg-primary/10"
valueClass="text-sm sm:text-base font-bold font-mono leading-tight truncate"
/> />
<StatCard <StatCard
icon={Calendar} icon={Calendar}
label={t("updates_stat_end")} label={t("updates_stat_end")}
value={data?.end ? formatAbsolute(data.end, locale) : "—"} value={data?.end ? formatAbsolute(data.end, locale) : "—"}
accentClass="text-primary bg-primary/10" accentClass="text-primary bg-primary/10"
valueClass="text-sm sm:text-base font-bold font-mono leading-tight truncate"
/> />
<StatCard <StatCard
icon={Globe} icon={Globe}
label={t("updates_stat_timezone")} label={t("updates_stat_timezone")}
value={tz} value={tz}
accentClass="text-primary bg-primary/10" accentClass="text-primary bg-primary/10"
valueClass="text-sm sm:text-base font-bold font-mono leading-tight truncate"
/> />
</div> </div>
@@ -278,17 +245,11 @@ export function UpdatesPage() {
</div> </div>
{actionError && ( {actionError && (
<div className="flex items-center gap-2 rounded-md border border-destructive/20 bg-destructive/10 p-3 text-sm text-destructive"> <MessageLine kind="error" message={actionError} />
<AlertCircle className="h-4 w-4 shrink-0" />
<p>{actionError}</p>
</div>
)} )}
{successMsg && ( {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"> <MessageLine kind="success" message={successMsg} />
<CheckCircle2 className="h-4 w-4 shrink-0" />
<p>{successMsg}</p>
</div>
)} )}
<div className="flex flex-wrap gap-3 pt-2"> <div className="flex flex-wrap gap-3 pt-2">
+45 -154
View File
@@ -1,13 +1,12 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useParams, useNavigate } from "react-router-dom"; import { useParams, useNavigate } from "react-router-dom";
import { MessageLine } from "@/components/MessageLine";
import { RoleBadge } from "@/components/RoleBadge";
import { UserStatusBadge } from "@/components/UserStatusBadge";
import { import {
ArrowLeft,
UserRound, UserRound,
Mail, Mail,
Shield,
Crown,
Calendar, Calendar,
Circle,
Key, Key,
Clock, Clock,
Trash2, Trash2,
@@ -15,7 +14,6 @@ import {
PowerOff, PowerOff,
Save, Save,
Loader2, Loader2,
AlertCircle,
CheckCircle2, CheckCircle2,
RefreshCw, RefreshCw,
Unlink, Unlink,
@@ -25,6 +23,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { BackLink } from "@/components/BackLink";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -35,15 +34,16 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { ConfirmFooter } from "@/components/ConfirmFooter";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Loading, ErrorState } from "@/components/States"; import { Loading, ErrorState } from "@/components/States";
import { PageHeader } from "@/components/PageHeader";
import { api, humanizeError } from "@/lib/api"; import { api, humanizeError } from "@/lib/api";
import { useAsync } from "@/lib/hooks"; import { useAsync } from "@/lib/hooks";
import { useTier } from "@/lib/tier"; import { useTier } from "@/lib/tier";
@@ -70,71 +70,43 @@ export function UserDetailPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Back link */} {/* Back link */}
<button <BackLink to="/admin/users" label={t("users_back_to_list")} />
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>
{/* Header */} <PageHeader
<div className="flex flex-wrap items-center justify-between gap-3"> icon={(
<div className="flex items-center gap-3">
<div className={cn( <div className={cn(
"rounded-full p-2", "rounded-full p-2",
user.role === "admin" ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground", user.role === "admin" ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground",
)}> )}>
<UserRound className="h-6 w-6" /> <UserRound className="h-6 w-6" />
</div> </div>
<div> )}
<h1 className="text-2xl font-semibold tracking-tight">{user.username}</h1> title={user.username}
<div className="flex flex-wrap items-center gap-2 mt-1 text-sm text-muted-foreground"> subtitle={(
{user.email && ( <div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span className="inline-flex items-center gap-1"> {user.email && (
<Mail className="h-3.5 w-3.5" />
{user.email}
{user.email_verified && (
<CheckCircle2 className="h-3 w-3 text-emerald-500" />
)}
</span>
)}
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">
<Calendar className="h-3.5 w-3.5" /> <Mail className="h-3.5 w-3.5" />
{formatAbsolute(user.created_at, locale)} {user.email}
{user.email_verified && (
<CheckCircle2 className="h-3 w-3 text-emerald-500" />
)}
</span> </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 className="inline-flex items-center gap-1">
</span> <Calendar className="h-3.5 w-3.5" />
</div> {formatAbsolute(user.created_at, locale)}
</div> </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"> <div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* Edit profile */} {/* Edit profile */}
@@ -227,16 +199,10 @@ function EditProfileCard({ user, onSaved, isSelf }: { user: UserDetail; onSaved:
)} )}
{err && ( {err && (
<div className="flex items-center gap-2 rounded-md border border-destructive/20 bg-destructive/10 p-3 text-sm text-destructive"> <MessageLine kind="error" message={err} />
<AlertCircle className="h-4 w-4 shrink-0" />
<p>{err}</p>
</div>
)} )}
{ok && ( {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"> <MessageLine kind="success" message={ok} />
<CheckCircle2 className="h-4 w-4 shrink-0" />
<p>{ok}</p>
</div>
)} )}
<Button <Button
@@ -395,14 +361,7 @@ function LinkedAccountsCard({ user, onChanged }: { user: UserDetail; onChanged:
<DialogTitle>{t("users_unlink_dlg_title")}</DialogTitle> <DialogTitle>{t("users_unlink_dlg_title")}</DialogTitle>
<DialogDescription>{t("users_unlink_dlg_desc")}</DialogDescription> <DialogDescription>{t("users_unlink_dlg_desc")}</DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <ConfirmFooter onCancel={() => setUnlinkDlg(null)} onConfirm={handleUnlinkConfirm} cancelLabel={t("common:cancel")} confirmLabel={t("users_unlink_btn")} />
<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>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</> </>
@@ -512,16 +471,10 @@ function QuotasCard({ userId }: { userId: string }) {
</div> </div>
{err && ( {err && (
<div className="flex items-center gap-2 rounded-md border border-destructive/20 bg-destructive/10 p-3 text-sm text-destructive"> <MessageLine kind="error" message={err} />
<AlertCircle className="h-4 w-4 shrink-0" />
<p>{err}</p>
</div>
)} )}
{ok && ( {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"> <MessageLine kind="success" message={ok} />
<CheckCircle2 className="h-4 w-4 shrink-0" />
<p>{ok}</p>
</div>
)} )}
<Button onClick={handleSave} disabled={saving} size="sm" className="gap-1.5"> <Button onClick={handleSave} disabled={saving} size="sm" className="gap-1.5">
@@ -604,16 +557,10 @@ function SessionsCard({ userId, onChanged }: { userId: string; onChanged: () =>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{err && ( {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"> <MessageLine kind="error" message={err} />
<AlertCircle className="h-4 w-4 shrink-0" />
<p>{err}</p>
</div>
)} )}
{ok && ( {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"> <MessageLine kind="success" message={ok} />
<CheckCircle2 className="h-4 w-4 shrink-0" />
<p>{ok}</p>
</div>
)} )}
{loading ? ( {loading ? (
<Loading label="" /> <Loading label="" />
@@ -665,14 +612,7 @@ function SessionsCard({ userId, onChanged }: { userId: string; onChanged: () =>
<DialogTitle>{t("users_session_revoke_one_dlg_title")}</DialogTitle> <DialogTitle>{t("users_session_revoke_one_dlg_title")}</DialogTitle>
<DialogDescription>{t("users_session_revoke_one_dlg_desc")}</DialogDescription> <DialogDescription>{t("users_session_revoke_one_dlg_desc")}</DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <ConfirmFooter onCancel={() => setRevokeOneDlg(null)} onConfirm={handleRevokeOne} cancelLabel={t("common:cancel")} confirmLabel={t("users_session_revoke_confirm")} />
<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>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -683,14 +623,7 @@ function SessionsCard({ userId, onChanged }: { userId: string; onChanged: () =>
<DialogTitle>{t("users_session_revoke_all_dlg_title")}</DialogTitle> <DialogTitle>{t("users_session_revoke_all_dlg_title")}</DialogTitle>
<DialogDescription>{t("users_session_revoke_all_dlg_desc")}</DialogDescription> <DialogDescription>{t("users_session_revoke_all_dlg_desc")}</DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <ConfirmFooter onCancel={() => setRevokeAllDlg(false)} onConfirm={handleRevokeAll} cancelLabel={t("common:cancel")} confirmLabel={t("users_session_revoke_confirm")} />
<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>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</> </>
@@ -864,21 +797,7 @@ function DangerDialogs({
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{err && <p className="text-sm text-destructive">{err}</p>} {err && <p className="text-sm text-destructive">{err}</p>}
<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"} />
<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>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -903,20 +822,7 @@ function DangerDialogs({
{ok} {ok}
</p> </p>
)} )}
<DialogFooter> <ConfirmFooter onCancel={close} onConfirm={handleResetPassword} loading={loading} disabled={loading || ok !== null} cancelLabel={t("common:cancel")} confirmLabel={t("users_danger_reset_pw_confirm")} />
<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>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -933,22 +839,7 @@ function DangerDialogs({
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{err && <p className="text-sm text-destructive">{err}</p>} {err && <p className="text-sm text-destructive">{err}</p>}
<DialogFooter> <ConfirmFooter onCancel={close} onConfirm={handleDelete} loading={loading} cancelLabel={t("common:cancel")} confirmLabel={t("users_danger_delete_yes")} />
<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>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</> </>
+8 -75
View File
@@ -1,12 +1,10 @@
import { useState, useCallback } from "react"; import { useState, useCallback } from "react";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { RoleBadge } from "@/components/RoleBadge";
import { UserStatusBadge } from "@/components/UserStatusBadge";
import { import {
Users, Users,
Search, Search,
Circle,
Shield,
UserRound,
Crown,
Server, Server,
Mail, Mail,
ChevronRight, ChevronRight,
@@ -23,6 +21,8 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Loading, ErrorState, EmptyState } from "@/components/States"; import { Loading, ErrorState, EmptyState } from "@/components/States";
import { PageHeader } from "@/components/PageHeader";
import { Pagination } from "@/components/Pagination";
import { CreateUserDialog } from "@/components/CreateUserDialog"; import { CreateUserDialog } from "@/components/CreateUserDialog";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { useAsync } from "@/lib/hooks"; import { useAsync } from "@/lib/hooks";
@@ -65,22 +65,7 @@ export function UsersPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} <PageHeader icon={Users} title={t("users_title")} subtitle={t("users_subtitle")} actions={<CreateUserDialog onCreated={(id) => { reload(); navigate(`/admin/users/${id}`); }} />} className="mb-6" />
<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>
{/* Filters */} {/* Filters */}
<Card> <Card>
@@ -166,34 +151,8 @@ export function UsersPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* Pagination */}
{totalPages > 1 && ( {totalPages > 1 && (
<div className="flex items-center justify-between text-sm"> <Pagination page={page + 1} pageSize={pageSize} total={data.total} onChange={(p) => setPage(p - 1)} />
<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>
)} )}
</> </>
)} )}
@@ -226,23 +185,7 @@ function UserRow({ user, locale }: { user: UserView; locale: string }) {
)} )}
</td> </td>
<td className="px-4 py-3 hidden sm:table-cell"> <td className="px-4 py-3 hidden sm:table-cell">
<span className={cn( <RoleBadge role={user.role} />
"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>
</td> </td>
<td className="px-4 py-3 hidden md:table-cell"> <td className="px-4 py-3 hidden md:table-cell">
<span className="inline-flex items-center gap-1 text-muted-foreground"> <span className="inline-flex items-center gap-1 text-muted-foreground">
@@ -251,17 +194,7 @@ function UserRow({ user, locale }: { user: UserView; locale: string }) {
</span> </span>
</td> </td>
<td className="px-4 py-3 hidden lg:table-cell"> <td className="px-4 py-3 hidden lg:table-cell">
{user.disabled ? ( <UserStatusBadge disabled={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>
)}
</td> </td>
<td className="px-4 py-3 text-muted-foreground hidden lg:table-cell text-xs"> <td className="px-4 py-3 text-muted-foreground hidden lg:table-cell text-xs">
{formatAbsolute(user.created_at, locale)} {formatAbsolute(user.created_at, locale)}
+53 -97
View File
@@ -6,7 +6,6 @@ import {
Square, Square,
Terminal, Terminal,
ExternalLink, ExternalLink,
Search,
RefreshCw, RefreshCw,
Server, Server,
Users, Users,
@@ -17,12 +16,12 @@ import {
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { SearchInput } from "@/components/SearchInput";
import { ConfirmFooter } from "@/components/ConfirmFooter";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } 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 { Loading, ErrorState, EmptyState } from "@/components/States";
import { Pagination } from "@/components/Pagination"; import { Pagination } from "@/components/Pagination";
import { CreateServerDialog } from "@/components/CreateServerDialog"; import { CreateServerDialog } from "@/components/CreateServerDialog";
import { StatCard } from "@/components/StatCard";
import { PageHeader } from "@/components/PageHeader";
import { api, humanizeError } from "@/lib/api"; import { api, humanizeError } from "@/lib/api";
import { useAsync, useConfig } from "@/lib/hooks"; import { useAsync, useConfig } from "@/lib/hooks";
import { useTier } from "@/lib/tier"; import { useTier } from "@/lib/tier";
@@ -83,38 +84,6 @@ interface UnifiedServer {
owned?: boolean; 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() { export function ServersPage() {
const { t } = useTranslation(["ops", "servers"]); const { t } = useTranslation(["ops", "servers"]);
const { isAdmin, identity } = useTier(); const { isAdmin, identity } = useTier();
@@ -218,35 +187,30 @@ export function ServersPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-4"> <PageHeader
<div className="flex items-center gap-3"> icon={Network}
<Network className="h-6 w-6 text-primary" /> title={isAdmin ? t("fleet_title") : t("servers:my_servers_title")}
<div className="text-left"> subtitle={isAdmin ? t("fleet_subtitle") : t("servers:my_servers_subtitle")}
<h1 className="text-2xl font-semibold tracking-tight"> actions={
{isAdmin ? t("fleet_title") : t("servers:my_servers_title")} <div className="flex items-center gap-3">
</h1> <span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<p className="text-sm text-muted-foreground"> <span className="h-1.5 w-1.5 rounded-full bg-[#22c55e] animate-pulse" />
{isAdmin ? t("fleet_subtitle") : t("servers:my_servers_subtitle")} {t("fleet_live")}
</p> </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> }
<div className="flex items-center gap-3"> className="mb-6"
{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>
{showInitialLoading ? ( {showInitialLoading ? (
<Loading /> <Loading />
@@ -265,23 +229,23 @@ export function ServersPage() {
{/* Stats Cards in a full grid row */} {/* Stats Cards in a full grid row */}
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4"> <div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<Stat icon={Server} label={t("fleet_stat_total")} value={stats.total} /> <StatCard icon={Server} label={t("fleet_stat_total")} value={stats.total} />
<Stat <StatCard
icon={Play} icon={Play}
label={t("fleet_stat_running")} label={t("fleet_stat_running")}
value={stats.counts.Running} value={stats.counts.Running}
accent={PHASE_COLOR.Running} accentColor={PHASE_COLOR.Running}
/> />
<Stat <StatCard
icon={Users} icon={Users}
label={t("fleet_stat_players")} label={t("fleet_stat_players")}
value={`${stats.playersOnline} / ${stats.playersMax}`} value={`${stats.playersOnline} / ${stats.playersMax}`}
/> />
<Stat <StatCard
icon={AlertTriangle} icon={AlertTriangle}
label={t("fleet_stat_failed")} label={t("fleet_stat_failed")}
value={stats.counts.Failed} value={stats.counts.Failed}
accent={stats.counts.Failed > 0 ? PHASE_COLOR.Failed : undefined} accentColor={stats.counts.Failed > 0 ? PHASE_COLOR.Failed : undefined}
/> />
</div> </div>
@@ -326,18 +290,14 @@ export function ServersPage() {
{/* Independent Search & Filter bar */} {/* Independent Search & Filter bar */}
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<div className="relative min-w-[14rem] flex-1"> <SearchInput
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> value={query}
<Input onChange={(v) => {
value={query} setQuery(v);
onChange={(e) => { setPage(1);
setQuery(e.target.value); }}
setPage(1); placeholder={isAdmin ? t("fleet_search_placeholder") : t("servers:search_placeholder")}
}} />
placeholder={isAdmin ? t("fleet_search_placeholder") : t("servers:search_placeholder")}
className="pl-9"
/>
</div>
<Select <Select
value={phaseFilter} value={phaseFilter}
onValueChange={(v) => { onValueChange={(v) => {
@@ -549,22 +509,18 @@ function ServerRow({
{ts("claim_server_desc", { name: server.name })} {ts("claim_server_desc", { name: server.name })}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter className="gap-2 sm:gap-0"> <ConfirmFooter
<Button variant="ghost" onClick={() => setConfirmOpen(false)} disabled={busy !== null}> onCancel={() => setConfirmOpen(false)}
{ts("access_cancel")} onConfirm={async () => {
</Button> await act("claim", () => api.claim(server.name));
<Button setConfirmOpen(false);
variant="default" }}
onClick={async () => { disabled={busy !== null}
await act("claim", () => api.claim(server.name)); loading={busy === "claim"}
setConfirmOpen(false); cancelLabel={ts("access_cancel")}
}} confirmLabel={ts("claim")}
disabled={busy !== null} confirmVariant="default"
> />
{busy === "claim" && <RefreshCw className="h-4 w-4 animate-spin mr-1" />}
{ts("claim")}
</Button>
</DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</> </>