ModReleaser/src/components/PublishModal.tsx
2026-07-16 15:22:06 +08:00

487 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useState, useCallback } from "react";
import {
Modal,
Steps,
Button,
Space,
Checkbox,
Table,
Select,
Row,
Col,
Flex,
Progress,
Typography,
Empty,
theme,
} from "antd";
import { ReloadOutlined } from "@ant-design/icons";
import type { BaseVersion, Config, UploadReleaseType } from "@/types";
import type { ParsedProject } from "@/services/project";
import { platforms } from "@/services/publish.schemas";
const { Text } = Typography;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface PublishVersion {
key: string;
mc_version: string;
artifact: string | null;
sources: string | null;
// version label that matches the platform's versioning
label: string;
// already exists on this platform?
existsModrinth: boolean;
existsCurseforge: boolean;
}
interface PublishModalProps {
open: boolean;
config: Config;
project: ParsedProject;
onClose: () => void;
}
type ReleaseType = Extract<BaseVersion["version_type"], UploadReleaseType>;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const RELEASE_TYPE_LABEL_MAP: Record<ReleaseType, string> = {
release: "Release",
beta: "Beta",
alpha: "Alpha"
};
const RELEASE_TYPES = Object.entries(RELEASE_TYPE_LABEL_MAP).map(v => ({ value: v[0], label: v[1] }));
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function PublishModal({
open,
config,
project,
onClose,
}: PublishModalProps) {
const { token } = theme.useToken();
const [currentStep, setCurrentStep] = useState(0);
// Version selection
const [selectedVersions, setSelectedVersions] = useState<Set<string>>(
new Set(),
);
// Publish settings
const [changelog, setChangelog] = useState("");
const [versionType, setVersionType] = useState<ReleaseType>(
"release",
);
// Progress & result
const [publishing, setPublishing] = useState(false);
const [publishDone, setPublishDone] = useState(false);
const [progress, setProgress] = useState<
Record<typeof platforms[number], { done: number; total: number }>
>({
modrinth: { done: 0, total: 0 },
curseforge: { done: 0, total: 0 },
});
const [publishResult, setPublishResult] = useState<{
success: number;
failed: number;
} | null>(null);
// Platform existing versions — loaded when modal opens
const [loadingExisting, setLoadingExisting] = useState(false);
const [existingModrinth, setExistingModrinth] = useState<string[]>([]);
const [existingCurseforge, setExistingCurseforge] = useState<string[]>([]);
const loadExisting = useCallback(async () => {
setLoadingExisting(true);
try {
// TODO: Fetch existing versions from both platforms via tRPC/Server Action
// For now, placeholder
setExistingModrinth([]);
setExistingCurseforge([]);
} catch {
// ignore
} finally {
setLoadingExisting(false);
}
}, []);
// Build publish version rows from project artifacts
const publishVersions: PublishVersion[] = project.artifacts.map((a) => ({
key: a.mc_version,
mc_version: a.mc_version,
artifact: a.artifact,
sources: a.sources,
label: "", // TODO: compute from version template
existsModrinth: existingModrinth.includes(a.mc_version),
existsCurseforge: existingCurseforge.includes(a.mc_version),
}));
// --------------- Steps handling ---------------
const handleNext = useCallback(() => {
if (currentStep === 0) {
// Init selection with all non-existing entries
const preselected = new Set<string>();
for (const v of publishVersions) {
if (!v.existsModrinth || !v.existsCurseforge) {
preselected.add(v.key);
}
}
setSelectedVersions(preselected);
}
setCurrentStep((prev) => Math.min(prev + 1, 2));
}, [currentStep, publishVersions]);
const handlePrev = useCallback(() => {
setCurrentStep((prev) => Math.max(prev - 1, 0));
}, []);
const handleConfirmPublish = useCallback(async () => {
setPublishing(true);
setPublishDone(false);
setPublishResult(null);
setCurrentStep(2);
// TODO: Call tRPC mutation to publish, update progress & result
setPublishResult({ success: 0, failed: 0 });
setPublishDone(true);
setPublishing(false);
}, []);
const handleClose = useCallback(() => {
if (publishing) return;
setCurrentStep(0);
setSelectedVersions(new Set());
setChangelog("");
setVersionType("release");
setPublishResult(null);
setPublishDone(false);
onClose();
}, [publishing, onClose]);
// --------------- Render: Step 1 — Version Selection ---------------
const renderStep1 = () => {
const modrinthColumns = [
{
title: "MC 版本",
dataIndex: "mc_version",
key: "mc_version",
width: 100,
},
{
title: "构建产物",
dataIndex: "artifact",
key: "artifact",
ellipsis: true,
},
{
title: "",
key: "select",
width: 50,
render: (_: unknown, record: PublishVersion) => (
<Checkbox
disabled={record.existsModrinth}
checked={
record.existsModrinth || selectedVersions.has(record.key)
}
onChange={(e) => {
const next = new Set(selectedVersions);
if (e.target.checked) next.add(record.key);
else next.delete(record.key);
setSelectedVersions(next);
}}
/>
),
},
];
const curseforgeColumns = [
{
title: "MC 版本",
dataIndex: "mc_version",
key: "mc_version",
width: 100,
},
{
title: "构建产物",
dataIndex: "artifact",
key: "artifact",
ellipsis: true,
},
{
title: "",
key: "select",
width: 50,
render: (_: unknown, record: PublishVersion) => (
<Checkbox
disabled={record.existsCurseforge}
checked={
record.existsCurseforge || selectedVersions.has(record.key)
}
onChange={(e) => {
const next = new Set(selectedVersions);
if (e.target.checked) next.add(record.key);
else next.delete(record.key);
setSelectedVersions(next);
}}
/>
),
},
];
return (
<div>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: token.margin,
}}
>
<Text strong></Text>
<Button
icon={<ReloadOutlined />}
loading={loadingExisting}
onClick={loadExisting}
size="small"
>
</Button>
</div>
{loadingExisting ? (
<div style={{ textAlign: "center", padding: 40 }}></div>
) : (
<Row gutter={token.margin}>
<Col span={12}>
<Text strong style={{ display: "block", marginBottom: token.marginXS }}>
Modrinth
</Text>
{publishVersions.length > 0 ? (
<Table
dataSource={publishVersions}
columns={modrinthColumns}
pagination={false}
size="small"
bordered
/>
) : (
<Empty description="无版本" />
)}
</Col>
<Col span={12}>
<Text strong style={{ display: "block", marginBottom: token.marginXS }}>
CurseForge
</Text>
{publishVersions.length > 0 ? (
<Table
dataSource={publishVersions}
columns={curseforgeColumns}
pagination={false}
size="small"
bordered
/>
) : (
<Empty description="无版本" />
)}
</Col>
</Row>
)}
</div>
);
};
// --------------- Render: Step 2 — Publish Settings ---------------
const renderStep2 = () => {
return (
<div>
{/* Changelog */}
<div style={{ marginBottom: token.marginLG }}>
<Text strong style={{ display: "block", marginBottom: token.marginSM }}>
Changelog (Markdown)
</Text>
{/* TODO: Replace with @uiw/react-md-editor */}
<textarea
style={{
width: "100%",
minHeight: 160,
border: `1px solid ${token.colorBorder}`,
borderRadius: token.borderRadius,
padding: token.paddingSM,
fontFamily: token.fontFamilyCode ?? "monospace",
fontSize: token.fontSizeSM,
}}
placeholder="输入 Markdown 格式的更新日志…"
value={changelog}
onChange={(e) => setChangelog(e.target.value)}
/>
</div>
{/* Version type */}
<div>
<Text strong style={{ display: "block", marginBottom: token.marginSM }}>
</Text>
<Select
value={versionType}
onChange={(v) => setVersionType(v)}
style={{ width: 160 }}
options={RELEASE_TYPES}
/>
</div>
</div>
);
};
// --------------- Render: Step 3 — Progress & Result ---------------
const renderStep3 = () => {
const hasModrinth = publishVersions.some(
(v) =>
!v.existsModrinth &&
(selectedVersions.has(v.key) || v.existsModrinth),
);
const hasCurseforge = publishVersions.some(
(v) =>
!v.existsCurseforge &&
(selectedVersions.has(v.key) || v.existsCurseforge),
);
return (
<div>
{/* Progress bars */}
<div style={{ marginBottom: token.marginLG }}>
<Flex vertical gap={token.marginSM}>
{hasModrinth && (
<Flex align="center" gap={token.marginXS}>
<img
src="/platform/modrinth.svg"
alt="Modrinth"
style={{ width: 30, height: 30, flexShrink: 0 }}
/>
<Text style={{ width: 80, flexShrink: 0, fontFamily: "Inter", fontWeight: "bold" }}>Modrinth</Text>
<Progress
percent={Math.round(
(progress.modrinth.done /
Math.max(progress.modrinth.total, 1)) *
100,
)}
format={() =>
`${progress.modrinth.done} / ${progress.modrinth.total}`
}
style={{ flex: 1 }}
/>
</Flex>
)}
{hasCurseforge && (
<Flex align="center" gap={token.marginXS}>
<img
src="/platform/curseforge.svg"
alt="CurseForge"
style={{ width: 30, height: 30, flexShrink: 0 }}
/>
<Text style={{ width: 80, flexShrink: 0, fontFamily: "Inter", fontWeight: "bold" }}>CurseForge</Text>
<Progress
percent={Math.round(
(progress.curseforge.done /
Math.max(progress.curseforge.total, 1)) *
100,
)}
format={() =>
`${progress.curseforge.done} / ${progress.curseforge.total}`
}
style={{ flex: 1 }}
/>
</Flex>
)}
</Flex>
</div>
{/* Result */}
{publishDone && publishResult && (
<div
style={{
background: token.colorSuccessBg,
border: `1px solid ${token.colorSuccessBorder}`,
borderRadius: token.borderRadius,
padding: token.padding,
}}
>
<Text strong></Text>
<div style={{ marginTop: token.marginSM }}>
<Text>
{publishResult.success} {publishResult.failed}
</Text>
</div>
</div>
)}
</div>
);
};
// --------------- Render ---------------
return (
<Modal
title="一键发布"
open={open}
onCancel={handleClose}
width={760}
destroyOnHidden
footer={
publishing ? null : (
<Space>
{currentStep > 0 && currentStep < 2 && (
<Button onClick={handlePrev}></Button>
)}
{currentStep === 0 ? (
<Button type="primary" onClick={handleNext}>
</Button>
) : currentStep === 1 ? (
<Button type="primary" onClick={handleConfirmPublish}>
</Button>
) : (
<Button type="primary" onClick={handleClose}>
</Button>
)}
</Space>
)
}
>
<Steps
current={currentStep}
size="small"
style={{ marginBottom: 24 }}
items={[
{ title: "版本选择" },
{ title: "发布设置" },
{ title: "发布进度" },
]}
/>
{currentStep === 0
? renderStep1()
: currentStep === 1
? renderStep2()
: renderStep3()}
</Modal>
);
}