ModReleaser/src/components/PublishModal.tsx

425 lines
12 KiB
TypeScript
Raw Normal View History

2026-07-11 12:13:20 +08:00
"use client";
import { useState, useCallback } from "react";
import {
Modal,
Steps,
Button,
Space,
Checkbox,
Table,
Select,
Row,
Col,
Progress,
Typography,
Empty,
} from "antd";
import { ReloadOutlined } from "@ant-design/icons";
import type { BaseVersion, Config, UploadReleaseType } from "@/types";
2026-07-11 12:13:20 +08:00
import type { ParsedProject } from "@/services/project";
import { platforms } from "@/services/publish.schemas";
2026-07-11 12:13:20 +08:00
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] }));
2026-07-11 12:13:20 +08:00
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function PublishModal({
open,
config,
project,
onClose,
}: PublishModalProps) {
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>(
2026-07-11 12:13:20 +08:00
"release",
);
// Progress
const [publishing, setPublishing] = useState(false);
const [progress, setProgress] = useState<
Record<typeof platforms[number], { done: number; total: number }>
>({
modrinth: { done: 0, total: 0 },
curseforge: { done: 0, total: 0 }
});
2026-07-11 12:13:20 +08:00
// 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, 1));
}, [currentStep, publishVersions]);
const handlePrev = useCallback(() => {
setCurrentStep((prev) => Math.max(prev - 1, 0));
}, []);
const handleConfirmPublish = useCallback(async () => {
setPublishing(true);
// TODO: Call tRPC mutation to publish
setPublishing(false);
}, []);
const handleClose = useCallback(() => {
if (publishing) return;
setCurrentStep(0);
setSelectedVersions(new Set());
setChangelog("");
setVersionType("release");
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: 16,
}}
>
<Text strong></Text>
<Button
icon={<ReloadOutlined />}
loading={loadingExisting}
onClick={loadExisting}
size="small"
>
</Button>
</div>
{loadingExisting ? (
<div style={{ textAlign: "center", padding: 40 }}></div>
) : (
<Row gutter={16}>
<Col span={12}>
<Text strong style={{ display: "block", marginBottom: 8 }}>
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: 8 }}>
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 = () => {
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>
{/* Changelog */}
<div style={{ marginBottom: 24 }}>
<Text strong style={{ display: "block", marginBottom: 8 }}>
Changelog (Markdown)
</Text>
{/* TODO: Replace with @uiw/react-md-editor */}
<textarea
style={{
width: "100%",
minHeight: 160,
border: "1px solid #d9d9d9",
borderRadius: 6,
padding: 8,
fontFamily: "monospace",
fontSize: 13,
}}
placeholder="输入 Markdown 格式的更新日志…"
value={changelog}
onChange={(e) => setChangelog(e.target.value)}
/>
</div>
{/* Version type */}
<div style={{ marginBottom: 24 }}>
<Text strong style={{ display: "block", marginBottom: 8 }}>
</Text>
<Select
value={versionType}
onChange={(v) => setVersionType(v)}
style={{ width: 160 }}
options={RELEASE_TYPES}
2026-07-11 12:13:20 +08:00
/>
</div>
{/* Publishing progress */}
{publishing && (
<div style={{ marginBottom: 24 }}>
{hasModrinth && (
<div style={{ marginBottom: 12 }}>
<Text>Modrinth: </Text>
<Progress
percent={Math.round(
(progress.modrinth.done / Math.max(progress.modrinth.total, 1)) *
100,
)}
format={() =>
`${progress.modrinth.done} / ${progress.modrinth.total}`
}
style={{ width: "80%" }}
/>
</div>
)}
{hasCurseforge && (
<div>
<Text>CurseForge: </Text>
<Progress
percent={Math.round(
(progress.curseforge.done /
Math.max(progress.curseforge.total, 1)) *
100,
)}
format={() =>
`${progress.curseforge.done} / ${progress.curseforge.total}`
}
style={{ width: "80%" }}
/>
</div>
)}
</div>
)}
</div>
);
};
// --------------- Render ---------------
return (
<Modal
title="一键发布"
open={open}
onCancel={handleClose}
width={760}
destroyOnHidden
footer={
publishing ? null : (
<Space>
{currentStep > 0 && (
<Button onClick={handlePrev}></Button>
)}
{currentStep < 1 ? (
<Button type="primary" onClick={handleNext}>
</Button>
) : (
<Button type="primary" onClick={handleConfirmPublish}>
</Button>
)}
</Space>
)
}
>
<Steps
current={currentStep}
size="small"
style={{ marginBottom: 24 }}
items={[
{ title: "版本选择" },
{ title: "发布设置" },
]}
/>
{currentStep === 0 ? renderStep1() : renderStep2()}
</Modal>
);
}