feat(panel): implement user-side modpack submissions with drag & drop context upload
This commit is contained in:
11 files changed
+824
-4
No files matched your search
+59
-4
@@ -853,10 +853,65 @@ async function handleImageRoute(ctx: SessionContext): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmissionRoute(ctx: SessionContext): Promise<boolean> {
|
async function handleSubmissionRoute(ctx: SessionContext): Promise<boolean> {
|
||||||
if (ctx.parts[2] !== "submissions") return false;
|
const isAdminSubmissions = ctx.parts[2] === "submissions";
|
||||||
|
const isMeSubmissions = ctx.parts[2] === "me" && ctx.parts[3] === "submissions";
|
||||||
|
if (!isAdminSubmissions && !isMeSubmissions) return false;
|
||||||
|
|
||||||
|
// GET /api/v1/me/submissions
|
||||||
|
if (isMeSubmissions && is("GET", ctx) && ctx.parts.length === 4) {
|
||||||
|
const userSubs = ctx.state.submissions.filter((s) => s.submitted_by === ctx.account.email);
|
||||||
|
sendJSON(ctx.res, 200, { submissions: userSubs });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/v1/me/submissions
|
||||||
|
if (isMeSubmissions && is("POST", ctx) && ctx.parts.length === 4) {
|
||||||
|
const body = await readJSON<{ display_name?: string }>(ctx.req);
|
||||||
|
const displayName = body.display_name?.trim();
|
||||||
|
if (!displayName) {
|
||||||
|
sendError(ctx.res, 400, "bad_request", "display_name is required");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const id = `sub-${Date.now()}`;
|
||||||
|
const newSub: Submission = {
|
||||||
|
id,
|
||||||
|
submitted_by: ctx.account.email,
|
||||||
|
display_name: displayName,
|
||||||
|
context_ref: `minio/contexts/${id}/context.tar.gz`,
|
||||||
|
status: "pending_review",
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
ctx.state.submissions.unshift(newSub);
|
||||||
|
sendJSON(ctx.res, 201, newSub);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/v1/me/submissions/{id}/context
|
||||||
|
if (isMeSubmissions && is("POST", ctx) && ctx.parts[5] === "context" && ctx.parts.length === 6) {
|
||||||
|
const id = ctx.parts[4];
|
||||||
|
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.submitted_by !== ctx.account.email) {
|
||||||
|
sendError(ctx.res, 404, "not_found", "submission not found");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (sub.status !== "pending_review") {
|
||||||
|
sendError(ctx.res, 409, "already_reviewed", "submission has already been reviewed");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the body stream to end so the socket is clean
|
||||||
|
for await (const _ of ctx.req) { /* discard */ }
|
||||||
|
|
||||||
|
sendJSON(ctx.res, 200, sub);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// GET /api/v1/submissions
|
// GET /api/v1/submissions
|
||||||
if (is("GET", ctx) && ctx.parts.length === 3) {
|
if (isAdminSubmissions && is("GET", ctx) && ctx.parts.length === 3) {
|
||||||
if (ctx.account.role !== "admin") {
|
if (ctx.account.role !== "admin") {
|
||||||
sendError(ctx.res, 403, "forbidden", "admin account required");
|
sendError(ctx.res, 403, "forbidden", "admin account required");
|
||||||
return true;
|
return true;
|
||||||
@@ -866,7 +921,7 @@ async function handleSubmissionRoute(ctx: SessionContext): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/v1/submissions/{id}/approve
|
// POST /api/v1/submissions/{id}/approve
|
||||||
if (is("POST", ctx) && ctx.parts[4] === "approve" && ctx.parts.length === 5) {
|
if (isAdminSubmissions && is("POST", ctx) && ctx.parts[4] === "approve" && ctx.parts.length === 5) {
|
||||||
if (ctx.account.role !== "admin") {
|
if (ctx.account.role !== "admin") {
|
||||||
sendError(ctx.res, 403, "forbidden", "admin account required");
|
sendError(ctx.res, 403, "forbidden", "admin account required");
|
||||||
return true;
|
return true;
|
||||||
@@ -917,7 +972,7 @@ async function handleSubmissionRoute(ctx: SessionContext): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/v1/submissions/{id}/reject
|
// POST /api/v1/submissions/{id}/reject
|
||||||
if (is("POST", ctx) && ctx.parts[4] === "reject" && ctx.parts.length === 5) {
|
if (isAdminSubmissions && is("POST", ctx) && ctx.parts[4] === "reject" && ctx.parts.length === 5) {
|
||||||
if (ctx.account.role !== "admin") {
|
if (ctx.account.role !== "admin") {
|
||||||
sendError(ctx.res, 403, "forbidden", "admin account required");
|
sendError(ctx.res, 403, "forbidden", "admin account required");
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ 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";
|
import { SubmissionsPage } from "@/pages/admin/SubmissionsPage";
|
||||||
|
import { MySubmissionsPage } from "@/pages/MySubmissionsPage";
|
||||||
|
|
||||||
// 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
|
||||||
@@ -45,6 +46,7 @@ export default function App() {
|
|||||||
<Route path="servers/:name" element={<ServerConsole />} />
|
<Route path="servers/:name" element={<ServerConsole />} />
|
||||||
<Route path="servers/:name/players" element={<ServerPlayers />} />
|
<Route path="servers/:name/players" element={<ServerPlayers />} />
|
||||||
<Route path="servers/:name/backups" element={<ServerBackups />} />
|
<Route path="servers/:name/backups" element={<ServerBackups />} />
|
||||||
|
<Route path="submissions" element={<MySubmissionsPage />} />
|
||||||
<Route path="account" element={<Account />} />
|
<Route path="account" element={<Account />} />
|
||||||
|
|
||||||
{/* Admin-Side — admin-tier (server & content ops).
|
{/* Admin-Side — admin-tier (server & content ops).
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import enOps from "./resources/en-US/ops.json";
|
|||||||
import enErrors from "./resources/en-US/errors.json";
|
import enErrors from "./resources/en-US/errors.json";
|
||||||
import enNavigation from "./resources/en-US/navigation.json";
|
import enNavigation from "./resources/en-US/navigation.json";
|
||||||
import enBackups from "./resources/en-US/backups.json";
|
import enBackups from "./resources/en-US/backups.json";
|
||||||
|
import enSubmissions from "./resources/en-US/submissions.json";
|
||||||
import zhCommon from "./resources/zh-CN/common.json";
|
import zhCommon from "./resources/zh-CN/common.json";
|
||||||
import zhAuth from "./resources/zh-CN/auth.json";
|
import zhAuth from "./resources/zh-CN/auth.json";
|
||||||
import zhDashboard from "./resources/zh-CN/dashboard.json";
|
import zhDashboard from "./resources/zh-CN/dashboard.json";
|
||||||
@@ -21,6 +22,7 @@ import zhOps from "./resources/zh-CN/ops.json";
|
|||||||
import zhErrors from "./resources/zh-CN/errors.json";
|
import zhErrors from "./resources/zh-CN/errors.json";
|
||||||
import zhNavigation from "./resources/zh-CN/navigation.json";
|
import zhNavigation from "./resources/zh-CN/navigation.json";
|
||||||
import zhBackups from "./resources/zh-CN/backups.json";
|
import zhBackups from "./resources/zh-CN/backups.json";
|
||||||
|
import zhSubmissions from "./resources/zh-CN/submissions.json";
|
||||||
|
|
||||||
i18next
|
i18next
|
||||||
.use(LanguageDetector)
|
.use(LanguageDetector)
|
||||||
@@ -38,6 +40,7 @@ i18next
|
|||||||
errors: enErrors,
|
errors: enErrors,
|
||||||
navigation: enNavigation,
|
navigation: enNavigation,
|
||||||
backups: enBackups,
|
backups: enBackups,
|
||||||
|
submissions: enSubmissions,
|
||||||
},
|
},
|
||||||
"zh-CN": {
|
"zh-CN": {
|
||||||
common: zhCommon,
|
common: zhCommon,
|
||||||
@@ -50,6 +53,7 @@ i18next
|
|||||||
errors: zhErrors,
|
errors: zhErrors,
|
||||||
navigation: zhNavigation,
|
navigation: zhNavigation,
|
||||||
backups: zhBackups,
|
backups: zhBackups,
|
||||||
|
submissions: zhSubmissions,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
fallbackLng: "en-US",
|
fallbackLng: "en-US",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"my_servers": "Servers",
|
"my_servers": "Servers",
|
||||||
|
"my_submissions": "My Submissions",
|
||||||
"account": "Account",
|
"account": "Account",
|
||||||
"admin_section": "Admin",
|
"admin_section": "Admin",
|
||||||
"admin_images": "Images",
|
"admin_images": "Images",
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"title": "My Submissions",
|
||||||
|
"subtitle": "Submit your custom modpacks for review and track their build status. Approved submissions will automatically trigger safe builds.",
|
||||||
|
"submit_card_title": "Submit New Modpack",
|
||||||
|
"submit_card_desc": "Provide a name and upload your build context gzip tarball (.tar.gz).",
|
||||||
|
"display_name_label": "Display Name",
|
||||||
|
"display_name_placeholder": "e.g., Pixelmon Adventure Pack",
|
||||||
|
"file_label": "Build Context (.tar.gz)",
|
||||||
|
"file_drag_hint": "Drag and drop a .tar.gz file here, or click to browse",
|
||||||
|
"file_selected": "Selected file: {{name}} ({{size}})",
|
||||||
|
"submit_btn": "Submit",
|
||||||
|
"submitting_create": "Creating submission...",
|
||||||
|
"submitting_upload": "Uploading build context...",
|
||||||
|
"submit_success": "Modpack submitted successfully!",
|
||||||
|
"list_card_title": "Submission History",
|
||||||
|
"filter_all": "All Statuses",
|
||||||
|
"status_pending_review": "Pending Review",
|
||||||
|
"status_approved": "Approved",
|
||||||
|
"status_rejected": "Rejected",
|
||||||
|
"table_display_name": "Modpack Name",
|
||||||
|
"table_status": "Status",
|
||||||
|
"table_created_at": "Submitted At",
|
||||||
|
"table_reviewed_by": "Reviewed By",
|
||||||
|
"table_image_ref": "Image Reference",
|
||||||
|
"table_reject_reason": "Reject Reason",
|
||||||
|
"no_submissions_title": "No Submissions Found",
|
||||||
|
"no_submissions_hint": "You haven't submitted any modpacks yet.",
|
||||||
|
"search_placeholder": "Search submissions...",
|
||||||
|
"error_file_type": "Please upload a valid .tar.gz file.",
|
||||||
|
"error_file_size": "File exceeds the allowed size limit.",
|
||||||
|
"error_name_required": "Display name is required.",
|
||||||
|
"error_file_required": "Build context file is required."
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"dashboard": "仪表盘",
|
"dashboard": "仪表盘",
|
||||||
"my_servers": "服务器",
|
"my_servers": "服务器",
|
||||||
|
"my_submissions": "我的提交",
|
||||||
"account": "账户",
|
"account": "账户",
|
||||||
"admin_section": "管理",
|
"admin_section": "管理",
|
||||||
"admin_images": "镜像",
|
"admin_images": "镜像",
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"title": "内容提交",
|
||||||
|
"subtitle": "提交您的自定义模组包进行审核并跟踪其构建状态。通过审核的提交将自动触发安全构建。",
|
||||||
|
"submit_card_title": "提交新模组包",
|
||||||
|
"submit_card_desc": "提供显示名称并上传您的构建上下文 gzip 压缩包 (.tar.gz)。",
|
||||||
|
"display_name_label": "显示名称",
|
||||||
|
"display_name_placeholder": "例如:Pixelmon 冒险包",
|
||||||
|
"file_label": "构建上下文 (.tar.gz)",
|
||||||
|
"file_drag_hint": "拖拽 .tar.gz 文件到此处,或点击浏览文件",
|
||||||
|
"file_selected": "已选择文件: {{name}} ({{size}})",
|
||||||
|
"submit_btn": "提交",
|
||||||
|
"submitting_create": "正在创建提交...",
|
||||||
|
"submitting_upload": "正在上传构建上下文...",
|
||||||
|
"submit_success": "模组包提交成功!",
|
||||||
|
"list_card_title": "提交历史",
|
||||||
|
"filter_all": "全部状态",
|
||||||
|
"status_pending_review": "等待审核",
|
||||||
|
"status_approved": "审核通过",
|
||||||
|
"status_rejected": "已拒绝",
|
||||||
|
"table_display_name": "模组包名称",
|
||||||
|
"table_status": "状态",
|
||||||
|
"table_created_at": "提交时间",
|
||||||
|
"table_reviewed_by": "审核人",
|
||||||
|
"table_image_ref": "镜像引用",
|
||||||
|
"table_reject_reason": "拒绝原因",
|
||||||
|
"no_submissions_title": "暂无提交记录",
|
||||||
|
"no_submissions_hint": "您还没有提交过任何模组包。",
|
||||||
|
"search_placeholder": "搜索提交记录...",
|
||||||
|
"error_file_type": "请上传有效的 .tar.gz 压缩文件。",
|
||||||
|
"error_file_size": "文件超过了允许的大小限制。",
|
||||||
|
"error_name_required": "必须填写显示名称。",
|
||||||
|
"error_file_required": "必须上传构建上下文文件。"
|
||||||
|
}
|
||||||
@@ -418,5 +418,42 @@ describe("image whitelist and builds wire shapes", () => {
|
|||||||
expect((opts as RequestInit).method).toBe("POST");
|
expect((opts as RequestInit).method).toBe("POST");
|
||||||
expect(JSON.parse((opts as RequestInit).body as string)).toEqual({ reason: "bad" });
|
expect(JSON.parse((opts as RequestInit).body as string)).toEqual({ reason: "bad" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("listMySubmissions GETs from /me/submissions", async () => {
|
||||||
|
const submissions = [{ id: "sub-2", display_name: "my test", status: "pending_review" }];
|
||||||
|
const fetchSpy = fakeFetch({ submissions });
|
||||||
|
vi.stubGlobal("fetch", fetchSpy);
|
||||||
|
const res = await api.listMySubmissions();
|
||||||
|
expect(res).toEqual(submissions);
|
||||||
|
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||||
|
expect(String(url)).toBe("/me/submissions");
|
||||||
|
expect((opts as RequestInit).method).toBe("GET");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("createSubmission POSTs {display_name} to /me/submissions", async () => {
|
||||||
|
const sub = { id: "sub-3", display_name: "new submission", status: "pending_review" };
|
||||||
|
const fetchSpy = fakeFetch(sub);
|
||||||
|
vi.stubGlobal("fetch", fetchSpy);
|
||||||
|
const res = await api.createSubmission("new submission");
|
||||||
|
expect(res).toEqual(sub);
|
||||||
|
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||||
|
expect(String(url)).toBe("/me/submissions");
|
||||||
|
expect((opts as RequestInit).method).toBe("POST");
|
||||||
|
expect(JSON.parse((opts as RequestInit).body as string)).toEqual({ display_name: "new submission" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uploadSubmissionContext POSTs Blob to /me/submissions/{id}/context", async () => {
|
||||||
|
const sub = { id: "sub-3", display_name: "new submission", status: "pending_review" };
|
||||||
|
const fetchSpy = fakeFetch(sub);
|
||||||
|
vi.stubGlobal("fetch", fetchSpy);
|
||||||
|
const blob = new Blob(["test"], { type: "application/x-gzip" });
|
||||||
|
const res = await api.uploadSubmissionContext("sub-3", blob);
|
||||||
|
expect(res).toEqual(sub);
|
||||||
|
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||||
|
expect(String(url)).toBe("/me/submissions/sub-3/context");
|
||||||
|
expect((opts as RequestInit).method).toBe("POST");
|
||||||
|
expect((opts as RequestInit).body).toBe(blob);
|
||||||
|
expect((opts as RequestInit).headers).toEqual({ "Content-Type": "application/x-gzip" });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -57,6 +57,34 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
|
|||||||
return parsed as T;
|
return parsed as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function requestRaw<T>(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body: Blob,
|
||||||
|
headers?: Record<string, string>,
|
||||||
|
): Promise<T> {
|
||||||
|
const { apiBase } = await loadConfig();
|
||||||
|
const res = await fetch(`${apiBase}${path}`, {
|
||||||
|
method,
|
||||||
|
credentials: "include",
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await res.text();
|
||||||
|
const parsed: unknown = text ? JSON.parse(text) : null;
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err: ApiError = {
|
||||||
|
status: res.status,
|
||||||
|
code: isApiError(parsed) ? parsed.error.code : "error",
|
||||||
|
message: isApiError(parsed) ? parsed.error.message : res.statusText,
|
||||||
|
};
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return parsed as T;
|
||||||
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
// Local-password auth (spec §B1). login sets an HttpOnly session cookie as a
|
// Local-password auth (spec §B1). login sets an HttpOnly session cookie as a
|
||||||
// side effect — the panel never sees it — and returns only what to route on next
|
// side effect — the panel never sees it — and returns only what to route on next
|
||||||
@@ -258,6 +286,17 @@ export const api = {
|
|||||||
|
|
||||||
rejectSubmission: (id: string, reason: string) =>
|
rejectSubmission: (id: string, reason: string) =>
|
||||||
request<Submission>("POST", `/submissions/${id}/reject`, { reason }),
|
request<Submission>("POST", `/submissions/${id}/reject`, { reason }),
|
||||||
|
|
||||||
|
listMySubmissions: () =>
|
||||||
|
request<{ submissions: Submission[] }>("GET", "/me/submissions").then((r) => r.submissions ?? []),
|
||||||
|
|
||||||
|
createSubmission: (displayName: string) =>
|
||||||
|
request<Submission>("POST", "/me/submissions", { display_name: displayName }),
|
||||||
|
|
||||||
|
uploadSubmissionContext: (id: string, file: Blob) =>
|
||||||
|
requestRaw<Submission>("POST", `/me/submissions/${id}/context`, file, {
|
||||||
|
"Content-Type": "application/x-gzip",
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Boxes,
|
Boxes,
|
||||||
Cpu,
|
Cpu,
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
|
Upload,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ export const NAV_SECTIONS: NavSection[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ to: "/", key: "dashboard", icon: LayoutDashboard, end: true },
|
{ to: "/", key: "dashboard", icon: LayoutDashboard, end: true },
|
||||||
{ to: "/servers", key: "my_servers", icon: Server },
|
{ to: "/servers", key: "my_servers", icon: Server },
|
||||||
|
{ to: "/submissions", key: "my_submissions", icon: Upload },
|
||||||
{ to: "/account", key: "account", icon: UserRound },
|
{ to: "/account", key: "account", icon: UserRound },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,613 @@
|
|||||||
|
import { useState, useMemo, useRef } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Upload,
|
||||||
|
Search,
|
||||||
|
Loader2,
|
||||||
|
AlertCircle,
|
||||||
|
FileText,
|
||||||
|
X,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
Plus,
|
||||||
|
CheckCircle,
|
||||||
|
CircleSlash,
|
||||||
|
ClipboardCheck,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
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 formatBytes(bytes: number, decimals = 2) {
|
||||||
|
if (bytes === 0) return "0 Bytes";
|
||||||
|
const k = 1024;
|
||||||
|
const dm = decimals < 0 ? 0 : decimals;
|
||||||
|
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
accentClass,
|
||||||
|
}: {
|
||||||
|
icon: LucideIcon;
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
accentClass?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center gap-3 p-4">
|
||||||
|
<div className={`rounded-md p-2 bg-muted/30 ${accentClass || "text-muted-foreground"}`}>
|
||||||
|
<Icon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold font-mono leading-none">{value}</div>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground font-medium">{label}</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MySubmissionsPage() {
|
||||||
|
const { t, i18n } = useTranslation("submissions");
|
||||||
|
const locale = i18n.language;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Async API hook
|
||||||
|
const { data, error: fetchError, loading, reload } = useAsync(() => api.listMySubmissions(), []);
|
||||||
|
const submissions: Submission[] = data ?? [];
|
||||||
|
|
||||||
|
// Dialog State
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
// Form State
|
||||||
|
const [displayName, setDisplayName] = useState("");
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [dragActive, setDragActive] = useState(false);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [submitStep, setSubmitStep] = useState<"create" | "upload" | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(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);
|
||||||
|
|
||||||
|
// Filtered & Paginated Submissions
|
||||||
|
const filteredSubmissions = useMemo(() => {
|
||||||
|
let list = [...submissions];
|
||||||
|
|
||||||
|
if (search.trim()) {
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
list = list.filter(
|
||||||
|
(s) =>
|
||||||
|
s.display_name.toLowerCase().includes(q) ||
|
||||||
|
s.id.toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
// 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]);
|
||||||
|
|
||||||
|
// Drag and drop event handlers
|
||||||
|
const handleDrag = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.type === "dragenter" || e.type === "dragover") {
|
||||||
|
setDragActive(true);
|
||||||
|
} else if (e.type === "dragleave") {
|
||||||
|
setDragActive(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setDragActive(false);
|
||||||
|
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
||||||
|
validateAndSetFile(e.dataTransfer.files[0]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const selectedFile = e.target.files?.[0] || null;
|
||||||
|
validateAndSetFile(selectedFile);
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateAndSetFile = (selectedFile: File | null) => {
|
||||||
|
setError(null);
|
||||||
|
if (!selectedFile) {
|
||||||
|
setFile(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!selectedFile.name.endsWith(".tar.gz")) {
|
||||||
|
setError(t("error_file_type"));
|
||||||
|
setFile(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const maxBytes = 1024 * 1024 * 1024; // 1 GiB limit
|
||||||
|
if (selectedFile.size > maxBytes) {
|
||||||
|
setError(t("error_file_size"));
|
||||||
|
setFile(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFile(selectedFile);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFormSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const trimmedName = displayName.trim();
|
||||||
|
if (!trimmedName) {
|
||||||
|
setError(t("error_name_required"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!file) {
|
||||||
|
setError(t("error_file_required"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setSubmitStep("create");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Create the submission metadata
|
||||||
|
const sub = await api.createSubmission(trimmedName);
|
||||||
|
|
||||||
|
// 2. Upload context file
|
||||||
|
setSubmitStep("upload");
|
||||||
|
await api.uploadSubmissionContext(sub.id, file);
|
||||||
|
|
||||||
|
setDisplayName("");
|
||||||
|
setFile(null);
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
setDialogOpen(false);
|
||||||
|
reload();
|
||||||
|
} catch (err) {
|
||||||
|
setError(humanizeError(err));
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
setSubmitStep(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">
|
||||||
|
<Upload className="h-6 w-6 text-primary" />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">{t("title")}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">{t("subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit Button to Open Dialog */}
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setError(null);
|
||||||
|
setDisplayName("");
|
||||||
|
setFile(null);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}}
|
||||||
|
className="text-xs gap-1.5"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
{t("submit_card_title")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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={CheckCircle} 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>
|
||||||
|
|
||||||
|
{/* Submission History 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={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">
|
||||||
|
<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",
|
||||||
|
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",
|
||||||
|
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",
|
||||||
|
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",
|
||||||
|
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>
|
||||||
|
) : fetchError ? (
|
||||||
|
<div className="py-12"><ErrorState error={fetchError} onRetry={reload} /></div>
|
||||||
|
) : filteredSubmissions.length === 0 ? (
|
||||||
|
<div className="p-4 border-b-0">
|
||||||
|
<EmptyState
|
||||||
|
title={t("no_submissions_title")}
|
||||||
|
hint={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-6">{t("table_display_name")}</div>
|
||||||
|
<div className="col-span-3 text-center">{t("table_status")}</div>
|
||||||
|
<div className="col-span-3">{t("table_created_at")}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table Rows */}
|
||||||
|
<div className="divide-y divide-border/60">
|
||||||
|
{paginatedSubmissions.map((sub) => {
|
||||||
|
const isExpanded = expandedId === sub.id;
|
||||||
|
return (
|
||||||
|
<div key={sub.id} className="flex flex-col">
|
||||||
|
{/* Row Header */}
|
||||||
|
<div
|
||||||
|
onClick={() => setExpandedId(isExpanded ? null : sub.id)}
|
||||||
|
className={cn(
|
||||||
|
"grid grid-cols-1 md:grid-cols-12 gap-3 px-4 py-3.5 items-center cursor-pointer transition-colors text-sm hover:bg-muted/30",
|
||||||
|
isExpanded && "bg-muted/20",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Display Name */}
|
||||||
|
<div className="col-span-1 md:col-span-6 flex items-center gap-2 min-w-0">
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronUp className="h-4 w-4 shrink-0 text-muted-foreground/80" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground/80" />
|
||||||
|
)}
|
||||||
|
<div className="truncate flex flex-col">
|
||||||
|
<span className="font-medium text-foreground truncate" title={sub.display_name}>
|
||||||
|
{sub.display_name}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-[10px] text-muted-foreground/75 truncate mt-0.5">
|
||||||
|
ID: {sub.id}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<div className="col-span-1 md:col-span-3 md:text-center">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn("text-[10px] px-2 py-0.5 font-semibold capitalize", STATUS_BADGE_STYLE[sub.status])}
|
||||||
|
>
|
||||||
|
{sub.status === "pending_review" && t("status_pending_review")}
|
||||||
|
{sub.status === "approved" && t("status_approved")}
|
||||||
|
{sub.status === "rejected" && t("status_rejected")}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Created At */}
|
||||||
|
<div
|
||||||
|
className="col-span-1 md:col-span-3 text-muted-foreground text-xs"
|
||||||
|
title={formatAbsolute(sub.created_at, locale)}
|
||||||
|
>
|
||||||
|
{formatRelative(sub.created_at, now, locale)}
|
||||||
|
</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.status !== "pending_review" && (
|
||||||
|
<div className="pt-1 border-t border-border/20 mt-2 flex flex-col gap-1.5">
|
||||||
|
{sub.reviewed_by && (
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold text-foreground mr-1.5">{t("table_reviewed_by")}:</span>
|
||||||
|
<code className="font-mono text-muted-foreground">{sub.reviewed_by}</code>
|
||||||
|
{sub.reviewed_at && (
|
||||||
|
<span className="text-[10px] text-muted-foreground/60 ml-2">
|
||||||
|
({formatAbsolute(sub.reviewed_at, locale)})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sub.status === "rejected" && sub.reject_reason && (
|
||||||
|
<div className="text-rose-500 font-medium">
|
||||||
|
<span className="font-semibold text-foreground mr-1.5">{t("table_reject_reason")}:</span>
|
||||||
|
{sub.reject_reason}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination Footer */}
|
||||||
|
{filteredSubmissions.length > PAGE_SIZE && (
|
||||||
|
<div className="p-4 border-t">
|
||||||
|
<Pagination
|
||||||
|
page={page}
|
||||||
|
total={filteredSubmissions.length}
|
||||||
|
pageSize={PAGE_SIZE}
|
||||||
|
onChange={setPage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Submit Modpack Dialog */}
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={(open) => !isSubmitting && setDialogOpen(open)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("submit_card_title")}</DialogTitle>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("submit_card_desc")}
|
||||||
|
</p>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleFormSubmit} className="space-y-4 pt-2">
|
||||||
|
{/* Error Banner */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-start gap-2 text-xs text-destructive font-medium bg-destructive/10 p-2.5 rounded-md">
|
||||||
|
<AlertCircle className="h-4 w-4 shrink-0 mt-0.5" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Display Name Input */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="displayName" className="text-xs font-semibold text-foreground">
|
||||||
|
{t("display_name_label")} <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="displayName"
|
||||||
|
placeholder={t("display_name_placeholder")}
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File Dropzone */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs font-semibold text-foreground">
|
||||||
|
{t("file_label")} <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
accept=".tar.gz"
|
||||||
|
className="hidden"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
onDragEnter={handleDrag}
|
||||||
|
onDragOver={handleDrag}
|
||||||
|
onDragLeave={handleDrag}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onClick={() => !isSubmitting && fileInputRef.current?.click()}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-center justify-center border border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors min-h-[140px]",
|
||||||
|
dragActive ? "border-primary bg-primary/5" : "border-border hover:bg-muted/30",
|
||||||
|
isSubmitting && "opacity-50 cursor-not-allowed",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{file ? (
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<FileText className="h-8 w-8 text-primary" />
|
||||||
|
<p className="text-xs font-medium text-foreground max-w-[240px] truncate" title={file.name}>
|
||||||
|
{file.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground">{formatBytes(file.size)}</p>
|
||||||
|
{!isSubmitting && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 px-2 text-[10px] text-rose-500 hover:text-rose-600 hover:bg-rose-500/10 mt-1"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setFile(null);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="mr-1 h-3 w-3" />
|
||||||
|
清除
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Upload className="h-8 w-8 text-muted-foreground/80 mb-2" />
|
||||||
|
<p className="text-xs font-medium text-foreground">{t("file_drag_hint")}</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground/70 mt-1">支持 .tar.gz 格式 (最大 1GB)</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setDialogOpen(false)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{t("common:cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting || !displayName.trim() || !file}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
||||||
|
{submitStep === "create" ? t("submitting_create") : t("submitting_upload")}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Upload className="mr-1.5 h-4 w-4" />
|
||||||
|
{t("submit_btn")}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user