diff --git a/apps/frontend/src/components/ui/create-project-version/stages/AddDetailsStage.vue b/apps/frontend/src/components/ui/create-project-version/stages/AddDetailsStage.vue
index e757627339..2e508b5897 100644
--- a/apps/frontend/src/components/ui/create-project-version/stages/AddDetailsStage.vue
+++ b/apps/frontend/src/components/ui/create-project-version/stages/AddDetailsStage.vue
@@ -155,8 +155,9 @@
diff --git a/apps/frontend/src/components/ui/create-project-version/stages/AddEnvironmentStage.vue b/apps/frontend/src/components/ui/create-project-version/stages/AddEnvironmentStage.vue
index ee04d8a614..f115f6c0b0 100644
--- a/apps/frontend/src/components/ui/create-project-version/stages/AddEnvironmentStage.vue
+++ b/apps/frontend/src/components/ui/create-project-version/stages/AddEnvironmentStage.vue
@@ -1,11 +1,11 @@
-
-
-
diff --git a/apps/frontend/src/pages/[type]/[id]/version/[version].vue b/apps/frontend/src/pages/[type]/[id]/version/[version].vue
index 6675d802d0..62399f4a49 100644
--- a/apps/frontend/src/pages/[type]/[id]/version/[version].vue
+++ b/apps/frontend/src/pages/[type]/[id]/version/[version].vue
@@ -560,6 +560,17 @@
{{ $formatVersion(version.game_versions) }}
+
+
Environment
+
+
+
+
+
+ {{ environment.title.defaultMessage }}
+
+
+
Downloads
{{ version.downloads }}
@@ -635,6 +646,7 @@ import {
Checkbox,
ConfirmModal,
CopyCode,
+ ENVIRONMENTS_COPY,
injectNotificationManager,
MarkdownEditor,
} from '@modrinth/ui'
@@ -817,6 +829,12 @@ export default defineNuxtComponent({
if (!version) {
version = props.versions.find((x) => x.displayUrlEnding === route.params.version)
}
+
+ const versionV3 = await useBaseFetch(
+ `project/${props.project.id}/version/${route.params.version}`,
+ { apiVersion: 3 },
+ )
+ if (versionV3) version.environment = versionV3.environment
}
if (!version) {
@@ -933,6 +951,9 @@ export default defineNuxtComponent({
(a, b) => order.indexOf(a.dependency_type) - order.indexOf(b.dependency_type),
)
},
+ environment() {
+ return ENVIRONMENTS_COPY[this.version.environment]
+ },
},
watch: {
'$route.path'() {
diff --git a/apps/frontend/src/pages/dashboard/projects.vue b/apps/frontend/src/pages/dashboard/projects.vue
index f2259ee00e..2ce08b2ea7 100644
--- a/apps/frontend/src/pages/dashboard/projects.vue
+++ b/apps/frontend/src/pages/dashboard/projects.vue
@@ -290,7 +290,7 @@
v-tooltip="'Please review environment metadata'"
:to="`/${getProjectTypeForUrl(project.project_type, project.loaders)}/${
project.slug ? project.slug : project.id
- }/settings/environment`"
+ }?showEnvironmentMigrationWarning=true`"
>
diff --git a/packages/ui/src/components/project/ProjectPageVersions.vue b/packages/ui/src/components/project/ProjectPageVersions.vue
index 8301ed5cd4..504c2103b1 100644
--- a/packages/ui/src/components/project/ProjectPageVersions.vue
+++ b/packages/ui/src/components/project/ProjectPageVersions.vue
@@ -42,7 +42,12 @@
@@ -57,6 +62,12 @@
>
Platforms
+
+ Environment
+
@@ -144,6 +155,24 @@
+
+
+
+ {{
+ ENVIRONMENTS_COPY[version.environment]?.title
+ ? formatMessage(ENVIRONMENTS_COPY[version.environment].title)
+ : ''
+ }}
+
+
= computed(
)
const selectedChannels: Ref = computed(() => versionFilters.value?.selectedChannels ?? [])
+const hasMultipleEnvironments = computed(() => {
+ const environments = new Set(props.versions.map((v) => v.environment).filter(Boolean))
+ return environments.size > 1
+})
+
const filteredVersions = computed(() => {
return props.versions.filter(
(version) =>
@@ -321,6 +357,14 @@ function updateQuery(newQueries: Record
diff --git a/packages/ui/src/components/project/ProjectSidebarCompatibility.vue b/packages/ui/src/components/project/ProjectSidebarCompatibility.vue
index 9365b15aa3..068837d725 100644
--- a/packages/ui/src/components/project/ProjectSidebarCompatibility.vue
+++ b/packages/ui/src/components/project/ProjectSidebarCompatibility.vue
@@ -21,7 +21,7 @@
:action="() => router.push(`/${project.project_type}s?g=categories:${platform}`)"
:style="`--_color: var(--color-platform-${platform})`"
>
-
+
{{ formatCategory(platform) }}
@@ -69,6 +69,7 @@
+import { CheckIcon } from '@modrinth/assets'
+import {
+ Admonition,
+ commonProjectSettingsMessages,
+ EnvironmentSelector,
+ injectModrinthClient,
+ injectNotificationManager,
+ injectProjectPageContext,
+ UnsavedChangesPopup,
+ useSavable,
+} from '@modrinth/ui'
+import { defineMessages, useVIntl } from '@vintl/vintl'
+import { computed, ref } from 'vue'
+
+const { formatMessage } = useVIntl()
+
+const { currentMember, projectV2, projectV3, refreshProject } = injectProjectPageContext()
+const { handleError } = injectNotificationManager()
+const client = injectModrinthClient()
+
+const saving = ref(false)
+
+const supportsEnvironment = computed(() =>
+ projectV3.value.project_types.some((type) => ['mod', 'modpack'].includes(type)),
+)
+
+const needsToVerify = computed(
+ () =>
+ projectV3.value.side_types_migration_review_status === 'pending' &&
+ (projectV3.value.environment?.length ?? 0) > 0 &&
+ projectV3.value.environment?.[0] !== 'unknown' &&
+ supportsEnvironment.value,
+)
+
+const hasPermission = computed(() => {
+ const EDIT_DETAILS = 1 << 2
+ return (currentMember.value?.permissions & EDIT_DETAILS) === EDIT_DETAILS
+})
+
+function getInitialEnv() {
+ return projectV3.value.environment?.length === 1 ? projectV3.value.environment[0] : undefined
+}
+
+const { saved, current, reset, save } = useSavable(
+ () => ({
+ environment: getInitialEnv(),
+ side_types_migration_review_status: projectV3.value.side_types_migration_review_status,
+ }),
+ ({ environment, side_types_migration_review_status }) => {
+ saving.value = true
+ side_types_migration_review_status = 'reviewed'
+ client.labrinth.projects_v3
+ .edit(projectV2.value.id, { environment, side_types_migration_review_status })
+ .then(() => refreshProject().then(reset))
+ .catch(handleError)
+ .finally(() => (saving.value = false))
+ },
+)
+// Set current to reviewed, which will trigger unsaved changes popup.
+// It should not be possible to save without reviewing it.
+const originalEnv = getInitialEnv()
+if (originalEnv && originalEnv !== 'unknown') {
+ current.value.side_types_migration_review_status = 'reviewed'
+}
+
+const messages = defineMessages({
+ verifyButton: {
+ id: 'project.settings.environment.verification.verify-button',
+ defaultMessage: 'Verify',
+ },
+ verifyLabel: {
+ id: 'project.settings.environment.verification.verify-text',
+ defaultMessage: `Verify that this project's environment is set correctly.`,
+ },
+ wrongProjectTypeTitle: {
+ id: 'project.settings.environment.notice.wrong-project-type.title',
+ defaultMessage: `This project type does not support environment metadata`,
+ },
+ wrongProjectTypeDescription: {
+ id: 'project.settings.environment.notice.wrong-project-type.description',
+ defaultMessage: `Only mod or modpack projects can have environment metadata.`,
+ },
+ missingEnvTitle: {
+ id: 'project.settings.environment.notice.missing-env.title',
+ defaultMessage: `Please select an environment for your project`,
+ },
+ missingEnvDescription: {
+ id: 'project.settings.environment.notice.missing-env.description',
+ defaultMessage: `Your project is missing environment metadata, please select the appropriate option below.`,
+ },
+ multipleEnvironmentsTitle: {
+ id: 'project.settings.environment.notice.multiple-environments.title',
+ defaultMessage: 'Your project has multiple environments',
+ },
+ multipleEnvironmentsDescription: {
+ id: 'project.settings.environment.notice.multiple-environments.description',
+ defaultMessage:
+ "Different versions of your project have different environments selected, so you can't edit them globally at this time.",
+ },
+ reviewOptionsTitle: {
+ id: 'project.settings.environment.notice.review-options.title',
+ defaultMessage: 'Please review the options below',
+ },
+ reviewOptionsDescription: {
+ id: 'project.settings.environment.notice.review-options.description',
+ defaultMessage:
+ "We've just overhauled the Environments system on Modrinth and new options are now available. Please ensure the correct option is selected below and then click 'Verify' when you're done!",
+ },
+})
+
+
+
+
diff --git a/packages/ui/src/components/project/settings/environment/ProjectSettingsEnvSelector.vue b/packages/ui/src/components/project/settings/environment/EnvironmentSelector.vue
similarity index 98%
rename from packages/ui/src/components/project/settings/environment/ProjectSettingsEnvSelector.vue
rename to packages/ui/src/components/project/settings/environment/EnvironmentSelector.vue
index 9de9cde182..fc661859f3 100644
--- a/packages/ui/src/components/project/settings/environment/ProjectSettingsEnvSelector.vue
+++ b/packages/ui/src/components/project/settings/environment/EnvironmentSelector.vue
@@ -33,7 +33,10 @@ const optionLabelFormat = defineMessage({
defaultMessage: '{title}: {description}',
})
-const OUTER_OPTIONS = {
+const OUTER_OPTIONS: Record<
+ string,
+ EnvironmentRadioOption & { suboptions: Record }
+> = {
client: {
title: defineMessage({
id: 'project.settings.environment.client_only.title',
@@ -125,10 +128,8 @@ const OUTER_OPTIONS = {
}),
suboptions: {},
},
-} as const satisfies Record<
- string,
- EnvironmentRadioOption & { suboptions: Record }
->
+} as const
+
type OuterOptionKey = keyof typeof OUTER_OPTIONS
type SubOptionKey = ValidKeys<(typeof OUTER_OPTIONS)[keyof typeof OUTER_OPTIONS]['suboptions']>
@@ -248,7 +249,7 @@ const simulateSave = ref(false)
:aria-label="
formatMessage(optionLabelFormat, {
title: formatMessage(title),
- description: formatMessage(description),
+ description: description ? formatMessage(description) : '',
})
"
@select="
diff --git a/packages/ui/src/components/project/settings/environment/ProjectEnvironmentModal.vue b/packages/ui/src/components/project/settings/environment/ProjectEnvironmentModal.vue
new file mode 100644
index 0000000000..789541dc36
--- /dev/null
+++ b/packages/ui/src/components/project/settings/environment/ProjectEnvironmentModal.vue
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/ui/src/components/project/settings/environment/environments.ts b/packages/ui/src/components/project/settings/environment/environments.ts
new file mode 100644
index 0000000000..6961a9f510
--- /dev/null
+++ b/packages/ui/src/components/project/settings/environment/environments.ts
@@ -0,0 +1,128 @@
+import type { Labrinth } from '@modrinth/api-client'
+import { ClientIcon, MonitorSmartphoneIcon, ServerIcon, UserIcon } from '@modrinth/assets'
+import { defineMessage, type MessageDescriptor } from '@vintl/vintl'
+import type { Component } from 'vue'
+
+export const ENVIRONMENTS_COPY: Record<
+ Labrinth.Projects.v3.Environment,
+ { title: MessageDescriptor; description: MessageDescriptor; icon?: Component }
+> = {
+ client_only: {
+ title: defineMessage({
+ id: 'project.environment.client-only.title',
+ defaultMessage: 'Client-side only',
+ }),
+ description: defineMessage({
+ id: 'project.environment.client-only.description',
+ defaultMessage:
+ 'All functionality is done client-side and is compatible with vanilla servers.',
+ }),
+ icon: ClientIcon,
+ },
+ server_only: {
+ title: defineMessage({
+ id: 'project.environment.server-only.title',
+ defaultMessage: 'Server-side only',
+ }),
+ description: defineMessage({
+ id: 'project.environment.server-only.description',
+ defaultMessage:
+ 'All functionality is done server-side and is compatible with vanilla clients.',
+ }),
+ icon: ServerIcon,
+ },
+ singleplayer_only: {
+ title: defineMessage({
+ id: 'project.environment.singleplayer-only.title',
+ defaultMessage: 'Singleplayer only',
+ }),
+ description: defineMessage({
+ id: 'project.environment.singleplayer-only.description',
+ defaultMessage:
+ 'Only functions in Singleplayer or when not connected to a Multiplayer server.',
+ }),
+ icon: UserIcon,
+ },
+ dedicated_server_only: {
+ title: defineMessage({
+ id: 'project.environment.dedicated-server-only.title',
+ defaultMessage: 'Server-side only',
+ }),
+ description: defineMessage({
+ id: 'project.environment.dedicated-server-only.description',
+ defaultMessage:
+ 'All functionality is done server-side and is compatible with vanilla clients.',
+ }),
+ icon: ServerIcon,
+ },
+ client_and_server: {
+ title: defineMessage({
+ id: 'project.environment.client-and-server.title',
+ defaultMessage: 'Client and server',
+ }),
+ description: defineMessage({
+ id: 'project.environment.client-and-server.description',
+ defaultMessage:
+ 'Has some functionality on both the client and server, even if only partially.',
+ }),
+ icon: MonitorSmartphoneIcon,
+ },
+ client_only_server_optional: {
+ title: defineMessage({
+ id: 'project.environment.client-only-server-optional.title',
+ defaultMessage: 'Client and server',
+ }),
+ description: defineMessage({
+ id: 'project.environment.client-only-server-optional.description',
+ defaultMessage:
+ 'Has some functionality on both the client and server, even if only partially.',
+ }),
+ icon: MonitorSmartphoneIcon,
+ },
+ server_only_client_optional: {
+ title: defineMessage({
+ id: 'project.environment.server-only-client-optional.title',
+ defaultMessage: 'Client and server',
+ }),
+ description: defineMessage({
+ id: 'project.environment.server-only-client-optional.description',
+ defaultMessage:
+ 'Has some functionality on both the client and server, even if only partially.',
+ }),
+ icon: MonitorSmartphoneIcon,
+ },
+ client_or_server: {
+ title: defineMessage({
+ id: 'project.environment.client-or-server.title',
+ defaultMessage: 'Client and server',
+ }),
+ description: defineMessage({
+ id: 'project.environment.client-or-server.description',
+ defaultMessage:
+ 'Has some functionality on both the client and server, even if only partially.',
+ }),
+ icon: MonitorSmartphoneIcon,
+ },
+ client_or_server_prefers_both: {
+ title: defineMessage({
+ id: 'project.environment.client-or-server-prefers-both.title',
+ defaultMessage: 'Client and server',
+ }),
+ description: defineMessage({
+ id: 'project.environment.client-or-server-prefers-both.description',
+ defaultMessage:
+ 'Has some functionality on both the client and server, even if only partially.',
+ }),
+ icon: MonitorSmartphoneIcon,
+ },
+ unknown: {
+ title: defineMessage({
+ id: 'project.environment.unknown.title',
+ defaultMessage: 'Unknown environment',
+ }),
+ description: defineMessage({
+ id: 'project.environment.unknown.description',
+ defaultMessage: 'The environment for this version could not be determined.',
+ }),
+ },
+}
diff --git a/packages/ui/src/components/project/settings/index.ts b/packages/ui/src/components/project/settings/index.ts
index dc2fb0c137..d538f8adfe 100644
--- a/packages/ui/src/components/project/settings/index.ts
+++ b/packages/ui/src/components/project/settings/index.ts
@@ -1,2 +1,4 @@
-// Environment
-export { default as ProjectSettingsEnvSelector } from './environment/ProjectSettingsEnvSelector.vue'
+export { default as EnvironmentMigration } from './environment/EnvironmentMigration.vue'
+export { ENVIRONMENTS_COPY } from './environment/environments'
+export { default as EnvironmentSelector } from './environment/EnvironmentSelector.vue'
+export { default as ProjectEnvironmentModal } from './environment/ProjectEnvironmentModal.vue'
diff --git a/packages/ui/src/locales/en-US/index.json b/packages/ui/src/locales/en-US/index.json
index 5e75d51289..8c8d74e0b4 100644
--- a/packages/ui/src/locales/en-US/index.json
+++ b/packages/ui/src/locales/en-US/index.json
@@ -677,6 +677,66 @@
"project.about.links.wiki": {
"defaultMessage": "Visit wiki"
},
+ "project.environment.client-and-server.description": {
+ "defaultMessage": "Has some functionality on both the client and server, even if only partially."
+ },
+ "project.environment.client-and-server.title": {
+ "defaultMessage": "Client and server"
+ },
+ "project.environment.client-only-server-optional.description": {
+ "defaultMessage": "Has some functionality on both the client and server, even if only partially."
+ },
+ "project.environment.client-only-server-optional.title": {
+ "defaultMessage": "Client and server"
+ },
+ "project.environment.client-only.description": {
+ "defaultMessage": "All functionality is done client-side and is compatible with vanilla servers."
+ },
+ "project.environment.client-only.title": {
+ "defaultMessage": "Client-side only"
+ },
+ "project.environment.client-or-server-prefers-both.description": {
+ "defaultMessage": "Has some functionality on both the client and server, even if only partially."
+ },
+ "project.environment.client-or-server-prefers-both.title": {
+ "defaultMessage": "Client and server"
+ },
+ "project.environment.client-or-server.description": {
+ "defaultMessage": "Has some functionality on both the client and server, even if only partially."
+ },
+ "project.environment.client-or-server.title": {
+ "defaultMessage": "Client and server"
+ },
+ "project.environment.dedicated-server-only.description": {
+ "defaultMessage": "All functionality is done server-side and is compatible with vanilla clients."
+ },
+ "project.environment.dedicated-server-only.title": {
+ "defaultMessage": "Server-side only"
+ },
+ "project.environment.server-only-client-optional.description": {
+ "defaultMessage": "Has some functionality on both the client and server, even if only partially."
+ },
+ "project.environment.server-only-client-optional.title": {
+ "defaultMessage": "Client and server"
+ },
+ "project.environment.server-only.description": {
+ "defaultMessage": "All functionality is done server-side and is compatible with vanilla clients."
+ },
+ "project.environment.server-only.title": {
+ "defaultMessage": "Server-side only"
+ },
+ "project.environment.singleplayer-only.description": {
+ "defaultMessage": "Only functions in Singleplayer or when not connected to a Multiplayer server."
+ },
+ "project.environment.singleplayer-only.title": {
+ "defaultMessage": "Singleplayer only"
+ },
+ "project.environment.unknown.description": {
+ "defaultMessage": "The environment for this version could not be determined."
+ },
+ "project.environment.unknown.title": {
+ "defaultMessage": "Unknown environment"
+ },
"project.settings.analytics.title": {
"defaultMessage": "Analytics"
},
@@ -710,6 +770,30 @@
"project.settings.environment.client_only.title": {
"defaultMessage": "Client-side only"
},
+ "project.settings.environment.notice.missing-env.description": {
+ "defaultMessage": "Your project is missing environment metadata, please select the appropriate option below."
+ },
+ "project.settings.environment.notice.missing-env.title": {
+ "defaultMessage": "Please select an environment for your project"
+ },
+ "project.settings.environment.notice.multiple-environments.description": {
+ "defaultMessage": "Different versions of your project have different environments selected, so you can't edit them globally at this time."
+ },
+ "project.settings.environment.notice.multiple-environments.title": {
+ "defaultMessage": "Your project has multiple environments"
+ },
+ "project.settings.environment.notice.review-options.description": {
+ "defaultMessage": "We've just overhauled the Environments system on Modrinth and new options are now available. Please ensure the correct option is selected below and then click 'Verify' when you're done!"
+ },
+ "project.settings.environment.notice.review-options.title": {
+ "defaultMessage": "Please review the options below"
+ },
+ "project.settings.environment.notice.wrong-project-type.description": {
+ "defaultMessage": "Only mod or modpack projects can have environment metadata."
+ },
+ "project.settings.environment.notice.wrong-project-type.title": {
+ "defaultMessage": "This project type does not support environment metadata"
+ },
"project.settings.environment.server_only.dedicated_only.title": {
"defaultMessage": "Dedicated server only"
},
@@ -737,6 +821,12 @@
"project.settings.environment.title": {
"defaultMessage": "Environment"
},
+ "project.settings.environment.verification.verify-button": {
+ "defaultMessage": "Verify"
+ },
+ "project.settings.environment.verification.verify-text": {
+ "defaultMessage": "Verify that this project's environment is set correctly."
+ },
"project.settings.gallery.title": {
"defaultMessage": "Gallery"
},