feat(panel): implement admin submission approval and reject queue
This commit is contained in:
12 files changed
+769
-4
No files matched your search
@@ -11,6 +11,7 @@ import type {
|
|||||||
Phase,
|
Phase,
|
||||||
ServerInfo,
|
ServerInfo,
|
||||||
WhitelistImage,
|
WhitelistImage,
|
||||||
|
Submission,
|
||||||
} from "../src/lib/types";
|
} from "../src/lib/types";
|
||||||
|
|
||||||
const ACCOUNT_IDS = ["owner", "user", "linked", "setup"] as const;
|
const ACCOUNT_IDS = ["owner", "user", "linked", "setup"] as const;
|
||||||
@@ -53,6 +54,7 @@ interface MockState {
|
|||||||
backups: BackupView[];
|
backups: BackupView[];
|
||||||
builds: Build[];
|
builds: Build[];
|
||||||
passkeys: Record<AccountID, { id: string; name: string; created_at: string }[]>;
|
passkeys: Record<AccountID, { id: string; name: string; created_at: string }[]>;
|
||||||
|
submissions: Submission[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// PLAYER_NAME mirrors the backend's mcNameRe (handlers_access.go) so the mock
|
// PLAYER_NAME mirrors the backend's mcNameRe (handlers_access.go) so the mock
|
||||||
@@ -267,6 +269,39 @@ function initialState(): MockState {
|
|||||||
user: [],
|
user: [],
|
||||||
setup: [],
|
setup: [],
|
||||||
},
|
},
|
||||||
|
submissions: [
|
||||||
|
{
|
||||||
|
id: "sub-1",
|
||||||
|
submitted_by: "[email protected]",
|
||||||
|
display_name: "Pixelmon Modpack V2",
|
||||||
|
context_ref: "minio/contexts/sub-1/context.tar.gz",
|
||||||
|
status: "pending_review",
|
||||||
|
created_at: new Date(Date.now() - 3600000).toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sub-2",
|
||||||
|
submitted_by: "[email protected]",
|
||||||
|
display_name: "Create: Astral pack",
|
||||||
|
context_ref: "minio/contexts/sub-2/context.tar.gz",
|
||||||
|
status: "approved",
|
||||||
|
image_ref: "registry.felis.svc:5000/user-uploads/sub-2:latest",
|
||||||
|
build_id: "bld-1",
|
||||||
|
reviewed_by: "[email protected]",
|
||||||
|
created_at: new Date(Date.now() - 7200000).toISOString(),
|
||||||
|
reviewed_at: new Date(Date.now() - 7100000).toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sub-3",
|
||||||
|
submitted_by: "[email protected]",
|
||||||
|
display_name: "Dangerous Modpack (Exploitative)",
|
||||||
|
context_ref: "minio/contexts/sub-3/context.tar.gz",
|
||||||
|
status: "rejected",
|
||||||
|
reviewed_by: "[email protected]",
|
||||||
|
reject_reason: "Contains malicious code in scripts/run.sh that tries to download remote malware.",
|
||||||
|
created_at: new Date(Date.now() - 10800000).toISOString(),
|
||||||
|
reviewed_at: new Date(Date.now() - 10700000).toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -650,6 +685,7 @@ async function handleSession(ctx: SessionContext): Promise<boolean> {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (await handleImageRoute(ctx)) return true;
|
if (await handleImageRoute(ctx)) return true;
|
||||||
|
if (await handleSubmissionRoute(ctx)) return true;
|
||||||
return await handleServerRoute(ctx);
|
return await handleServerRoute(ctx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -816,6 +852,106 @@ async function handleImageRoute(ctx: SessionContext): Promise<boolean> {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleSubmissionRoute(ctx: SessionContext): Promise<boolean> {
|
||||||
|
if (ctx.parts[2] !== "submissions") return false;
|
||||||
|
|
||||||
|
// GET /api/v1/submissions
|
||||||
|
if (is("GET", ctx) && ctx.parts.length === 3) {
|
||||||
|
if (ctx.account.role !== "admin") {
|
||||||
|
sendError(ctx.res, 403, "forbidden", "admin account required");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
sendJSON(ctx.res, 200, { submissions: ctx.state.submissions });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/v1/submissions/{id}/approve
|
||||||
|
if (is("POST", ctx) && ctx.parts[4] === "approve" && ctx.parts.length === 5) {
|
||||||
|
if (ctx.account.role !== "admin") {
|
||||||
|
sendError(ctx.res, 403, "forbidden", "admin account required");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const id = ctx.parts[3];
|
||||||
|
const sub = ctx.state.submissions.find((s) => s.id === id);
|
||||||
|
if (!sub) {
|
||||||
|
sendError(ctx.res, 404, "not_found", "submission not found");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (sub.status !== "pending_review") {
|
||||||
|
sendError(ctx.res, 409, "already_reviewed", "submission already reviewed");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
sub.status = "approved";
|
||||||
|
sub.reviewed_by = ctx.account.email;
|
||||||
|
sub.reviewed_at = new Date().toISOString();
|
||||||
|
sub.image_ref = `registry.felis.svc:5000/user-uploads/${id}:latest`;
|
||||||
|
|
||||||
|
// Trigger mock build
|
||||||
|
const newBuild: Build = {
|
||||||
|
id: `bld-${Date.now()}`,
|
||||||
|
image_ref: sub.image_ref,
|
||||||
|
status: "building",
|
||||||
|
dockerfile: `# felis user-modpack submission ${id}\n# The executed Dockerfile is provided by the uploaded build context:\n# ${sub.context_ref}\n`,
|
||||||
|
context_ref: sub.context_ref,
|
||||||
|
requested_by: ctx.account.email,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
ctx.state.builds.unshift(newBuild);
|
||||||
|
sub.build_id = newBuild.id;
|
||||||
|
|
||||||
|
// Succeeded after 15 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
const b = ctx.state.builds.find((x) => x.id === newBuild.id);
|
||||||
|
if (b && b.status === "building") {
|
||||||
|
b.status = "succeeded";
|
||||||
|
b.finished_at = new Date().toISOString();
|
||||||
|
if (!ctx.state.images.some((i) => i.image_ref === b.image_ref)) {
|
||||||
|
ctx.state.images.unshift({ image_ref: b.image_ref, enabled: true, source: "built" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
|
sendJSON(ctx.res, 200, sub);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/v1/submissions/{id}/reject
|
||||||
|
if (is("POST", ctx) && ctx.parts[4] === "reject" && ctx.parts.length === 5) {
|
||||||
|
if (ctx.account.role !== "admin") {
|
||||||
|
sendError(ctx.res, 403, "forbidden", "admin account required");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const id = ctx.parts[3];
|
||||||
|
const sub = ctx.state.submissions.find((s) => s.id === id);
|
||||||
|
if (!sub) {
|
||||||
|
sendError(ctx.res, 404, "not_found", "submission not found");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (sub.status !== "pending_review") {
|
||||||
|
sendError(ctx.res, 409, "already_reviewed", "submission already reviewed");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await readJSON<{ reason?: string }>(ctx.req);
|
||||||
|
const reason = body.reason?.trim();
|
||||||
|
if (!reason) {
|
||||||
|
sendError(ctx.res, 400, "bad_request", "reason is required");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
sub.status = "rejected";
|
||||||
|
sub.reviewed_by = ctx.account.email;
|
||||||
|
sub.reviewed_at = new Date().toISOString();
|
||||||
|
sub.reject_reason = reason;
|
||||||
|
|
||||||
|
sendJSON(ctx.res, 200, sub);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function streamBuildLogs(
|
function streamBuildLogs(
|
||||||
req: IncomingMessage,
|
req: IncomingMessage,
|
||||||
res: ServerResponse,
|
res: ServerResponse,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { ServerBackups } from "@/pages/ServerBackups";
|
|||||||
import { Account } from "@/pages/Account";
|
import { Account } from "@/pages/Account";
|
||||||
import { ImageAdmin } from "@/pages/admin/ImageAdmin";
|
import { ImageAdmin } from "@/pages/admin/ImageAdmin";
|
||||||
import { ImageBuildPage } from "@/pages/admin/ImageBuildPage";
|
import { ImageBuildPage } from "@/pages/admin/ImageBuildPage";
|
||||||
|
import { SubmissionsPage } from "@/pages/admin/SubmissionsPage";
|
||||||
|
|
||||||
// Three UX surfaces over two Zero-Trust tiers (DESIGN-WEB-3SIDES):
|
// Three UX surfaces over two Zero-Trust tiers (DESIGN-WEB-3SIDES):
|
||||||
// / User-Side — app-tier, every authenticated principal
|
// / User-Side — app-tier, every authenticated principal
|
||||||
@@ -53,6 +54,7 @@ export default function App() {
|
|||||||
<Route index element={<Navigate to="/admin/images" replace />} />
|
<Route index element={<Navigate to="/admin/images" replace />} />
|
||||||
<Route path="images" element={<ImageAdmin />} />
|
<Route path="images" element={<ImageAdmin />} />
|
||||||
<Route path="builds" element={<ImageBuildPage />} />
|
<Route path="builds" element={<ImageBuildPage />} />
|
||||||
|
<Route path="submissions" element={<SubmissionsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const buttonVariants = cva(
|
|||||||
outline:
|
outline:
|
||||||
"border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
|
"border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline bg-transparent",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "h-9 px-4 py-2",
|
default: "h-9 px-4 py-2",
|
||||||
|
|||||||
@@ -52,5 +52,24 @@
|
|||||||
"filter_all": "All",
|
"filter_all": "All",
|
||||||
"filter_enabled": "Enabled",
|
"filter_enabled": "Enabled",
|
||||||
"filter_disabled": "Disabled",
|
"filter_disabled": "Disabled",
|
||||||
"image_ref_label": "Image Reference"
|
"image_ref_label": "Image Reference",
|
||||||
|
"submissions_title": "Submissions",
|
||||||
|
"submissions_subtitle": "Review queue for user-uploaded modpacks. Approved submissions will automatically trigger safe Kaniko builds with Trivy scans.",
|
||||||
|
"table_submitted_by": "Submitted By",
|
||||||
|
"table_display_name": "Name",
|
||||||
|
"approve_btn": "Approve",
|
||||||
|
"reject_btn": "Reject",
|
||||||
|
"reject_reason_label": "Rejection Reason",
|
||||||
|
"reject_reason_placeholder": "Please provide a reason (max 1000 characters)",
|
||||||
|
"reject_dialog_title": "Reject Submission",
|
||||||
|
"reject_dialog_desc": "Please provide a detailed reason for rejecting this modpack submission. This reason will be shown to the user.",
|
||||||
|
"reject_dialog_submit": "Confirm Reject",
|
||||||
|
"status_pending_review": "Pending Review",
|
||||||
|
"status_approved": "Approved",
|
||||||
|
"status_rejected": "Rejected",
|
||||||
|
"no_submissions_title": "No Submissions Found",
|
||||||
|
"no_submissions_hint": "There are currently no pending or reviewed modpack submissions.",
|
||||||
|
"reviewer": "Reviewer",
|
||||||
|
"reviewed_at": "Reviewed At",
|
||||||
|
"reject_reason": "Rejection Reason"
|
||||||
}
|
}
|
||||||
@@ -4,5 +4,6 @@
|
|||||||
"account": "Account",
|
"account": "Account",
|
||||||
"admin_section": "Admin",
|
"admin_section": "Admin",
|
||||||
"admin_images": "Images",
|
"admin_images": "Images",
|
||||||
"admin_builds": "Build Pipeline"
|
"admin_builds": "Build Pipeline",
|
||||||
|
"admin_submissions": "Submissions"
|
||||||
}
|
}
|
||||||
@@ -52,5 +52,24 @@
|
|||||||
"filter_all": "全部",
|
"filter_all": "全部",
|
||||||
"filter_enabled": "已启用",
|
"filter_enabled": "已启用",
|
||||||
"filter_disabled": "已禁用",
|
"filter_disabled": "已禁用",
|
||||||
"image_ref_label": "镜像引用"
|
"image_ref_label": "镜像引用",
|
||||||
|
"submissions_title": "审核提交",
|
||||||
|
"submissions_subtitle": "用户模组包上传的审核队列。通过审核的任务将自动启动 Kaniko 构建并运行 Trivy 安全扫描。",
|
||||||
|
"table_submitted_by": "提交人",
|
||||||
|
"table_display_name": "名称",
|
||||||
|
"approve_btn": "通过",
|
||||||
|
"reject_btn": "驳回",
|
||||||
|
"reject_reason_label": "驳回原因",
|
||||||
|
"reject_reason_placeholder": "请填写驳回原因(限 1000 字符)",
|
||||||
|
"reject_dialog_title": "驳回申请",
|
||||||
|
"reject_dialog_desc": "请提供驳回该模组包提交的详细原因,提交后该原因将展示给用户。",
|
||||||
|
"reject_dialog_submit": "确定驳回",
|
||||||
|
"status_pending_review": "待审核",
|
||||||
|
"status_approved": "已通过",
|
||||||
|
"status_rejected": "已驳回",
|
||||||
|
"no_submissions_title": "暂无审核任务",
|
||||||
|
"no_submissions_hint": "当前没有待审核或已审核的模组包提交。",
|
||||||
|
"reviewer": "审核人",
|
||||||
|
"reviewed_at": "审核时间",
|
||||||
|
"reject_reason": "驳回理由"
|
||||||
}
|
}
|
||||||
@@ -4,5 +4,6 @@
|
|||||||
"account": "账户",
|
"account": "账户",
|
||||||
"admin_section": "管理",
|
"admin_section": "管理",
|
||||||
"admin_images": "镜像",
|
"admin_images": "镜像",
|
||||||
"admin_builds": "构建流水线"
|
"admin_builds": "构建流水线",
|
||||||
|
"admin_submissions": "审核提交"
|
||||||
}
|
}
|
||||||
@@ -383,4 +383,40 @@ describe("image whitelist and builds wire shapes", () => {
|
|||||||
expect(String(url)).toBe("/images/build/bld-1/cancel");
|
expect(String(url)).toBe("/images/build/bld-1/cancel");
|
||||||
expect((opts as RequestInit).method).toBe("POST");
|
expect((opts as RequestInit).method).toBe("POST");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("submissions", () => {
|
||||||
|
it("listSubmissions GETs from /submissions", async () => {
|
||||||
|
const submissions = [{ id: "sub-1", display_name: "test", status: "pending_review" }];
|
||||||
|
const fetchSpy = fakeFetch({ submissions });
|
||||||
|
vi.stubGlobal("fetch", fetchSpy);
|
||||||
|
const res = await api.listSubmissions();
|
||||||
|
expect(res).toEqual(submissions);
|
||||||
|
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||||
|
expect(String(url)).toBe("/submissions");
|
||||||
|
expect((opts as RequestInit).method).toBe("GET");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("approveSubmission POSTs to /submissions/{id}/approve", async () => {
|
||||||
|
const sub = { id: "sub-1", status: "approved" };
|
||||||
|
const fetchSpy = fakeFetch(sub);
|
||||||
|
vi.stubGlobal("fetch", fetchSpy);
|
||||||
|
const res = await api.approveSubmission("sub-1");
|
||||||
|
expect(res).toEqual(sub);
|
||||||
|
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||||
|
expect(String(url)).toBe("/submissions/sub-1/approve");
|
||||||
|
expect((opts as RequestInit).method).toBe("POST");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejectSubmission POSTs {reason} to /submissions/{id}/reject", async () => {
|
||||||
|
const sub = { id: "sub-1", status: "rejected", reject_reason: "bad" };
|
||||||
|
const fetchSpy = fakeFetch(sub);
|
||||||
|
vi.stubGlobal("fetch", fetchSpy);
|
||||||
|
const res = await api.rejectSubmission("sub-1", "bad");
|
||||||
|
expect(res).toEqual(sub);
|
||||||
|
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||||
|
expect(String(url)).toBe("/submissions/sub-1/reject");
|
||||||
|
expect((opts as RequestInit).method).toBe("POST");
|
||||||
|
expect(JSON.parse((opts as RequestInit).body as string)).toEqual({ reason: "bad" });
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -16,6 +16,7 @@ import type {
|
|||||||
ServerInfo,
|
ServerInfo,
|
||||||
WhitelistImage,
|
WhitelistImage,
|
||||||
WhitelistResult,
|
WhitelistResult,
|
||||||
|
Submission,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { loadConfig } from "./config";
|
import { loadConfig } from "./config";
|
||||||
import i18next from "i18next";
|
import i18next from "i18next";
|
||||||
@@ -248,6 +249,15 @@ export const api = {
|
|||||||
|
|
||||||
passkeyDelete: (id: string) =>
|
passkeyDelete: (id: string) =>
|
||||||
request<void>("DELETE", `/account/passkey/credentials/${id}`),
|
request<void>("DELETE", `/account/passkey/credentials/${id}`),
|
||||||
|
|
||||||
|
listSubmissions: () =>
|
||||||
|
request<{ submissions: Submission[] }>("GET", "/submissions").then((r) => r.submissions ?? []),
|
||||||
|
|
||||||
|
approveSubmission: (id: string) =>
|
||||||
|
request<Submission>("POST", `/submissions/${id}/approve`),
|
||||||
|
|
||||||
|
rejectSubmission: (id: string, reason: string) =>
|
||||||
|
request<Submission>("POST", `/submissions/${id}/reject`, { reason }),
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
UserRound,
|
UserRound,
|
||||||
Boxes,
|
Boxes,
|
||||||
Cpu,
|
Cpu,
|
||||||
|
ClipboardCheck,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
@@ -53,6 +54,7 @@ export const NAV_SECTIONS: NavSection[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ to: "/admin/images", key: "admin_images", icon: Boxes },
|
{ to: "/admin/images", key: "admin_images", icon: Boxes },
|
||||||
{ to: "/admin/builds", key: "admin_builds", icon: Cpu },
|
{ to: "/admin/builds", key: "admin_builds", icon: Cpu },
|
||||||
|
{ to: "/admin/submissions", key: "admin_submissions", icon: ClipboardCheck },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -245,3 +245,20 @@ export interface Build {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
finished_at?: string;
|
finished_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SubmissionStatus = "pending_review" | "approved" | "rejected";
|
||||||
|
|
||||||
|
/** Submission mirrors an image_submissions row (spec §6, migration 0002). */
|
||||||
|
export interface Submission {
|
||||||
|
id: string;
|
||||||
|
submitted_by: string;
|
||||||
|
display_name: string;
|
||||||
|
context_ref: string;
|
||||||
|
status: SubmissionStatus;
|
||||||
|
image_ref?: string;
|
||||||
|
build_id?: string;
|
||||||
|
reviewed_by?: string;
|
||||||
|
reject_reason?: string;
|
||||||
|
created_at: string;
|
||||||
|
reviewed_at?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,521 @@
|
|||||||
|
import { useState, useMemo } from "react";
|
||||||
|
import { ClipboardCheck, CheckCircle2, CircleSlash, Search, ChevronDown, ChevronUp, Check, X, Loader2 } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Loading, ErrorState, EmptyState } from "@/components/States";
|
||||||
|
import { Pagination } from "@/components/Pagination";
|
||||||
|
import { api, humanizeError } from "@/lib/api";
|
||||||
|
import { useAsync } from "@/lib/hooks";
|
||||||
|
import { formatRelative, formatAbsolute } from "@/lib/format";
|
||||||
|
import type { Submission, SubmissionStatus } from "@/lib/types";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
accentClass,
|
||||||
|
}: {
|
||||||
|
icon: any;
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
accentClass?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center gap-3 p-4">
|
||||||
|
<div className={cn("rounded-md p-2 bg-muted/30", accentClass || "text-muted-foreground")}>
|
||||||
|
<Icon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold font-mono leading-none">{value}</div>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground font-medium">{label}</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SubmissionsPage() {
|
||||||
|
const { t, i18n } = useTranslation("admin");
|
||||||
|
const locale = i18n.language;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const { data, error, loading, reload } = useAsync(() => api.listSubmissions(), []);
|
||||||
|
const submissions: Submission[] = data ?? [];
|
||||||
|
|
||||||
|
// Dialog State
|
||||||
|
const [rejectDialogOpen, setRejectDialogOpen] = useState(false);
|
||||||
|
const [rejectingId, setRejectingId] = useState<string | null>(null);
|
||||||
|
const [rejectReason, setRejectReason] = useState("");
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Pending actions (for button spinners)
|
||||||
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
|
const [busyType, setBusyType] = useState<"approve" | "reject" | null>(null);
|
||||||
|
|
||||||
|
// Search & Filtering State
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [statusFilter, setStatusFilter] = useState<"all" | SubmissionStatus>("all");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const total = submissions.length;
|
||||||
|
const pending = submissions.filter((s) => s.status === "pending_review").length;
|
||||||
|
const approved = submissions.filter((s) => s.status === "approved").length;
|
||||||
|
const rejected = submissions.filter((s) => s.status === "rejected").length;
|
||||||
|
return { total, pending, approved, rejected };
|
||||||
|
}, [submissions]);
|
||||||
|
|
||||||
|
// Filtered & Paginated Submissions
|
||||||
|
const filteredSubmissions = useMemo(() => {
|
||||||
|
let list = [...submissions];
|
||||||
|
|
||||||
|
// 1. Search Filter
|
||||||
|
if (search.trim()) {
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
list = list.filter(
|
||||||
|
(s) =>
|
||||||
|
s.display_name.toLowerCase().includes(q) ||
|
||||||
|
s.submitted_by.toLowerCase().includes(q) ||
|
||||||
|
s.id.toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Status Filter
|
||||||
|
if (statusFilter !== "all") {
|
||||||
|
list = list.filter((s) => s.status === statusFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}, [submissions, search, statusFilter]);
|
||||||
|
|
||||||
|
// Reset page when filter changes
|
||||||
|
const lastFilterKey = `${search}-${statusFilter}`;
|
||||||
|
const [prevFilterKey, setPrevFilterKey] = useState(lastFilterKey);
|
||||||
|
if (prevFilterKey !== lastFilterKey) {
|
||||||
|
setPage(1);
|
||||||
|
setPrevFilterKey(lastFilterKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
const paginatedSubmissions = useMemo(() => {
|
||||||
|
const start = (page - 1) * PAGE_SIZE;
|
||||||
|
return filteredSubmissions.slice(start, start + PAGE_SIZE);
|
||||||
|
}, [filteredSubmissions, page]);
|
||||||
|
|
||||||
|
async function handleApprove(id: string) {
|
||||||
|
if (busyId) return;
|
||||||
|
setBusyId(id);
|
||||||
|
setBusyType("approve");
|
||||||
|
setActionError(null);
|
||||||
|
try {
|
||||||
|
await api.approveSubmission(id);
|
||||||
|
reload();
|
||||||
|
} catch (err) {
|
||||||
|
setActionError(humanizeError(err));
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
setBusyType(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRejectDialog(id: string) {
|
||||||
|
setRejectingId(id);
|
||||||
|
setRejectReason("");
|
||||||
|
setActionError(null);
|
||||||
|
setRejectDialogOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRejectSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!rejectingId || !rejectReason.trim()) return;
|
||||||
|
setBusyId(rejectingId);
|
||||||
|
setBusyType("reject");
|
||||||
|
setActionError(null);
|
||||||
|
try {
|
||||||
|
await api.rejectSubmission(rejectingId, rejectReason.trim());
|
||||||
|
setRejectDialogOpen(false);
|
||||||
|
reload();
|
||||||
|
} catch (err) {
|
||||||
|
setActionError(humanizeError(err));
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
setBusyType(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_BADGE_STYLE: Record<SubmissionStatus, string> = {
|
||||||
|
pending_review: "bg-amber-500/10 text-amber-500 border-amber-500/20",
|
||||||
|
approved: "bg-emerald-500/10 text-emerald-500 border-emerald-500/20",
|
||||||
|
rejected: "bg-rose-500/10 text-rose-500 border-rose-500/20",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<ClipboardCheck className="h-6 w-6 text-primary" />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">{t("submissions_title")}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("submissions_subtitle")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Error Alert */}
|
||||||
|
{actionError && (
|
||||||
|
<p className="text-xs text-destructive font-medium bg-destructive/10 p-2.5 rounded-md">
|
||||||
|
{actionError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats Cards Row */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
|
||||||
|
<StatCard icon={ClipboardCheck} label={t("filter_all")} value={stats.total} />
|
||||||
|
<StatCard icon={Loader2} label={t("status_pending_review")} value={stats.pending} accentClass={cn(stats.pending > 0 && "text-amber-500 bg-amber-500/10")} />
|
||||||
|
<StatCard icon={CheckCircle2} label={t("status_approved")} value={stats.approved} accentClass="text-emerald-500 bg-emerald-500/10" />
|
||||||
|
<StatCard icon={CircleSlash} label={t("status_rejected")} value={stats.rejected} accentClass="text-rose-500 bg-rose-500/10" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Table Card */}
|
||||||
|
<Card className="w-full">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{/* Filters Bar */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3 p-4 border-b">
|
||||||
|
<div className="relative flex-1 w-full">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="搜索模组包名称或提交人..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground shrink-0 select-none border border-border/40">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setStatusFilter("all");
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-xs font-semibold transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
|
statusFilter === "all"
|
||||||
|
? "bg-background text-foreground shadow-sm"
|
||||||
|
: "text-muted-foreground hover:bg-background/30 hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t("filter_all")} ({stats.total})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setStatusFilter("pending_review");
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-xs font-semibold transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
|
statusFilter === "pending_review"
|
||||||
|
? "bg-background text-amber-500 shadow-sm"
|
||||||
|
: "text-muted-foreground hover:bg-background/30 hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t("status_pending_review")} ({stats.pending})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setStatusFilter("approved");
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-xs font-semibold transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
|
statusFilter === "approved"
|
||||||
|
? "bg-background text-emerald-500 shadow-sm"
|
||||||
|
: "text-muted-foreground hover:bg-background/30 hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t("status_approved")} ({stats.approved})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setStatusFilter("rejected");
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-xs font-semibold transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
|
statusFilter === "rejected"
|
||||||
|
? "bg-background text-rose-500 shadow-sm"
|
||||||
|
: "text-muted-foreground hover:bg-background/30 hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t("status_rejected")} ({stats.rejected})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List Content */}
|
||||||
|
{loading && !data ? (
|
||||||
|
<div className="py-12"><Loading /></div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="py-12"><ErrorState error={error} onRetry={reload} /></div>
|
||||||
|
) : filteredSubmissions.length === 0 ? (
|
||||||
|
<div className="p-4 border-b-0">
|
||||||
|
<EmptyState
|
||||||
|
title={search.trim() || statusFilter !== "all" ? "无匹配结果" : t("no_submissions_title")}
|
||||||
|
hint={search.trim() || statusFilter !== "all" ? "尝试更换搜索词或筛选条件" : t("no_submissions_hint")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-full">
|
||||||
|
{/* Table Header */}
|
||||||
|
<div className="hidden md:grid grid-cols-12 gap-3 px-4 py-2.5 text-[11px] font-semibold text-muted-foreground border-b select-none">
|
||||||
|
<div className="col-span-4">{t("table_display_name")}</div>
|
||||||
|
<div className="col-span-3">{t("table_submitted_by")}</div>
|
||||||
|
<div className="col-span-2 text-center">{t("table_status")}</div>
|
||||||
|
<div className="col-span-2">{t("table_created_at")}</div>
|
||||||
|
<div className="col-span-1 text-right">{t("table_action")}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table Body */}
|
||||||
|
<div className="divide-y divide-border select-text">
|
||||||
|
{paginatedSubmissions.map((sub) => {
|
||||||
|
const isExpanded = expandedId === sub.id;
|
||||||
|
const isBusyApprove = busyId === sub.id && busyType === "approve";
|
||||||
|
const isBusyReject = busyId === sub.id && busyType === "reject";
|
||||||
|
return (
|
||||||
|
<div key={sub.id} className="flex flex-col">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"grid grid-cols-1 md:grid-cols-12 gap-3 items-center px-4 py-3 text-xs transition-colors hover:bg-accent/25 cursor-pointer",
|
||||||
|
isExpanded && "bg-muted/10",
|
||||||
|
)}
|
||||||
|
onClick={() => setExpandedId(isExpanded ? null : sub.id)}
|
||||||
|
>
|
||||||
|
{/* Name */}
|
||||||
|
<div className="col-span-4 flex items-center gap-2 min-w-0">
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronUp className="h-4 w-4 shrink-0 text-muted-foreground/60" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground/60" />
|
||||||
|
)}
|
||||||
|
<span className="font-semibold truncate">{sub.display_name}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground font-mono bg-muted/40 px-1 py-0.2 rounded shrink-0">
|
||||||
|
{sub.id}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submitter */}
|
||||||
|
<div className="col-span-3 truncate text-muted-foreground" title={sub.submitted_by}>
|
||||||
|
{sub.submitted_by}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<div className="col-span-2 md:text-center py-0.5">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn("text-[10px] px-2 py-0.5 font-semibold capitalize", STATUS_BADGE_STYLE[sub.status])}
|
||||||
|
>
|
||||||
|
{sub.status === "pending_review" && t("status_pending_review")}
|
||||||
|
{sub.status === "approved" && t("status_approved")}
|
||||||
|
{sub.status === "rejected" && t("status_rejected")}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Created At */}
|
||||||
|
<div
|
||||||
|
className="col-span-2 text-muted-foreground"
|
||||||
|
title={formatAbsolute(sub.created_at, locale)}
|
||||||
|
>
|
||||||
|
{formatRelative(sub.created_at, now, locale)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons (only in table row if NOT pending, else show triggers) */}
|
||||||
|
<div className="col-span-1 text-right" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{sub.status === "pending_review" ? (
|
||||||
|
<div className="flex items-center justify-end gap-1.5">
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 w-7 text-emerald-500 hover:text-emerald-600 border-emerald-500/20 hover:bg-emerald-500/10 focus-visible:ring-emerald-500"
|
||||||
|
onClick={() => handleApprove(sub.id)}
|
||||||
|
disabled={!!busyId}
|
||||||
|
title={t("approve_btn")}
|
||||||
|
>
|
||||||
|
{isBusyApprove ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Check className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 w-7 text-rose-500 hover:text-rose-600 border-rose-500/20 hover:bg-rose-500/10 focus-visible:ring-rose-500"
|
||||||
|
onClick={() => openRejectDialog(sub.id)}
|
||||||
|
disabled={!!busyId}
|
||||||
|
title={t("reject_btn")}
|
||||||
|
>
|
||||||
|
{isBusyReject ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-[10px] text-muted-foreground/60 font-medium">
|
||||||
|
—
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expandable details block */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="px-10 py-3 bg-muted/20 border-t border-b border-border/40 text-xs text-muted-foreground space-y-2">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-foreground mb-1">构建上下文引用 (Context Ref)</p>
|
||||||
|
<pre className="font-mono bg-background border rounded p-1.5 truncate select-all">{sub.context_ref}</pre>
|
||||||
|
</div>
|
||||||
|
{sub.image_ref && (
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-foreground mb-1">目标镜像引用 (Image Ref)</p>
|
||||||
|
<pre className="font-mono bg-background border rounded p-1.5 truncate select-all">{sub.image_ref}</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sub.build_id && (
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-foreground">关联构建任务 (Build ID)</p>
|
||||||
|
<code className="font-mono bg-background border rounded px-1.5 py-0.5">{sub.build_id}</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sub.status !== "pending_review" && (
|
||||||
|
<div className="pt-1 border-t border-border/20 mt-2 flex flex-col gap-1.5">
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold text-foreground">{t("reviewer")}:</span>{" "}
|
||||||
|
<span>{sub.reviewed_by}</span>
|
||||||
|
</div>
|
||||||
|
{sub.reviewed_at && (
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold text-foreground">{t("reviewed_at")}:</span>{" "}
|
||||||
|
<span>{formatAbsolute(sub.reviewed_at, locale)} ({formatRelative(sub.reviewed_at, now, locale)})</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sub.status === "rejected" && sub.reject_reason && (
|
||||||
|
<div className="bg-rose-500/5 border border-rose-500/10 rounded-md p-2 mt-1">
|
||||||
|
<span className="font-semibold text-rose-500 block mb-1">{t("reject_reason")}:</span>
|
||||||
|
<p className="text-foreground whitespace-pre-wrap leading-relaxed">{sub.reject_reason}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{filteredSubmissions.length > PAGE_SIZE && (
|
||||||
|
<div className="p-4 border-t">
|
||||||
|
<Pagination
|
||||||
|
page={page}
|
||||||
|
pageSize={PAGE_SIZE}
|
||||||
|
total={filteredSubmissions.length}
|
||||||
|
onChange={setPage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Reject Modal Dialog */}
|
||||||
|
<Dialog open={rejectDialogOpen} onOpenChange={(o) => {
|
||||||
|
setRejectDialogOpen(o);
|
||||||
|
if (!o) {
|
||||||
|
setRejectingId(null);
|
||||||
|
setRejectReason("");
|
||||||
|
setActionError(null);
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("reject_dialog_title")}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t("reject_dialog_desc")}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleRejectSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="rejectReasonInput" className="text-xs font-medium text-muted-foreground">
|
||||||
|
{t("reject_reason_label")} *
|
||||||
|
</Label>
|
||||||
|
<textarea
|
||||||
|
id="rejectReasonInput"
|
||||||
|
placeholder={t("reject_reason_placeholder")}
|
||||||
|
value={rejectReason}
|
||||||
|
onChange={(e) => setRejectReason(e.target.value)}
|
||||||
|
maxLength={1000}
|
||||||
|
disabled={!!busyId}
|
||||||
|
required
|
||||||
|
className="flex min-h-[100px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setRejectDialogOpen(false)}
|
||||||
|
disabled={!!busyId}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{t("common:cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="destructive"
|
||||||
|
disabled={!!busyId || !rejectReason.trim()}
|
||||||
|
className="text-xs gap-1.5"
|
||||||
|
>
|
||||||
|
{busyId && busyType === "reject" ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{t("reject_dialog_submit")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user