diff --git a/package-lock.json b/package-lock.json index ac64ab5..9d0e0c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,6 +6,7 @@ "": { "dependencies": { "@ant-design/icons": "^6.3.2", + "@ant-design/nextjs-registry": "^1.3.0", "@tanstack/react-query": "^5.101.2", "@trpc/client": "^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", "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": { "version": "2.0.0", "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", "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": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@ant-design/react-slick/-/react-slick-2.0.0.tgz", diff --git a/package.json b/package.json index 60874fe..a35c258 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ }, "dependencies": { "@ant-design/icons": "^6.3.2", + "@ant-design/nextjs-registry": "^1.3.0", "@tanstack/react-query": "^5.101.2", "@trpc/client": "^11.18.0", "@trpc/server": "^11.18.0", diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000..abfbbb0 --- /dev/null +++ b/src/app/globals.css @@ -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 */ +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index dbce4ea..535d7fb 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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({ children, }: { - children: React.ReactNode + children: React.ReactNode; }) { return ( - - {children} + + + {children} + - ) + ); } diff --git a/src/app/page.tsx b/src/app/page.tsx index 6baa6ad..af6b686 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,3 +1,8 @@ -export default function Page() { - return

Hello, Next.js!

+import { listConfigs } from "@/services/config"; +import { MainPage } from "@/components/MainPage"; + +export default async function Page() { + const configList = await listConfigs(); + + return ; } diff --git a/src/app/providers.tsx b/src/app/providers.tsx new file mode 100644 index 0000000..e0cdefd --- /dev/null +++ b/src/app/providers.tsx @@ -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 ( + + + {children} + + + ); +} diff --git a/src/components/ConfigModal.tsx b/src/components/ConfigModal.tsx new file mode 100644 index 0000000..339225b --- /dev/null +++ b/src/components/ConfigModal.tsx @@ -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(null); + const [curseforgeProjectName, setCurseforgeProjectName] = useState(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 ( + + + + + } + > + {metaLoading ? ( +
+ +
+ ) : meta ? ( + + ) : null} +
+ ); +} + +// --------------------------------------------------------------------------- +// 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(); + + // 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); + } + }, [mode, initialConfig, form]); + + const handleFinish = useCallback( + async (values: ConfigValues) => { + onSave(values); + }, + [onSave], + ); + + const handleFinishFailed = useCallback(() => { + // Ant Design shows inline validation errors + }, []); + + return ( +
+ {/* Hidden submit button triggered by modal footer */} + + + )} + + + + ), + }, + + // ──── CurseForge ──── + { + key: "curseforge", + label: "CurseForge", + children: ( + <> + + + lookupCurseforgeProject( + typeof val === "number" ? val : null, + ) + } + /> + + + + + + + + ({ + label: l, + value: l, + }))} + /> + + + {/* Relations */} + + + {(fields, { add, remove }) => ( + <> + {fields.map(({ key, name, ...rest }) => ( + + + + + + ({ + value: c.file, + label: c.name, + }))} + /> + + + + + setModalOpen(false)} + onSaved={handleModalSaved} + /> + + ); +} diff --git a/src/components/MainPage.tsx b/src/components/MainPage.tsx new file mode 100644 index 0000000..92ddd1a --- /dev/null +++ b/src/components/MainPage.tsx @@ -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( + null, + ); + const [selectedConfig, setSelectedConfig] = useState(null); + const [parsedProject, setParsedProject] = useState( + 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 ( +
+ {/* Page header */} + + Mod Releaser + + + + {/* 1. Config selector */} + + + {/* 2. Version table */} + + + + {/* Publish button */} + {parsedProject && parsedProject.artifacts.length > 0 && ( +
+ +
+ )} +
+
+ + {/* 3. Publish modal */} + {publishOpen && selectedConfig && parsedProject && ( + + )} +
+ ); +} diff --git a/src/components/PublishModal.tsx b/src/components/PublishModal.tsx new file mode 100644 index 0000000..83e8572 --- /dev/null +++ b/src/components/PublishModal.tsx @@ -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>( + 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([]); + const [existingCurseforge, setExistingCurseforge] = useState([]); + + 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(); + 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) => ( + { + 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) => ( + { + const next = new Set(selectedVersions); + if (e.target.checked) next.add(record.key); + else next.delete(record.key); + setSelectedVersions(next); + }} + /> + ), + }, + ]; + + return ( +
+
+ 选择要发布的版本 + +
+ + {loadingExisting ? ( +
加载中…
+ ) : ( + + + + Modrinth + + {publishVersions.length > 0 ? ( + + ) : ( + + )} + + + + CurseForge + + {publishVersions.length > 0 ? ( +
+ ) : ( + + )} + + + )} + + ); + }; + + // --------------- 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 ( +
+ {/* Changelog */} +
+ + Changelog (Markdown) + + {/* TODO: Replace with @uiw/react-md-editor */} +