Add loader icons, colors, and sort order to ConfigModal selects

This commit is contained in:
CPTProgrammer 2026-07-15 22:16:12 +08:00
parent 80d664936a
commit 269e7024a2
No known key found for this signature in database
2 changed files with 191 additions and 3 deletions

View File

@ -1,6 +1,6 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import React, { useEffect, useState, useCallback, useMemo } from "react";
import {
Modal,
Form,
@ -8,9 +8,11 @@ import {
InputNumber,
Select,
Tabs,
Tag,
Button,
Space,
App,
theme,
} from "antd";
import { PlusOutlined, DeleteOutlined } from "@ant-design/icons";
import { createConfig, updateConfig } from "@/services/config";
@ -40,6 +42,7 @@ import {
} from "@/hooks/useFieldValidations";
import type { Config, DependencyType, UploadRelationType } from "@/types";
import { Template } from "@/lib/utils/template";
import { getModLoaderMetadata, MOD_LOADERS_METADATA, type ModLoaderMetadata } from "./loaders";
// ---------------------------------------------------------------------------
// 工具
@ -485,6 +488,10 @@ export function ConfigModal({
// Inner component — Form with independently-loading Selects
// ---------------------------------------------------------------------------
function LoaderIcon({ meta, size }: { meta: ModLoaderMetadata; size: number }) {
return <meta.iconComponent style={{ color: meta.color, fontSize: size }} />;
}
interface ConfigModalFormProps {
mode: "add" | "edit";
initialConfig: Config | null;
@ -497,12 +504,37 @@ function ConfigModalForm({
onSave,
}: ConfigModalFormProps) {
const [form] = Form.useForm<ConfigValues>();
const { token } = theme.useToken();
// Each Select loads its own data independently
const mrLoaders = useModrinthLoaders();
const mrEnvs = useModrinthEnvironments();
const cfMeta = useCurseforgeMeta();
const sortedMrOptions = useMemo(() => {
const orderMap = new Map(
MOD_LOADERS_METADATA.map((m, i) => [m.name.toLowerCase(), i]),
);
const sortFn = (a: { label: string }, b: { label: string }) => {
const ai = orderMap.get(a.label.toLowerCase()) ?? Infinity;
const bi = orderMap.get(b.label.toLowerCase()) ?? Infinity;
return ai - bi;
};
return [...mrLoaders.options].sort(sortFn);
}, [mrLoaders.options]);
const sortedCfOptions = useMemo(() => {
const orderMap = new Map(
MOD_LOADERS_METADATA.map((m, i) => [m.name.toLowerCase(), i]),
);
const sortFn = (a: string, b: string) => {
const ai = orderMap.get(a.toLowerCase()) ?? Infinity;
const bi = orderMap.get(b.toLowerCase()) ?? Infinity;
return ai - bi;
};
return [...cfMeta.loaders].sort(sortFn);
}, [cfMeta.loaders]);
// 表单项实时校验
const { Provider, results } = useFieldValidations<Config>(form, STATIC_DEFS, LIST_DEFS);
@ -676,7 +708,38 @@ function ConfigModalForm({
mode="multiple"
placeholder="选择加载器…"
loading={mrLoaders.loading}
options={mrLoaders.options}
options={sortedMrOptions}
optionRender={(option) => {
const meta = getModLoaderMetadata(option.label as string);
return (
<span style={{ display: "flex", alignItems: "center", gap: token.marginXS }}>
{meta && <LoaderIcon meta={meta} size={token.fontSize} />}
<span style={{ color: meta?.color }}>
{meta?.name ?? option.label}
</span>
</span>
);
}}
tagRender={(props) => {
const { label, closable, onClose } = props;
const meta = getModLoaderMetadata(label as string);
const onPreventMouseDown = (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
};
return (
<Tag
closable={closable}
onClose={onClose}
onMouseDown={onPreventMouseDown}
color={meta?.color}
icon={meta ? <LoaderIcon meta={meta} size={token.fontSizeSM} /> : undefined}
style={{ marginInlineEnd: token.marginXXS, fontWeight: 500 }}
>
{meta?.name ?? label}
</Tag>
);
}}
notFoundContent={
mrLoaders.loading ? "加载中…" : "无可用选项"
}
@ -817,10 +880,41 @@ function ConfigModalForm({
mode="multiple"
placeholder="选择加载器…"
loading={cfMeta.loading}
options={cfMeta.loaders.map((l) => ({
options={sortedCfOptions.map((l) => ({
label: l,
value: l,
}))}
optionRender={(option) => {
const meta = getModLoaderMetadata(option.label as string);
return (
<span style={{ display: "flex", alignItems: "center", gap: token.marginXS }}>
{meta && <LoaderIcon meta={meta} size={token.fontSize} />}
<span style={{ color: meta?.color }}>
{meta?.name ?? option.label}
</span>
</span>
);
}}
tagRender={(props) => {
const { label, closable, onClose } = props;
const meta = getModLoaderMetadata(label as string);
const onPreventMouseDown = (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
};
return (
<Tag
closable={closable}
onClose={onClose}
onMouseDown={onPreventMouseDown}
color={meta?.color}
icon={meta ? <LoaderIcon meta={meta} size={token.fontSizeSM} /> : undefined}
style={{ marginInlineEnd: token.marginXXS, fontWeight: 500 }}
>
{meta?.name ?? label}
</Tag>
);
}}
notFoundContent={
cfMeta.loading ? "加载中…" : "无可用选项"
}

View File

@ -0,0 +1,94 @@
import Icon from "@ant-design/icons";
import { GetProps } from "antd";
import React from "react";
export type ModLoaderMetadataPart = {
icon: React.ReactElement<React.SVGProps<SVGSVGElement>>,
name: string,
/** 加载器别名 */
alias?: string[],
color: string,
};
export type ModLoaderMetadata = ModLoaderMetadataPart & {
iconComponent: React.FC<Partial<GetProps<typeof Icon>>>,
};
function preprocessMetadata(metadata: ModLoaderMetadataPart[]): ModLoaderMetadata[] {
return metadata.map(meta => ({
...meta,
iconComponent: (props) => (
<Icon component={
(svgProps: React.SVGProps<SVGSVGElement>) => {
const { children, ...rest } = svgProps;
return React.cloneElement(meta.icon, rest);
}
} {...props} />
)
}));
}
export const MOD_LOADERS_METADATA: ModLoaderMetadata[] = preprocessMetadata([
{
icon: (
<svg xmlns="http://www.w3.org/2000/svg" xmlSpace="preserve" fillRule="evenodd" strokeLinecap="round" strokeLinejoin="round" clipRule="evenodd" viewBox="0 0 24 24">
<path fill="none" d="M0 0h24v24H0z"/>
<path fill="none" stroke="currentColor" strokeWidth="23" d="m820 761-85.6-87.6c-4.6-4.7-10.4-9.6-25.9 1-19.9 13.6-8.4 21.9-5.2 25.4 8.2 9 84.1 89 97.2 104 2.5 2.8-20.3-22.5-6.5-39.7 5.4-7 18-12 26-3 6.5 7.3 10.7 18-3.4 29.7-24.7 20.4-102 82.4-127 103-12.5 10.3-28.5 2.3-35.8-6-7.5-8.9-30.6-34.6-51.3-58.2-5.5-6.3-4.1-19.6 2.3-25 35-30.3 91.9-73.8 111.9-90.8" transform="matrix(.08671 0 0 .0867 -49.8 -56)"/>
</svg>
),
name: "Fabric",
color: "#8a7b71"
},
{
icon: (
<svg xmlSpace="preserve" fillRule="evenodd" strokeLinecap="round" strokeLinejoin="round" strokeMiterlimit="1.5" clipRule="evenodd" viewBox="0 0 24 24">
<path fill="none" d="M0 0h24v24H0z"></path>
<path fill="none" stroke="currentColor" strokeWidth="2" d="M2 7.5h8v-2h12v2s-7 3.4-7 6 3.1 3.1 3.1 3.1l.9 3.9H5l1-4.1s3.8.1 4-2.9c.2-2.7-6.5-.7-8-6Z"></path>
</svg>
),
name: "Forge",
color: "#5b6197"
},
{
icon: (
<svg enableBackground="new 0 0 24 24" version="1.1" viewBox="0 0 24 24" xmlSpace="preserve" xmlns="http://www.w3.org/2000/svg">
<g fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2">
<path d="m12 19.2v2m0-2v2"/>
<path d="m8.4 1.3c0.5 1.5 0.7 3 0.1 4.6-0.2 0.5-0.9 1.5-1.6 1.5m8.7-6.1c-0.5 1.5-0.7 3-0.1 4.6 0.2 0.6 0.9 1.5 1.6 1.5"/>
<path d="m3.6 15.8h-1.7m18.5 0h1.7"/>
<path d="m3.2 12.1h-1.7m19.3 0h1.8"/>
<path d="m8.1 12.7v1.6m7.8-1.6v1.6"/>
<path d="m10.8 18h1.2m0 1.2-1.2-1.2m2.4 0h-1.2m0 1.2 1.2-1.2"/>
<path d="m4 9.7c-0.5 1.2-0.8 2.4-0.8 3.7 0 3.1 2.9 6.3 5.3 8.2 0.9 0.7 2.2 1.1 3.4 1.1m0.1-17.8c-1.1 0-2.1 0.2-3.2 0.7m11.2 4.1c0.5 1.2 0.8 2.4 0.8 3.7 0 3.1-2.9 6.3-5.3 8.2-0.9 0.7-2.2 1.1-3.4 1.1m-0.1-17.8c1.1 0 2.1 0.2 3.2 0.7"/>
<path d="m4 9.7c-0.2-1.8-0.3-3.7 0.5-5.5s2.2-2.6 3.9-3m11.6 8.5c0.2-1.9 0.3-3.7-0.5-5.5s-2.2-2.6-3.9-3"/>
<path d="m12 21.2-2.4 0.4m2.4-0.4 2.4 0.4"/>
</g>
</svg>
),
name: "NeoForge",
color: "#dc895c"
},
{
icon: (
<svg xmlnsXlink="http://www.w3.org/1999/xlink" xmlSpace="preserve" fillRule="evenodd" strokeLinecap="round" strokeLinejoin="round" strokeMiterlimit="2" clipRule="evenodd" viewBox="0 0 24 24">
<defs>
<path id="quilt" fill="none" stroke="currentColor" strokeWidth="65.6" d="M442.5 233.9c0-6.4-5.2-11.6-11.6-11.6h-197c-6.4 0-11.6 5.2-11.6 11.6v197c0 6.4 5.2 11.6 11.6 11.6h197c6.4 0 11.6-5.2 11.6-11.7v-197Z"></path>
</defs>
<path fill="none" d="M0 0h24v24H0z"></path>
<use xlinkHref="#quilt" strokeWidth="65.6" transform="matrix(.03053 0 0 .03046 -3.2 -3.2)"></use>
<use xlinkHref="#quilt" strokeWidth="65.6" transform="matrix(.03053 0 0 .03046 -3.2 7)"></use>
<use xlinkHref="#quilt" strokeWidth="65.6" transform="matrix(.03053 0 0 .03046 6.9 -3.2)"></use>
<path fill="none" stroke="currentColor" strokeWidth="70.4" d="M442.5 234.8c0-7-5.6-12.5-12.5-12.5H234.7c-6.8 0-12.4 5.6-12.4 12.5V430c0 6.9 5.6 12.5 12.4 12.5H430c6.9 0 12.5-5.6 12.5-12.5V234.8Z" transform="rotate(45 3.5 24) scale(.02843 .02835)"></path>
</svg>
),
name: "Quilt",
color: "#8b61b4"
},
]);
export function getModLoaderMetadata(name: string): ModLoaderMetadata | undefined {
return MOD_LOADERS_METADATA.find(loader =>
loader.name.toLowerCase() === name.toLowerCase() ||
(loader.alias && loader.alias.some(alias => alias.toLowerCase() === name.toLowerCase()))
);
}