Implement project frontend page

This commit is contained in:
CPTProgrammer 2026-07-11 12:13:20 +08:00
parent b5555d5635
commit 8d23ae1823
No known key found for this signature in database
11 changed files with 1483 additions and 6 deletions

19
package-lock.json generated
View File

@ -6,6 +6,7 @@
"": { "": {
"dependencies": { "dependencies": {
"@ant-design/icons": "^6.3.2", "@ant-design/icons": "^6.3.2",
"@ant-design/nextjs-registry": "^1.3.0",
"@tanstack/react-query": "^5.101.2", "@tanstack/react-query": "^5.101.2",
"@trpc/client": "^11.18.0", "@trpc/client": "^11.18.0",
"@trpc/server": "^11.18.0", "@trpc/server": "^11.18.0",
@ -99,6 +100,18 @@
"resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.5.0.tgz", "resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.5.0.tgz",
"integrity": "sha512-1BTUFyKPTBZ53MuTP8s0k5SFEXL7o3VHEOwLgzaoWKwnBeqIcqUtVshc4SKzhI6uACfqhJqBwBUE9FsWR3uULA==" "integrity": "sha512-1BTUFyKPTBZ53MuTP8s0k5SFEXL7o3VHEOwLgzaoWKwnBeqIcqUtVshc4SKzhI6uACfqhJqBwBUE9FsWR3uULA=="
}, },
"node_modules/@ant-design/nextjs-registry": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@ant-design/nextjs-registry/-/nextjs-registry-1.3.0.tgz",
"integrity": "sha512-lHlg5bnj3LPYHUD+iZsO1k11NhIrx1mLWh2DXQ0m3Ly3OSaYfgeHX0WoVXxdVjEGjjYnPTe+OUY7YvU/p1aPZw==",
"peerDependencies": {
"@ant-design/cssinjs": ">=1.0.0",
"antd": ">=5.0.0",
"next": ">=14.0.0",
"react": ">=16.0.0",
"react-dom": ">=16.0.0"
}
},
"node_modules/@ant-design/react-slick": { "node_modules/@ant-design/react-slick": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/@ant-design/react-slick/-/react-slick-2.0.0.tgz", "resolved": "https://registry.npmjs.org/@ant-design/react-slick/-/react-slick-2.0.0.tgz",
@ -5859,6 +5872,12 @@
"resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.5.0.tgz", "resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.5.0.tgz",
"integrity": "sha512-1BTUFyKPTBZ53MuTP8s0k5SFEXL7o3VHEOwLgzaoWKwnBeqIcqUtVshc4SKzhI6uACfqhJqBwBUE9FsWR3uULA==" "integrity": "sha512-1BTUFyKPTBZ53MuTP8s0k5SFEXL7o3VHEOwLgzaoWKwnBeqIcqUtVshc4SKzhI6uACfqhJqBwBUE9FsWR3uULA=="
}, },
"@ant-design/nextjs-registry": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@ant-design/nextjs-registry/-/nextjs-registry-1.3.0.tgz",
"integrity": "sha512-lHlg5bnj3LPYHUD+iZsO1k11NhIrx1mLWh2DXQ0m3Ly3OSaYfgeHX0WoVXxdVjEGjjYnPTe+OUY7YvU/p1aPZw==",
"requires": {}
},
"@ant-design/react-slick": { "@ant-design/react-slick": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/@ant-design/react-slick/-/react-slick-2.0.0.tgz", "resolved": "https://registry.npmjs.org/@ant-design/react-slick/-/react-slick-2.0.0.tgz",

View File

@ -9,6 +9,7 @@
}, },
"dependencies": { "dependencies": {
"@ant-design/icons": "^6.3.2", "@ant-design/icons": "^6.3.2",
"@ant-design/nextjs-registry": "^1.3.0",
"@tanstack/react-query": "^5.101.2", "@tanstack/react-query": "^5.101.2",
"@trpc/client": "^11.18.0", "@trpc/client": "^11.18.0",
"@trpc/server": "^11.18.0", "@trpc/server": "^11.18.0",

26
src/app/globals.css Normal file
View File

@ -0,0 +1,26 @@
/* Global reset & base styles — follow Ant Design 3-layer surface model */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html,
body {
height: 100%;
font-family:
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
"Helvetica Neue",
Arial,
"Noto Sans",
sans-serif;
font-size: 14px;
line-height: 1.5715;
color: #1f1f1f; /* Ant Design on-surface / colorText */
background-color: #f5f5f5; /* Ant Design bg-layout */
}

View File

@ -1,11 +1,22 @@
import type { Metadata } from "next";
import { Providers } from "./providers";
import "./globals.css";
export const metadata: Metadata = {
title: "Mod Releaser",
description: "一键向 Modrinth 和 CurseForge 发布 mod 新版本",
};
export default function RootLayout({ export default function RootLayout({
children, children,
}: { }: {
children: React.ReactNode children: React.ReactNode;
}) { }) {
return ( return (
<html lang="en"> <html lang="zh-CN">
<body>{children}</body> <body>
<Providers>{children}</Providers>
</body>
</html> </html>
) );
} }

View File

@ -1,3 +1,8 @@
export default function Page() { import { listConfigs } from "@/services/config";
return <h1>Hello, Next.js!</h1> import { MainPage } from "@/components/MainPage";
export default async function Page() {
const configList = await listConfigs();
return <MainPage initialConfigs={configList} />;
} }

21
src/app/providers.tsx Normal file
View File

@ -0,0 +1,21 @@
"use client";
import { AntdRegistry } from "@ant-design/nextjs-registry";
import { App, ConfigProvider } from "antd";
export function Providers({ children }: { children: React.ReactNode }) {
return (
<AntdRegistry>
<ConfigProvider
theme={{
token: {
colorPrimary: "#1677FF",
borderRadius: 6,
},
}}
>
<App>{children}</App>
</ConfigProvider>
</AntdRegistry>
);
}

View File

@ -0,0 +1,609 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import {
Modal,
Form,
Input,
InputNumber,
Select,
Tabs,
Button,
Space,
App,
Spin,
} from "antd";
import { PlusOutlined, DeleteOutlined } from "@ant-design/icons";
import { useDebouncedCallback } from "use-debounce";
import { createConfig, updateConfig } from "@/services/config";
import {
getModrinthLoaders,
getModrinthEnvironments,
getCurseForgeMeta,
} from "@/services/meta";
import type { Config } from "@/types";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const DEPENDENCY_TYPES = [
{ label: "Required", value: "required" },
{ label: "Optional", value: "optional" },
{ label: "Incompatible", value: "incompatible" },
{ label: "Embedded", value: "embedded" },
];
const RELATION_TYPES = [
{ label: "Embedded Library", value: "embeddedLibrary" },
{ label: "Incompatible", value: "incompatible" },
{ label: "Optional Dependency", value: "optionalDependency" },
{ label: "Required Dependency", value: "requiredDependency" },
{ label: "Tool", value: "tool" },
];
type ConfigValues = Config;
// ---------------------------------------------------------------------------
// Outer component — Modal shell, meta loading, state
// ---------------------------------------------------------------------------
interface ConfigModalProps {
open: boolean;
mode: "add" | "edit";
initialName: string | null;
initialConfig: Config | null;
onCancel: () => void;
onSaved: (file: string, name: string) => void;
}
export function ConfigModal({
open,
mode,
initialName,
initialConfig,
onCancel,
onSaved,
}: ConfigModalProps) {
const { message } = App.useApp();
const [saving, setSaving] = useState(false);
// --------------- Metadata ---------------
const [metaLoading, setMetaLoading] = useState(false);
const [meta, setMeta] = useState<{
modrinthLoaders: string[];
modrinthEnvironments: { value: string; label: string }[];
curseforgeEnvs: string[];
curseforgeLoaders: string[];
} | null>(null);
const loadMeta = useCallback(async () => {
setMetaLoading(true);
try {
const [loaders, envs, cfMeta] = await Promise.all([
getModrinthLoaders(),
getModrinthEnvironments(),
getCurseForgeMeta(),
]);
setMeta({
modrinthLoaders: loaders,
modrinthEnvironments: envs,
curseforgeEnvs: cfMeta.environments,
curseforgeLoaders: cfMeta.loaders,
});
} catch {
message.warning("部分元数据加载失败,手动输入仍然可用");
} finally {
setMetaLoading(false);
}
}, []);
useEffect(() => {
if (open) loadMeta();
}, [open, loadMeta]);
// --------------- Save ---------------
const handleSave = useCallback(
async (values: ConfigValues) => {
const data: Config = {
...values,
modrinth: {
...values.modrinth,
dependencies: values.modrinth.dependencies ?? [],
},
curseforge: {
...values.curseforge,
relations: values.curseforge.relations ?? { projects: [] },
},
};
setSaving(true);
try {
if (mode === "add") {
await createConfig(data);
onSaved(`${data.name}.json`, data.name);
message.success("配置已创建");
} else {
const oldName = (initialName ?? "").replace(/\.json$/, "");
await updateConfig(oldName, data);
onSaved(`${data.name}.json`, data.name);
message.success("配置已更新");
}
} catch (err) {
message.error(
err instanceof Error ? err.message : "保存配置失败",
);
} finally {
setSaving(false);
}
},
[mode, initialName, onSaved],
);
// --------------- Debounced project name lookup ---------------
const [modrinthProjectName, setModrinthProjectName] = useState<string | null>(null);
const [curseforgeProjectName, setCurseforgeProjectName] = useState<string | null>(null);
const lookupModrinthProject = useDebouncedCallback(async (projectId: string) => {
if (!projectId) {
setModrinthProjectName(null);
return;
}
// TODO: call Modrinth API to get project name
setModrinthProjectName(null);
}, 800);
const lookupCurseforgeProject = useDebouncedCallback(async (projectId: number | null) => {
if (projectId == null) {
setCurseforgeProjectName(null);
return;
}
// TODO: call CurseForge API to get project name
setCurseforgeProjectName(null);
}, 800);
// --------------- Render ---------------
return (
<Modal
title={mode === "add" ? "添加配置" : "修改配置"}
open={open}
onCancel={onCancel}
width={720}
destroyOnHidden
footer={
<Space>
<Button onClick={onCancel}></Button>
<Button
type="primary"
loading={saving}
onClick={() => {
// Trigger form submit via a hidden button inside the form
document
.getElementById("config-modal-submit")
?.click();
}}
>
</Button>
</Space>
}
>
{metaLoading ? (
<div style={{ textAlign: "center", padding: 40 }}>
<Spin description="正在加载元数据…" />
</div>
) : meta ? (
<ConfigModalForm
mode={mode}
initialName={initialName}
initialConfig={initialConfig}
meta={meta}
modrinthProjectName={modrinthProjectName}
curseforgeProjectName={curseforgeProjectName}
lookupModrinthProject={lookupModrinthProject}
lookupCurseforgeProject={lookupCurseforgeProject}
onSave={handleSave}
/>
) : null}
</Modal>
);
}
// ---------------------------------------------------------------------------
// Inner component — Form (only rendered when meta is loaded)
// ---------------------------------------------------------------------------
interface ConfigModalFormProps {
mode: "add" | "edit";
initialName: string | null;
initialConfig: Config | null;
meta: {
modrinthLoaders: string[];
modrinthEnvironments: { value: string; label: string }[];
curseforgeEnvs: string[];
curseforgeLoaders: string[];
};
modrinthProjectName: string | null;
curseforgeProjectName: string | null;
lookupModrinthProject: (projectId: string) => void;
lookupCurseforgeProject: (projectId: number | null) => void;
onSave: (values: ConfigValues) => void;
}
function ConfigModalForm({
mode,
initialConfig,
meta,
modrinthProjectName,
curseforgeProjectName,
lookupModrinthProject,
lookupCurseforgeProject,
onSave,
}: ConfigModalFormProps) {
const [form] = Form.useForm<ConfigValues>();
// Reset form when modal opens or mode/config changes
useEffect(() => {
if (mode === "edit" && initialConfig) {
form.setFieldsValue(initialConfig);
} else {
form.resetFields();
form.setFieldsValue({
minecraft_properties_dir: "./properties",
project_properties_path: "./gradle.properties",
mod_version_field: "mod_version",
filename_format: "",
source_filename_format: "",
modrinth: {
project_id: "",
version_name: "ModName v${version} for Minecraft ${mc_version_range}",
version: "${version}-mc${mc_version}",
loaders: [],
environment: "client_and_server",
dependencies: [],
} as Config["modrinth"],
curseforge: {
project_id: 0,
version_name: "#{filename_format}",
environment: [],
loaders: [],
relations: { projects: [] },
} as Config["curseforge"],
} as Partial<ConfigValues>);
}
}, [mode, initialConfig, form]);
const handleFinish = useCallback(
async (values: ConfigValues) => {
onSave(values);
},
[onSave],
);
const handleFinishFailed = useCallback(() => {
// Ant Design shows inline validation errors
}, []);
return (
<Form
form={form}
layout="vertical"
onFinish={handleFinish}
onFinishFailed={handleFinishFailed}
style={{ maxHeight: "60vh", overflow: "auto", paddingRight: 8 }}
>
{/* Hidden submit button triggered by modal footer */}
<button id="config-modal-submit" type="submit" style={{ display: "none" }} />
<Tabs
items={[
// ──── 通用 ────
{
key: "general",
label: "通用",
children: (
<>
<Form.Item
name="name"
label="配置名称"
rules={[{ required: true, message: "请输入配置名称" }]}
>
<Input placeholder="模组名称" />
</Form.Item>
<Form.Item
name="project_dir"
label="项目目录"
rules={[{ required: true, message: "请输入项目目录绝对路径" }]}
>
<Input placeholder="/path/to/project" />
</Form.Item>
<Form.Item
name="minecraft_properties_dir"
label="MC 属性目录"
rules={[{ required: true }]}
>
<Input placeholder="./properties" />
</Form.Item>
<Form.Item
name="project_properties_path"
label="项目属性文件路径"
rules={[{ required: true }]}
>
<Input placeholder="./gradle.properties" />
</Form.Item>
<Form.Item
name="mod_version_field"
label="版本号字段名"
rules={[{ required: true }]}
>
<Input placeholder="mod_version" />
</Form.Item>
<Form.Item
name="filename_format"
label="构建产物文件名模板"
rules={[{ required: true, message: "请输入文件名模板" }]}
extra="支持 ${version} 和 ${mc_version} 占位符"
>
<Input placeholder='modname-${version}-mc${mc_version}.jar' />
</Form.Item>
<Form.Item
name="source_filename_format"
label="源码文件名模板"
rules={[{ required: true }]}
extra="支持 ${version} 和 ${mc_version} 占位符"
>
<Input placeholder='modname-${version}-mc${mc_version}-sources.jar' />
</Form.Item>
</>
),
},
// ──── Modrinth ────
{
key: "modrinth",
label: "Modrinth",
children: (
<>
<Form.Item
name={["modrinth", "project_id"]}
label="项目 ID"
rules={[{ required: true, message: "请输入 Modrinth 项目 ID" }]}
extra={
modrinthProjectName
? `项目名称: ${modrinthProjectName}`
: undefined
}
>
<Input
placeholder="8 位 base62"
onChange={(e) => lookupModrinthProject(e.target.value)}
/>
</Form.Item>
<Form.Item
name={["modrinth", "version_name"]}
label="版本名称模板"
rules={[{ required: true }]}
extra="支持 ${version}、${mc_version_range} 等占位符"
>
<Input placeholder="ModName v${version} for Minecraft ${mc_version_range}" />
</Form.Item>
<Form.Item
name={["modrinth", "version"]}
label="版本号模板"
rules={[{ required: true }]}
extra="支持 ${version}、${mc_version} 等占位符"
>
<Input placeholder="${version}-mc${mc_version}" />
</Form.Item>
<Form.Item
name={["modrinth", "loaders"]}
label="加载器"
rules={[{ required: true, message: "请选择加载器" }]}
>
<Select
mode="multiple"
placeholder="选择加载器…"
options={meta.modrinthLoaders.map((l) => ({
label: l,
value: l,
}))}
/>
</Form.Item>
<Form.Item
name={["modrinth", "environment"]}
label="运行环境"
rules={[{ required: true, message: "请选择运行环境" }]}
>
<Select
placeholder="选择运行环境"
options={meta.modrinthEnvironments}
/>
</Form.Item>
{/* Dependencies */}
<Form.Item label="依赖">
<Form.List name={["modrinth", "dependencies"]}>
{(fields, { add, remove }) => (
<>
{fields.map(({ key, name, ...rest }) => (
<Space
key={key}
style={{ display: "flex", marginBottom: 8 }}
align="baseline"
>
<Form.Item
{...rest}
name={[name, "dependency_type"]}
rules={[{ required: true, message: "必填" }]}
style={{ marginBottom: 0 }}
>
<Select
placeholder="类型"
options={DEPENDENCY_TYPES}
style={{ width: 140 }}
/>
</Form.Item>
<Form.Item
{...rest}
name={[name, "project_id"]}
rules={[{ required: true, message: "必填" }]}
style={{ marginBottom: 0 }}
>
<Input placeholder="项目 ID" style={{ width: 200 }} />
</Form.Item>
<DeleteOutlined onClick={() => remove(name)} />
</Space>
))}
<Button
type="dashed"
onClick={() =>
add({ dependency_type: "required", project_id: "" })
}
icon={<PlusOutlined />}
block
>
</Button>
</>
)}
</Form.List>
</Form.Item>
</>
),
},
// ──── CurseForge ────
{
key: "curseforge",
label: "CurseForge",
children: (
<>
<Form.Item
name={["curseforge", "project_id"]}
label="项目 ID"
rules={[{ required: true, message: "请输入 CurseForge 项目 ID" }]}
extra={
curseforgeProjectName
? `项目名称: ${curseforgeProjectName}`
: undefined
}
>
<InputNumber
style={{ width: "100%" }}
placeholder="数字 ID"
onChange={(val) =>
lookupCurseforgeProject(
typeof val === "number" ? val : null,
)
}
/>
</Form.Item>
<Form.Item
name={["curseforge", "version_name"]}
label="版本名称模板"
rules={[{ required: true }]}
extra="支持 #{filename_format} 等占位符"
>
<Input placeholder="#{filename_format}" />
</Form.Item>
<Form.Item
name={["curseforge", "environment"]}
label="运行环境"
rules={[{ required: true, message: "请选择运行环境" }]}
>
<Select
mode="multiple"
placeholder="选择运行环境…"
options={meta.curseforgeEnvs.map((e) => ({
label: e,
value: e,
}))}
/>
</Form.Item>
<Form.Item
name={["curseforge", "loaders"]}
label="加载器"
rules={[{ required: true, message: "请选择加载器" }]}
>
<Select
mode="multiple"
placeholder="选择加载器…"
options={meta.curseforgeLoaders.map((l) => ({
label: l,
value: l,
}))}
/>
</Form.Item>
{/* Relations */}
<Form.Item label="关联项目">
<Form.List name={["curseforge", "relations", "projects"]}>
{(fields, { add, remove }) => (
<>
{fields.map(({ key, name, ...rest }) => (
<Space
key={key}
style={{ display: "flex", marginBottom: 8 }}
align="baseline"
>
<Form.Item
{...rest}
name={[name, "slug"]}
rules={[{ required: true, message: "必填" }]}
style={{ marginBottom: 0 }}
>
<Input placeholder="Slug" style={{ width: 160 }} />
</Form.Item>
<Form.Item
{...rest}
name={[name, "type"]}
rules={[{ required: true, message: "必填" }]}
style={{ marginBottom: 0 }}
>
<Select
placeholder="类型"
options={RELATION_TYPES}
style={{ width: 170 }}
/>
</Form.Item>
<DeleteOutlined onClick={() => remove(name)} />
</Space>
))}
<Button
type="dashed"
onClick={() =>
add({ slug: "", type: "requiredDependency" })
}
icon={<PlusOutlined />}
block
>
</Button>
</>
)}
</Form.List>
</Form.Item>
</>
),
},
]}
/>
</Form>
);
}

View File

@ -0,0 +1,135 @@
"use client";
import { useState, useCallback } from "react";
import { Select, Button, Space, App } from "antd";
import { EditOutlined, PlusOutlined } from "@ant-design/icons";
import { ConfigModal } from "./ConfigModal";
import { getConfig } from "@/services/config";
import { parseProject } from "@/services/project";
import type { Config } from "@/types";
import type { ParsedProject } from "@/services/project";
interface ConfigEntry {
name: string;
file: string;
}
interface ConfigSelectorProps {
configs: ConfigEntry[];
selectedFile: string | null;
onSelect: (file: string, config: Config, project: ParsedProject) => void;
onConfigAdded: (entry: ConfigEntry) => void;
onConfigUpdated: (entry: ConfigEntry) => void;
}
/** Strip .json suffix to get the config name */
function toConfigName(file: string): string {
return file.replace(/\.json$/, "");
}
export function ConfigSelector({
configs,
selectedFile,
onSelect,
onConfigAdded,
onConfigUpdated,
}: ConfigSelectorProps) {
const { message } = App.useApp();
const [selecting, setSelecting] = useState(false);
// Config modal state
const [modalOpen, setModalOpen] = useState(false);
const [modalMode, setModalMode] = useState<"add" | "edit">("add");
const [modalInitial, setModalInitial] = useState<{
name: string;
config: Config;
} | null>(null);
// Handle config selection
const handleChange = useCallback(
async (file: string) => {
setSelecting(true);
try {
const config = await getConfig(toConfigName(file));
const project = await parseProject(config);
onSelect(file, config, project);
} catch (err) {
message.error(
err instanceof Error ? err.message : "解析项目配置失败",
);
} finally {
setSelecting(false);
}
},
[onSelect],
);
// Open add modal
const openAdd = useCallback(() => {
setModalMode("add");
setModalInitial(null);
setModalOpen(true);
}, []);
// Open edit modal
const openEdit = useCallback(async () => {
if (!selectedFile) {
message.warning("请先选择一个配置");
return;
}
try {
const config = await getConfig(toConfigName(selectedFile));
setModalMode("edit");
setModalInitial({ name: selectedFile, config });
setModalOpen(true);
} catch (err) {
message.error("读取配置失败");
}
}, [selectedFile]);
// Modal save callback
const handleModalSaved = useCallback(
(file: string, name: string) => {
if (modalMode === "add") {
onConfigAdded({ name, file });
} else {
onConfigUpdated({ name, file });
}
setModalOpen(false);
},
[modalMode, onConfigAdded, onConfigUpdated],
);
return (
<>
<Space.Compact style={{ width: "100%" }}>
<Select
style={{ flex: 1 }}
placeholder="选择配置…"
loading={selecting}
value={selectedFile}
onChange={handleChange}
options={configs.map((c) => ({
value: c.file,
label: c.name,
}))}
/>
<Button icon={<EditOutlined />} onClick={openEdit}>
</Button>
<Button icon={<PlusOutlined />} onClick={openAdd}>
</Button>
</Space.Compact>
<ConfigModal
open={modalOpen}
mode={modalMode}
initialName={modalInitial?.name ?? null}
initialConfig={modalInitial?.config ?? null}
onCancel={() => setModalOpen(false)}
onSaved={handleModalSaved}
/>
</>
);
}

130
src/components/MainPage.tsx Normal file
View File

@ -0,0 +1,130 @@
"use client";
import { useState, useCallback } from "react";
import { Typography, Space, Card, Button } from "antd";
import { CloudUploadOutlined } from "@ant-design/icons";
import { ConfigSelector } from "./ConfigSelector";
import { VersionTable } from "./VersionTable";
import { PublishModal } from "./PublishModal";
import type { Config } from "@/types";
import type { ParsedProject } from "@/services/project";
const { Title } = Typography;
interface MainPageProps {
initialConfigs: { name: string; file: string }[];
}
export function MainPage({ initialConfigs }: MainPageProps) {
const [configs, setConfigs] =
useState<{ name: string; file: string }[]>(initialConfigs);
const [selectedConfigFile, setSelectedConfigFile] = useState<string | null>(
null,
);
const [selectedConfig, setSelectedConfig] = useState<Config | null>(null);
const [parsedProject, setParsedProject] = useState<ParsedProject | null>(
null,
);
const [projectLoading, setProjectLoading] = useState(false);
// ------- ConfigSelector callbacks -------
const handleConfigSelect = useCallback(
async (file: string, config: Config, project: ParsedProject) => {
setSelectedConfigFile(file);
setSelectedConfig(config);
setParsedProject(project);
},
[],
);
const handleConfigAdded = useCallback(
(entry: { name: string; file: string }) => {
setConfigs((prev) => [...prev, entry]);
},
[],
);
const handleConfigUpdated = useCallback(
(entry: { name: string; file: string }) => {
setConfigs((prev) =>
prev.map((c) => (c.file === entry.file ? entry : c)),
);
},
[],
);
// ------- Publish -------
const [publishOpen, setPublishOpen] = useState(false);
const handlePublishClose = useCallback(() => {
setPublishOpen(false);
}, []);
return (
<main
style={{
maxWidth: 900,
margin: "40px auto",
padding: "0 24px",
}}
>
{/* Page header */}
<Title level={2} style={{ marginBottom: 24 }}>
Mod Releaser
</Title>
<Space orientation="vertical" size="large" style={{ width: "100%" }}>
{/* 1. Config selector */}
<ConfigSelector
configs={configs}
selectedFile={selectedConfigFile}
onSelect={handleConfigSelect}
onConfigAdded={handleConfigAdded}
onConfigUpdated={handleConfigUpdated}
/>
{/* 2. Version table */}
<Card>
<VersionTable
project={parsedProject}
loading={projectLoading}
onLoadingChange={setProjectLoading}
/>
{/* Publish button */}
{parsedProject && parsedProject.artifacts.length > 0 && (
<div
style={{
display: "flex",
justifyContent: "flex-end",
marginTop: 24,
}}
>
<Button
type="primary"
icon={<CloudUploadOutlined />}
size="large"
onClick={() => setPublishOpen(true)}
disabled={!selectedConfig}
>
</Button>
</div>
)}
</Card>
</Space>
{/* 3. Publish modal */}
{publishOpen && selectedConfig && parsedProject && (
<PublishModal
open={publishOpen}
config={selectedConfig}
project={parsedProject}
onClose={handlePublishClose}
/>
)}
</main>
);
}

View File

@ -0,0 +1,412 @@
"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 { Config } from "@/types";
import type { ParsedProject } from "@/services/project";
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;
}
// ---------------------------------------------------------------------------
// 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<"release" | "beta" | "alpha">(
"release",
);
// Progress
const [publishing, setPublishing] = useState(false);
const [progress, setProgress] = useState<{
modrinth: { done: number; total: number };
curseforge: { done: number; total: number };
}>({ modrinth: { done: 0, total: 0 }, curseforge: { done: 0, total: 0 } });
// 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={[
{ label: "Release", value: "release" },
{ label: "Beta", value: "beta" },
{ label: "Alpha", value: "alpha" },
]}
/>
</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>
);
}

View File

@ -0,0 +1,108 @@
"use client";
import { Table, Empty, Tag } from "antd";
import { useMemo } from "react";
import type { ParsedProject } from "@/services/project";
interface ArtifactRow {
key: string;
mc_version: string;
artifact: string | null;
sources: string | null;
}
interface VersionTableProps {
project: ParsedProject | null;
loading: boolean;
onLoadingChange: (loading: boolean) => void;
}
export function VersionTable({
project,
loading,
onLoadingChange,
}: VersionTableProps) {
const dataSource: ArtifactRow[] = useMemo(() => {
if (!project) return [];
return project.artifacts.map((a) => ({
key: a.mc_version,
mc_version: a.mc_version,
artifact: a.artifact,
sources: a.sources,
}));
}, [project]);
const columns = [
{
title: "MC 版本",
dataIndex: "mc_version",
key: "mc_version",
width: 120,
render: (val: string) => <Tag>{val}</Tag>,
},
{
title: "兼容范围",
dataIndex: "mc_version",
key: "range",
width: 200,
render: (_: string, __: ArtifactRow, index: number) => {
const isLast =
project && index === project.artifacts.length - 1;
// 兼容范围在发布前由 PublishModal 加载元数据后完整计算
// 此处仅作占位显示
return isLast ? (
<Tag color="blue"></Tag>
) : (
<span style={{ color: "#8c8c8c" }}></span>
);
},
},
{
title: "构建产物",
dataIndex: "artifact",
key: "artifact",
ellipsis: true,
render: (val: string | null) =>
val ? (
<span style={{ color: "#52C41A" }}>{val}</span>
) : (
<span style={{ color: "#FF4D4F" }}></span>
),
},
{
title: "源码",
dataIndex: "sources",
key: "sources",
ellipsis: true,
render: (val: string | null) =>
val ? (
<span style={{ color: "#52C41A" }}>{val}</span>
) : (
<span style={{ color: "#FF4D4F" }}></span>
),
},
];
if (!project) {
return (
<Empty
description="请选择一个配置以查看版本信息"
style={{ padding: 40 }}
/>
);
}
return (
<Table<ArtifactRow>
dataSource={dataSource}
columns={columns}
loading={loading}
pagination={false}
size="middle"
bordered
locale={{
emptyText: "未找到构建产物",
}}
/>
);
}