From d258f775ca978d8767df71d38d222a5ed3c64c37 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Wed, 21 Jan 2026 13:29:11 +0000 Subject: [PATCH 1/6] Added: Create new mod extension for empty user-generated mods Integrates the 'Add custom mod button' functionality as a built-in extension. Users can now create empty mod entries directly in Vortex to manually add files to. - Adds 'Create new mod' button to mod list toolbar - Shows dialog to enter mod name - Creates mod folder in staging directory - Registers mod with default attributes (author, version, etc.) - Shows success notification with 'Open Folder' action --- src/extensions/add_new_mod/index.ts | 130 ++++++++++++++++++++++++++++ src/extensions/index.ts | 1 + src/util/ExtensionManager.ts | 1 + 3 files changed, 132 insertions(+) create mode 100644 src/extensions/add_new_mod/index.ts diff --git a/src/extensions/add_new_mod/index.ts b/src/extensions/add_new_mod/index.ts new file mode 100644 index 0000000000..f762658e8d --- /dev/null +++ b/src/extensions/add_new_mod/index.ts @@ -0,0 +1,130 @@ +import type { + IExtensionApi, + IExtensionContext, +} from "../../types/IExtensionContext"; +import type { IDialogResult } from "../../types/IDialog"; +import type { IMod } from "../mod_management/types/IMod"; + +import { addMod } from "../mod_management/actions/mods"; +import * as fs from "../../util/fs"; +import { log } from "../../util/log"; +import opn from "../../util/opn"; +import { activeGameId, installPath } from "../../util/selectors"; + +import * as path from "path"; + +/** + * Entry point for the extension. + * Registers the "Create new mod" action in the mod list toolbar. + */ +function init(context: IExtensionContext): boolean { + context.registerAction( + "mod-icons", + 50, + "add", + {}, + "Create new mod", + () => { + promptCreateMod(context.api); + }, + () => true, + ); + + return true; +} + +async function promptCreateMod(api: IExtensionApi): Promise { + try { + const result = await api.showDialog?.( + "question", + "Create new mod", + { + text: "To create a new, empty mod enter the name of the mod below.", + input: [ + { + id: "modName", + type: "text", + label: "Mod Name", + placeholder: "Enter a name for the new mod", + }, + ], + }, + [{ label: "Cancel" }, { label: "Create Mod", default: true }], + ); + + if (result?.action === "Cancel" || result === undefined) { + return; + } + + if (!result.input.modName) { + api.showErrorNotification?.( + "Missing Mod Name", + "Please enter a name to create a new mod", + ); + return; + } + + await createNewMod(api, result.input.modName); + } catch (err) { + log("error", "Failed to show create mod dialog", err); + } +} + +async function createNewMod(api: IExtensionApi, name: string): Promise { + const id = `custom-mod-${Date.now()}`; + const state = api.getState(); + const gameId = activeGameId(state); + const stagingPath = installPath(state); + + if (stagingPath === undefined) { + api.showErrorNotification?.( + "Cannot create mod", + "No game is currently being managed", + ); + return; + } + + const modInstallPath = path.join(stagingPath, id); + + const newMod: IMod = { + id, + state: "installed", + type: "", + installationPath: id, + attributes: { + name, + author: "Me", + installTime: new Date().toISOString(), + version: "1.0.0", + notes: "Created with Create New Mod", + source: "user-generated", + }, + }; + + try { + await fs.ensureDirAsync(modInstallPath); + api.store?.dispatch(addMod(gameId, newMod)); + + api.sendNotification?.({ + id: "mod-created", + type: "success", + title: "Mod Created", + message: name, + displayMS: 10000, + actions: [ + { + title: "Open Folder", + action: (dismiss) => { + opn(modInstallPath).catch(() => undefined); + dismiss(); + }, + }, + ], + }); + } catch (err) { + log("error", "Failed to create new mod", err); + api.showErrorNotification?.("Failed to create mod", err); + } +} + +export default init; diff --git a/src/extensions/index.ts b/src/extensions/index.ts index 07bdfe4bf5..ab5561d1bf 100644 --- a/src/extensions/index.ts +++ b/src/extensions/index.ts @@ -2,6 +2,7 @@ // on the extensions and re-compiles them properly. They are completely // removed during compilation import {} from "./about_dialog"; +import {} from "./add_new_mod"; import {} from "./analytics"; import {} from "./announcement_dashlet"; import {} from "./browser"; diff --git a/src/util/ExtensionManager.ts b/src/util/ExtensionManager.ts index 3317b47de9..add4bf5cee 100644 --- a/src/util/ExtensionManager.ts +++ b/src/util/ExtensionManager.ts @@ -3037,6 +3037,7 @@ class ExtensionManager { "mod_load_order", "file_based_loadorder", "mod_management", + "add_new_mod", "category_management", "collections_integration", "profile_management", From 1265cc735bcf6accdef8ec32f16da048879b90e5 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Wed, 21 Jan 2026 14:08:03 +0000 Subject: [PATCH 2/6] Added: Headless UI Modal component infrastructure Cherry-picked Modal component from 18917-health-check-extension branch. - Added @headlessui/react dependency for accessible dialog primitives - Created Modal, ModalWrapper, ModalPanel components with Tailwind styling - Exported Modal from tailwind components index --- app/package.json | 1 + app/yarn.lock | 47 ++++++++++- package.json | 1 + src/tailwind/components/index.ts | 1 + src/tailwind/components/modal/Modal.tsx | 104 ++++++++++++++++++++++++ src/tailwind/components/modal/index.ts | 1 + yarn.lock | 58 +++++++++++-- 7 files changed, 204 insertions(+), 9 deletions(-) create mode 100644 src/tailwind/components/modal/Modal.tsx create mode 100644 src/tailwind/components/modal/index.ts diff --git a/app/package.json b/app/package.json index 5d2f979bab..2971aad3c1 100644 --- a/app/package.json +++ b/app/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@electron/remote": "^2.0.12", + "@headlessui/react": "^1.7.19", "@mdi/js": "6.6.96", "@msgpack/msgpack": "^2.7.0", "@nexusmods/nexus-api": "Nexus-Mods/node-nexus-api", diff --git a/app/yarn.lock b/app/yarn.lock index 57553500fc..bc5e14cab0 100644 --- a/app/yarn.lock +++ b/app/yarn.lock @@ -45,6 +45,14 @@ dependencies: tslib "^2.4.0" +"@headlessui/react@^1.7.19": + version "1.7.19" + resolved "https://registry.yarnpkg.com/@headlessui/react/-/react-1.7.19.tgz#91c78cf5fcb254f4a0ebe96936d48421caf75f40" + integrity sha512-Ll+8q3OlMJfJbAKM/+/Y2q6PPYbryqNTXDbryx7SXLIDamkF6iQFbriYHga0dY44PvDhvvBWCx1Xj4U5+G4hOw== + dependencies: + "@tanstack/react-virtual" "^3.0.0-beta.60" + client-only "^0.0.1" + "@hypnosphi/create-react-context@^0.3.1": version "0.3.1" resolved "https://registry.yarnpkg.com/@hypnosphi/create-react-context/-/create-react-context-0.3.1.tgz#f8bfebdc7665f5d426cba3753e0e9c7d3154d7c6" @@ -417,6 +425,18 @@ "@tailwindcss/oxide-win32-arm64-msvc" "4.1.15" "@tailwindcss/oxide-win32-x64-msvc" "4.1.15" +"@tanstack/react-virtual@^3.0.0-beta.60": + version "3.13.18" + resolved "https://registry.yarnpkg.com/@tanstack/react-virtual/-/react-virtual-3.13.18.tgz#9124ee9f08b2ca3c67ffd508dae3732328983362" + integrity sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A== + dependencies: + "@tanstack/virtual-core" "3.13.18" + +"@tanstack/virtual-core@3.13.18": + version "3.13.18" + resolved "https://registry.yarnpkg.com/@tanstack/virtual-core/-/virtual-core-3.13.18.tgz#586e3c1fe08547ee6abf87e8fb7c99087b9c47ff" + integrity sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg== + "@tybys/wasm-util@^0.10.1": version "0.10.1" resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz#ecddd3205cf1e2d5274649ff0eedd2991ed7f414" @@ -941,6 +961,11 @@ cli-truncate@^2.1.0: slice-ansi "^3.0.0" string-width "^4.2.0" +client-only@^0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/client-only/-/client-only-0.0.1.tgz#38bba5d403c41ab150bff64a95c85013cf73bca1" + integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA== + clsx@^1.0.4: version "1.2.1" resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.2.1.tgz#0ddc4a20a549b59c93a4116bb26f5294ca17dc12" @@ -4348,8 +4373,7 @@ string-template@^1.0.0: resolved "https://registry.yarnpkg.com/string-template/-/string-template-1.0.0.tgz#9e9f2233dc00f218718ec379a28a5673ecca8b96" integrity sha512-SLqR3GBUXuoPP5MmYtD7ompvXiG87QjT6lzOszyXjTM86Uu7At7vNnt2xgyTLq5o9T4IxTYFyGxcULqpsmsfdg== -"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0: - name string-width-cjs +"string-width-cjs@npm:string-width@^4.2.0": version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -4367,6 +4391,15 @@ string-width@^1.0.1: is-fullwidth-code-point "^1.0.0" strip-ansi "^3.0.0" +"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + string-width@^5.0.1, string-width@^5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" @@ -4400,8 +4433,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: - name strip-ansi-cjs +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -4415,6 +4447,13 @@ strip-ansi@^3.0.0, strip-ansi@^3.0.1: dependencies: ansi-regex "^2.0.0" +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-ansi@^7.0.1: version "7.1.2" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.2.tgz#132875abde678c7ea8d691533f2e7e22bb744dba" diff --git a/package.json b/package.json index df905cccac..fce03bcd2d 100644 --- a/package.json +++ b/package.json @@ -250,6 +250,7 @@ }, "dependencies": { "@electron/remote": "^2.0.12", + "@headlessui/react": "^1.7.19", "@mdi/js": "6.6.96", "@msgpack/msgpack": "^2.7.0", "@nexusmods/nexus-api": "Nexus-Mods/node-nexus-api", diff --git a/src/tailwind/components/index.ts b/src/tailwind/components/index.ts index 2d31980d6e..5c972ab05a 100644 --- a/src/tailwind/components/index.ts +++ b/src/tailwind/components/index.ts @@ -2,3 +2,4 @@ // Currently only demo components export { TailwindTest } from "./TailwindTest"; +export { Modal, ModalWrapper, ModalPanel } from "./modal"; diff --git a/src/tailwind/components/modal/Modal.tsx b/src/tailwind/components/modal/Modal.tsx new file mode 100644 index 0000000000..10687ab25c --- /dev/null +++ b/src/tailwind/components/modal/Modal.tsx @@ -0,0 +1,104 @@ +import { Dialog } from "@headlessui/react"; +import React, { type PropsWithChildren, type RefObject } from "react"; +import { joinClasses } from "../next/utils"; +import { Typography } from "../next/typography"; +import { Icon } from "../next/icon"; + +type ModalSize = "sm" | "md" | "lg" | "xl"; + +const modalSize: { [key in ModalSize]: string } = { + sm: "max-w-xs", + md: "max-w-md", + lg: "max-w-2xl", + xl: "max-w-5xl", +}; + +type ModalProps = PropsWithChildren<{ + className?: string; + initialFocusRef?: RefObject; + isOpen: boolean; + onClose: () => void; +}>; + +export const ModalWrapper = ({ + children, + className, + initialFocusRef, + isOpen = false, + onClose, +}: ModalProps) => ( + + + + {children} + +); + +type ModalPanelProps = { + className?: string; + size?: ModalSize; + showCloseButton?: boolean; + title?: string; + onClose?: () => void; +}; + +export const ModalPanel = ({ + className, + children, + size = "md", + showCloseButton = true, + title, + onClose, +}: PropsWithChildren) => ( + + {!!title && ( + + {title} + + )} + + {showCloseButton && ( + + )} + + {children} + +); + +export const Modal = ({ + children, + size = "md", + title, + onClose, + ...props +}: ModalProps & ModalPanelProps) => ( + + + {children} + + +); diff --git a/src/tailwind/components/modal/index.ts b/src/tailwind/components/modal/index.ts new file mode 100644 index 0000000000..175b83afcc --- /dev/null +++ b/src/tailwind/components/modal/index.ts @@ -0,0 +1 @@ +export { Modal, ModalWrapper, ModalPanel } from "./Modal"; diff --git a/yarn.lock b/yarn.lock index 26c67f89fe..18a54ef99b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1847,6 +1847,14 @@ "@eslint/core" "^0.17.0" levn "^0.4.1" +"@headlessui/react@^1.7.19": + version "1.7.19" + resolved "https://registry.yarnpkg.com/@headlessui/react/-/react-1.7.19.tgz#91c78cf5fcb254f4a0ebe96936d48421caf75f40" + integrity sha512-Ll+8q3OlMJfJbAKM/+/Y2q6PPYbryqNTXDbryx7SXLIDamkF6iQFbriYHga0dY44PvDhvvBWCx1Xj4U5+G4hOw== + dependencies: + "@tanstack/react-virtual" "^3.0.0-beta.60" + client-only "^0.0.1" + "@humanfs/core@^0.19.1": version "0.19.1" resolved "https://registry.yarnpkg.com/@humanfs/core/-/core-0.19.1.tgz#17c55ca7d426733fe3c561906b8173c336b40a77" @@ -2783,6 +2791,18 @@ "@tailwindcss/oxide-win32-arm64-msvc" "4.1.15" "@tailwindcss/oxide-win32-x64-msvc" "4.1.15" +"@tanstack/react-virtual@^3.0.0-beta.60": + version "3.13.18" + resolved "https://registry.yarnpkg.com/@tanstack/react-virtual/-/react-virtual-3.13.18.tgz#9124ee9f08b2ca3c67ffd508dae3732328983362" + integrity sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A== + dependencies: + "@tanstack/virtual-core" "3.13.18" + +"@tanstack/virtual-core@3.13.18": + version "3.13.18" + resolved "https://registry.yarnpkg.com/@tanstack/virtual-core/-/virtual-core-3.13.18.tgz#586e3c1fe08547ee6abf87e8fb7c99087b9c47ff" + integrity sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg== + "@tootallnate/once@2": version "2.0.0" resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf" @@ -5236,6 +5256,11 @@ cli-truncate@^5.0.0: slice-ansi "^7.1.0" string-width "^8.0.0" +client-only@^0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/client-only/-/client-only-0.0.1.tgz#38bba5d403c41ab150bff64a95c85013cf73bca1" + integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA== + cliui@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/cliui/-/cliui-5.0.0.tgz#deefcfdb2e800784aa34f46fa08e06851c7bbbc5" @@ -12690,8 +12715,7 @@ string-ts@^2.2.1: resolved "https://registry.yarnpkg.com/string-ts/-/string-ts-2.2.1.tgz#9cf9a93d210f778080a9db86ca37cba37f55e44c" integrity sha512-Q2u0gko67PLLhbte5HmPfdOjNvUKbKQM+mCNQae6jE91DmoFHY6HH9GcdqCeNx87DZ2KKjiFxmA0R/42OneGWw== -"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3, "string-width@npm:string-width@4.2.3": - name string-width-cjs +"string-width-cjs@npm:string-width@^4.2.0": version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -12709,6 +12733,15 @@ string-width@^1.0.1: is-fullwidth-code-point "^1.0.0" strip-ansi "^3.0.0" +"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3, "string-width@npm:string-width@4.2.3": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + string-width@^3.0.0, string-width@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/string-width/-/string-width-3.1.0.tgz#22767be21b62af1081574306f69ac51b62203961" @@ -12805,7 +12838,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1, "strip-ansi@npm:strip-ansi@6.0.1": +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -12833,6 +12866,13 @@ strip-ansi@^5.0.0, strip-ansi@^5.1.0, strip-ansi@^5.2.0: dependencies: ansi-regex "^4.1.0" +strip-ansi@^6.0.0, strip-ansi@^6.0.1, "strip-ansi@npm:strip-ansi@6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-ansi@^7.0.1, strip-ansi@^7.1.0: version "7.1.2" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.2.tgz#132875abde678c7ea8d691533f2e7e22bb744dba" @@ -14209,8 +14249,7 @@ wordwrap@^1.0.0: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: - name wrap-ansi-cjs +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -14228,6 +14267,15 @@ wrap-ansi@^5.1.0: string-width "^3.0.0" strip-ansi "^5.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" From 3bfe93dcc89790ecf0f4fd49c03f6471e24dbff3 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Wed, 21 Jan 2026 14:08:08 +0000 Subject: [PATCH 3/6] Added: Redux infrastructure for add_new_mod dialog visibility - Created showAddModDialog action to control dialog state - Created sessionReducer to store showDialog boolean in session.addNewMod --- src/extensions/add_new_mod/actions/session.ts | 9 +++++++++ src/extensions/add_new_mod/reducers/session.ts | 17 +++++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 src/extensions/add_new_mod/actions/session.ts create mode 100644 src/extensions/add_new_mod/reducers/session.ts diff --git a/src/extensions/add_new_mod/actions/session.ts b/src/extensions/add_new_mod/actions/session.ts new file mode 100644 index 0000000000..d3b38acd8b --- /dev/null +++ b/src/extensions/add_new_mod/actions/session.ts @@ -0,0 +1,9 @@ +import safeCreateAction from "../../../actions/safeCreateAction"; + +/** + * Action to show or hide the Add New Mod dialog + */ +export const showAddModDialog = safeCreateAction( + "SHOW_ADD_MOD_DIALOG", + (show: boolean) => show, +); diff --git a/src/extensions/add_new_mod/reducers/session.ts b/src/extensions/add_new_mod/reducers/session.ts new file mode 100644 index 0000000000..d39e8354a1 --- /dev/null +++ b/src/extensions/add_new_mod/reducers/session.ts @@ -0,0 +1,17 @@ +import type { IReducerSpec } from "../../../types/IExtensionContext"; +import { setSafe } from "../../../util/storeHelper"; + +import * as actions from "../actions/session"; + +/** + * Reducer for the add_new_mod extension session state + */ +export const sessionReducer: IReducerSpec = { + reducers: { + [actions.showAddModDialog as any]: (state, payload) => + setSafe(state, ["showDialog"], payload), + }, + defaults: { + showDialog: false, + }, +}; From 2fac4477b8865c4fd4bd4cd6c241fb26c0aac6a5 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Wed, 21 Jan 2026 14:08:13 +0000 Subject: [PATCH 4/6] Added: Styled AddModModal component with Tailwind/Headless UI - Created AddModModal with modern functional component pattern - Uses Modal, Button, Input, Typography from Tailwind components - Created AddModDialog as Redux-connected wrapper for registerDialog - Extracted createNewMod utility for mod creation logic --- .../add_new_mod/components/AddModDialog.tsx | 47 ++++++++ .../add_new_mod/components/AddModModal.tsx | 110 ++++++++++++++++++ src/extensions/add_new_mod/util/createMod.ts | 75 ++++++++++++ 3 files changed, 232 insertions(+) create mode 100644 src/extensions/add_new_mod/components/AddModDialog.tsx create mode 100644 src/extensions/add_new_mod/components/AddModModal.tsx create mode 100644 src/extensions/add_new_mod/util/createMod.ts diff --git a/src/extensions/add_new_mod/components/AddModDialog.tsx b/src/extensions/add_new_mod/components/AddModDialog.tsx new file mode 100644 index 0000000000..9ab0207d90 --- /dev/null +++ b/src/extensions/add_new_mod/components/AddModDialog.tsx @@ -0,0 +1,47 @@ +import React, { useCallback } from "react"; +import { useSelector, useDispatch } from "react-redux"; + +import { AddModModal } from "./AddModModal"; +import { showAddModDialog } from "../actions/session"; +import { createNewMod } from "../util/createMod"; +import type { IExtensionApi } from "../../../types/IExtensionContext"; + +interface IConnectedState { + showDialog: boolean; +} + +interface AddModDialogProps { + api: IExtensionApi; +} + +/** + * Redux-connected wrapper for AddModModal + * Used with registerDialog to integrate with Vortex's dialog system + */ +const AddModDialog = ({ api }: AddModDialogProps) => { + const dispatch = useDispatch(); + const showDialog = useSelector( + (state: any) => state.session?.addNewMod?.showDialog ?? false, + ); + + const handleClose = useCallback(() => { + dispatch(showAddModDialog(false)); + }, [dispatch]); + + const handleConfirm = useCallback( + async (modName: string) => { + await createNewMod(api, modName); + }, + [api], + ); + + return ( + + ); +}; + +export default AddModDialog; diff --git a/src/extensions/add_new_mod/components/AddModModal.tsx b/src/extensions/add_new_mod/components/AddModModal.tsx new file mode 100644 index 0000000000..ece3aabf01 --- /dev/null +++ b/src/extensions/add_new_mod/components/AddModModal.tsx @@ -0,0 +1,110 @@ +import React, { useState, useCallback, useRef, useEffect } from "react"; +import { useTranslation } from "react-i18next"; + +import { Modal } from "../../../tailwind/components/modal"; +import { Typography } from "../../../tailwind/components/next/typography"; +import { Button } from "../../../tailwind/components/next/button"; +import { Input } from "../../../tailwind/components/next/form/input"; + +interface AddModModalProps { + isOpen: boolean; + onClose: () => void; + onConfirm: (modName: string) => void; +} + +/** + * Modal dialog for creating a new empty mod + * Uses the modern Tailwind/Headless UI styling + */ +export const AddModModal = ({ + isOpen, + onClose, + onConfirm, +}: AddModModalProps) => { + const { t } = useTranslation(["common"]); + const [modName, setModName] = useState(""); + const inputRef = useRef(null); + + // Reset mod name when dialog opens + useEffect(() => { + if (isOpen) { + setModName(""); + } + }, [isOpen]); + + const handleConfirm = useCallback(() => { + const trimmedName = modName.trim(); + if (trimmedName) { + onConfirm(trimmedName); + setModName(""); + onClose(); + } + }, [modName, onConfirm, onClose]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" && modName.trim()) { + handleConfirm(); + } + }, + [handleConfirm, modName], + ); + + const handleChange = useCallback((e: React.ChangeEvent) => { + setModName(e.target.value); + }, []); + + return ( + + + {t( + "Enter a name for your new mod. This will create an empty mod folder in your staging directory where you can add files manually.", + )} + + + + +
+ + +
+
+ ); +}; + +export default AddModModal; diff --git a/src/extensions/add_new_mod/util/createMod.ts b/src/extensions/add_new_mod/util/createMod.ts new file mode 100644 index 0000000000..fca386f514 --- /dev/null +++ b/src/extensions/add_new_mod/util/createMod.ts @@ -0,0 +1,75 @@ +import type { IExtensionApi } from "../../../types/IExtensionContext"; +import type { IMod } from "../../mod_management/types/IMod"; + +import { addMod } from "../../mod_management/actions/mods"; +import * as fs from "../../../util/fs"; +import { log } from "../../../util/log"; +import opn from "../../../util/opn"; +import { activeGameId, installPath } from "../../../util/selectors"; + +import * as path from "path"; + +/** + * Creates a new empty mod with the given name + * @param api The extension API + * @param name The name for the new mod + */ +export async function createNewMod( + api: IExtensionApi, + name: string, +): Promise { + const id = `custom-mod-${Date.now()}`; + const state = api.getState(); + const gameId = activeGameId(state); + const stagingPath = installPath(state); + + if (stagingPath === undefined) { + api.showErrorNotification?.( + "Cannot create mod", + "No game is currently being managed", + ); + return; + } + + const modInstallPath = path.join(stagingPath, id); + + const newMod: IMod = { + id, + state: "installed", + type: "", + installationPath: id, + attributes: { + name, + author: "Me", + installTime: new Date().toISOString(), + version: "1.0.0", + notes: "Created with Create New Mod", + source: "user-generated", + }, + }; + + try { + await fs.ensureDirAsync(modInstallPath); + api.store?.dispatch(addMod(gameId, newMod)); + + api.sendNotification?.({ + id: "mod-created", + type: "success", + title: "Mod Created", + message: name, + displayMS: 10000, + actions: [ + { + title: "Open Folder", + action: (dismiss) => { + opn(modInstallPath).catch(() => undefined); + dismiss(); + }, + }, + ], + }); + } catch (err) { + log("error", "Failed to create new mod", err); + api.showErrorNotification?.("Failed to create mod", err); + } +} From f54e02aa6f19ef7fbcc08388aa23934603c15a7e Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Wed, 21 Jan 2026 14:08:19 +0000 Subject: [PATCH 5/6] Refactored: add_new_mod extension to use styled modal dialog - Replaced api.showDialog() with registerDialog pattern - Registers sessionReducer for dialog state management - Button now dispatches showAddModDialog action - Added i18n translations for dialog strings --- locales/en/common.json | 8 +- src/extensions/add_new_mod/index.ts | 127 ++++------------------------ 2 files changed, 24 insertions(+), 111 deletions(-) diff --git a/locales/en/common.json b/locales/en/common.json index 6c6bcf007b..2ed7ecf798 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -25,5 +25,11 @@ "The mod you {{enabled}} depends on other mods, do you want to {{enable}} those as well?_plural": "The mods you {{enabled}} depend on other mods, do you want to {{enable}} those as well?", "actions": { "search": "Search" - } + }, + "Create New Mod": "Create New Mod", + "Enter a name for your new mod. This will create an empty mod folder in your staging directory where you can add files manually.": "Enter a name for your new mod. This will create an empty mod folder in your staging directory where you can add files manually.", + "Mod Name": "Mod Name", + "My Custom Mod": "My Custom Mod", + "Cancel": "Cancel", + "Create Mod": "Create Mod" } diff --git a/src/extensions/add_new_mod/index.ts b/src/extensions/add_new_mod/index.ts index f762658e8d..0c977ec2de 100644 --- a/src/extensions/add_new_mod/index.ts +++ b/src/extensions/add_new_mod/index.ts @@ -1,23 +1,24 @@ -import type { - IExtensionApi, - IExtensionContext, -} from "../../types/IExtensionContext"; -import type { IDialogResult } from "../../types/IDialog"; -import type { IMod } from "../mod_management/types/IMod"; +import type { IExtensionContext } from "../../types/IExtensionContext"; -import { addMod } from "../mod_management/actions/mods"; -import * as fs from "../../util/fs"; -import { log } from "../../util/log"; -import opn from "../../util/opn"; -import { activeGameId, installPath } from "../../util/selectors"; - -import * as path from "path"; +import AddModDialog from "./components/AddModDialog"; +import { showAddModDialog } from "./actions/session"; +import { sessionReducer } from "./reducers/session"; /** - * Entry point for the extension. - * Registers the "Create new mod" action in the mod list toolbar. + * Entry point for the add_new_mod extension. + * Registers a "Create new mod" action that opens a styled modal dialog + * allowing users to create empty mods for manual file management. */ function init(context: IExtensionContext): boolean { + // Register the session reducer for dialog visibility state + context.registerReducer(["session", "addNewMod"], sessionReducer); + + // Register the dialog component + context.registerDialog("add-new-mod", AddModDialog, () => ({ + api: context.api, + })); + + // Register the toolbar action context.registerAction( "mod-icons", 50, @@ -25,7 +26,7 @@ function init(context: IExtensionContext): boolean { {}, "Create new mod", () => { - promptCreateMod(context.api); + context.api.store?.dispatch(showAddModDialog(true)); }, () => true, ); @@ -33,98 +34,4 @@ function init(context: IExtensionContext): boolean { return true; } -async function promptCreateMod(api: IExtensionApi): Promise { - try { - const result = await api.showDialog?.( - "question", - "Create new mod", - { - text: "To create a new, empty mod enter the name of the mod below.", - input: [ - { - id: "modName", - type: "text", - label: "Mod Name", - placeholder: "Enter a name for the new mod", - }, - ], - }, - [{ label: "Cancel" }, { label: "Create Mod", default: true }], - ); - - if (result?.action === "Cancel" || result === undefined) { - return; - } - - if (!result.input.modName) { - api.showErrorNotification?.( - "Missing Mod Name", - "Please enter a name to create a new mod", - ); - return; - } - - await createNewMod(api, result.input.modName); - } catch (err) { - log("error", "Failed to show create mod dialog", err); - } -} - -async function createNewMod(api: IExtensionApi, name: string): Promise { - const id = `custom-mod-${Date.now()}`; - const state = api.getState(); - const gameId = activeGameId(state); - const stagingPath = installPath(state); - - if (stagingPath === undefined) { - api.showErrorNotification?.( - "Cannot create mod", - "No game is currently being managed", - ); - return; - } - - const modInstallPath = path.join(stagingPath, id); - - const newMod: IMod = { - id, - state: "installed", - type: "", - installationPath: id, - attributes: { - name, - author: "Me", - installTime: new Date().toISOString(), - version: "1.0.0", - notes: "Created with Create New Mod", - source: "user-generated", - }, - }; - - try { - await fs.ensureDirAsync(modInstallPath); - api.store?.dispatch(addMod(gameId, newMod)); - - api.sendNotification?.({ - id: "mod-created", - type: "success", - title: "Mod Created", - message: name, - displayMS: 10000, - actions: [ - { - title: "Open Folder", - action: (dismiss) => { - opn(modInstallPath).catch(() => undefined); - dismiss(); - }, - }, - ], - }); - } catch (err) { - log("error", "Failed to create new mod", err); - api.showErrorNotification?.("Failed to create mod", err); - } -} - export default init; From e391e1f5b35ab75b600cba58fd4b7846bd34a81d Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Wed, 21 Jan 2026 18:00:37 +0000 Subject: [PATCH 6/6] Improved: Fixed some lints --- src/extensions/add_new_mod/components/AddModDialog.tsx | 5 +---- src/extensions/add_new_mod/components/AddModModal.tsx | 2 ++ src/extensions/add_new_mod/reducers/session.ts | 3 ++- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/extensions/add_new_mod/components/AddModDialog.tsx b/src/extensions/add_new_mod/components/AddModDialog.tsx index 9ab0207d90..4549f07689 100644 --- a/src/extensions/add_new_mod/components/AddModDialog.tsx +++ b/src/extensions/add_new_mod/components/AddModDialog.tsx @@ -6,10 +6,6 @@ import { showAddModDialog } from "../actions/session"; import { createNewMod } from "../util/createMod"; import type { IExtensionApi } from "../../../types/IExtensionContext"; -interface IConnectedState { - showDialog: boolean; -} - interface AddModDialogProps { api: IExtensionApi; } @@ -21,6 +17,7 @@ interface AddModDialogProps { const AddModDialog = ({ api }: AddModDialogProps) => { const dispatch = useDispatch(); const showDialog = useSelector( + // eslint-disable-next-line @typescript-eslint/no-explicit-any (state: any) => state.session?.addNewMod?.showDialog ?? false, ); diff --git a/src/extensions/add_new_mod/components/AddModModal.tsx b/src/extensions/add_new_mod/components/AddModModal.tsx index ece3aabf01..a82963eed8 100644 --- a/src/extensions/add_new_mod/components/AddModModal.tsx +++ b/src/extensions/add_new_mod/components/AddModModal.tsx @@ -28,6 +28,8 @@ export const AddModModal = ({ // Reset mod name when dialog opens useEffect(() => { if (isOpen) { + // This is intentional - reset state when modal opens + // eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect setModName(""); } }, [isOpen]); diff --git a/src/extensions/add_new_mod/reducers/session.ts b/src/extensions/add_new_mod/reducers/session.ts index d39e8354a1..db89eaed4f 100644 --- a/src/extensions/add_new_mod/reducers/session.ts +++ b/src/extensions/add_new_mod/reducers/session.ts @@ -8,7 +8,8 @@ import * as actions from "../actions/session"; */ export const sessionReducer: IReducerSpec = { reducers: { - [actions.showAddModDialog as any]: (state, payload) => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [actions.showAddModDialog as any]: (state, payload: boolean) => setSafe(state, ["showDialog"], payload), }, defaults: {