Versions environments updates (#4949)

* add environment to version page metadata card

* remove environment migration warnings

* show settings/environments in nav only for staff

* use v2 versions route due to regressions

* add modpack incorrect loaders migration

* remove modpack migration step

* remove unused var

* run pnpm intl:extract

* componentize environment migration page

* rename environment selector

* rename environment selector pt2

* add migration modal to admonition

* hide environments in settings and show message

* show environment in project versions table

* pnpm fix

* pnpm fix on ui package

* intl:extract

* fix: .value

* lower case file

* add icon to environment tags and use i18n

* Update apps/frontend/src/pages/[type]/[id].vue

Co-authored-by: Calum H. <contact@cal.engineer>
Signed-off-by: Truman Gao <106889354+tdgao@users.noreply.github.com>

* open migration modal from warning icon in project dashboard

* fix settings side nav icon

* use useRoute composable

* pnpm fix

* intl:extract

* fix import

* fix import again

* run pnpm prepr

* fix designMessage import

* fix environment fetch

* fix environment fetch properly without key conflict

* fix environment refetching

* fix not using current versions in table to check different environments

* fix download tooltip

---------

Signed-off-by: Truman Gao <106889354+tdgao@users.noreply.github.com>
Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
This commit is contained in:
Truman Gao
2025-12-29 13:58:17 -08:00
committed by GitHub
parent 9924faab84
commit 91b08e7380
16 changed files with 671 additions and 286 deletions

View File

@@ -42,7 +42,12 @@
</div>
<div
v-if="versions.length > 0"
class="flex flex-col gap-4 rounded-2xl bg-bg-raised px-6 pb-8 pt-4 supports-[grid-template-columns:subgrid]:grid supports-[grid-template-columns:subgrid]:grid-cols-[1fr_min-content] sm:px-8 supports-[grid-template-columns:subgrid]:sm:grid-cols-[min-content_auto_auto_auto_min-content] supports-[grid-template-columns:subgrid]:xl:grid-cols-[min-content_auto_auto_auto_auto_auto_min-content]"
class="flex flex-col gap-4 rounded-2xl bg-bg-raised px-6 pb-8 pt-4 supports-[grid-template-columns:subgrid]:grid supports-[grid-template-columns:subgrid]:grid-cols-[1fr_min-content] sm:px-8 supports-[grid-template-columns:subgrid]:sm:grid-cols-[min-content_auto_auto_auto_min-content]"
:class="[
hasMultipleEnvironments
? 'supports-[grid-template-columns:subgrid]:xl:grid-cols-[min-content_auto_auto_auto_auto_auto_auto_min-content] has-environment'
: 'supports-[grid-template-columns:subgrid]:xl:grid-cols-[min-content_auto_auto_auto_auto_auto_min-content] no-environment',
]"
>
<div class="versions-grid-row">
<div class="w-9 max-sm:hidden"></div>
@@ -57,6 +62,12 @@
>
Platforms
</div>
<div
v-if="hasMultipleEnvironments"
class="text-sm font-bold text-contrast max-sm:hidden sm:max-xl:collapse sm:max-xl:hidden"
>
Environment
</div>
<div
class="text-sm font-bold text-contrast max-sm:hidden sm:max-xl:collapse sm:max-xl:hidden"
>
@@ -144,6 +155,24 @@
</TagItem>
</div>
</div>
<div
v-if="hasMultipleEnvironments"
v-tooltip="
ENVIRONMENTS_COPY[version.environment || 'unknown']?.description
? formatMessage(ENVIRONMENTS_COPY[version.environment || 'unknown'].description)
: undefined
"
class="flex items-center"
>
<TagItem class="z-[1] text-center">
<component :is="ENVIRONMENTS_COPY[version.environment || 'unknown']?.icon" />
{{
ENVIRONMENTS_COPY[version.environment || 'unknown']?.title
? formatMessage(ENVIRONMENTS_COPY[version.environment || 'unknown'].title)
: ''
}}
</TagItem>
</div>
</div>
<div
class="flex flex-col justify-center gap-1 max-sm:flex-row max-sm:justify-start max-sm:gap-3 xl:contents"
@@ -215,12 +244,14 @@ import { commonMessages } from '../../utils/common-messages'
import AutoLink from '../base/AutoLink.vue'
import TagItem from '../base/TagItem.vue'
import { Pagination, VersionChannelIndicator, VersionFilterControl } from '../index'
import { ENVIRONMENTS_COPY } from './settings/environment/environments'
const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()
type VersionWithDisplayUrlEnding = Version & {
displayUrlEnding: string
environment?: Labrinth.Projects.v3.Environment
}
const props = withDefaults(
@@ -260,6 +291,11 @@ const selectedPlatforms: Ref<string[]> = computed(
)
const selectedChannels: Ref<string[]> = computed(() => versionFilters.value?.selectedChannels ?? [])
const hasMultipleEnvironments = computed(() => {
const environments = new Set(currentVersions.value.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<string, string | string[] | undefined |
</script>
<style scoped>
.versions-grid-row {
@apply grid grid-cols-[1fr_min-content] gap-4 supports-[grid-template-columns:subgrid]:col-span-full supports-[grid-template-columns:subgrid]:!grid-cols-subgrid sm:grid-cols-[min-content_1fr_1fr_1fr_min-content] xl:grid-cols-[min-content_1fr_1fr_1fr_1fr_1fr_min-content];
@apply grid grid-cols-[1fr_min-content] gap-4 supports-[grid-template-columns:subgrid]:col-span-full supports-[grid-template-columns:subgrid]:!grid-cols-subgrid sm:grid-cols-[min-content_1fr_1fr_1fr_min-content];
}
.has-environment .versions-grid-row {
@apply xl:grid-cols-[min-content_1fr_1fr_1fr_1fr_1fr_1fr_min-content];
}
.no-environment .versions-grid-row {
@apply xl:grid-cols-[min-content_1fr_1fr_1fr_1fr_1fr_min-content];
}
</style>

View File

@@ -21,7 +21,7 @@
:action="() => router.push(`/${project.project_type}s?g=categories:${platform}`)"
:style="`--_color: var(--color-platform-${platform})`"
>
<svg v-html="tags.loaders.find((x) => x.name === platform).icon"></svg>
<svg v-html="tags.loaders.find((x) => x.name === platform)?.icon"></svg>
{{ formatCategory(platform) }}
</TagItem>
</div>
@@ -69,6 +69,7 @@
</TagItem>
<TagItem
v-if="
// @ts-ignore
project.project_type !== 'datapack' &&
project.client_side !== 'unsupported' &&
project.server_side !== 'unsupported' &&

View File

@@ -0,0 +1,173 @@
<script setup lang="ts">
import { CheckIcon } from '@modrinth/assets'
import {
Admonition,
commonProjectSettingsMessages,
defineMessages,
EnvironmentSelector,
injectModrinthClient,
injectNotificationManager,
injectProjectPageContext,
UnsavedChangesPopup,
useSavable,
useVIntl,
} from '@modrinth/ui'
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!",
},
})
</script>
<template>
<div>
<Admonition
v-if="!supportsEnvironment"
type="critical"
:header="formatMessage(messages.wrongProjectTypeTitle)"
:body="formatMessage(messages.wrongProjectTypeDescription)"
class="mb-3"
/>
<template v-else>
<Admonition
v-if="!hasPermission"
type="critical"
:header="formatMessage(commonProjectSettingsMessages.noPermissionTitle)"
:body="formatMessage(commonProjectSettingsMessages.noPermissionDescription)"
class="mb-3"
/>
<Admonition
v-else-if="
!projectV3.environment ||
projectV3.environment.length === 0 ||
(projectV3.environment.length === 1 && projectV3.environment[0] === 'unknown')
"
type="critical"
:header="formatMessage(messages.missingEnvTitle)"
:body="formatMessage(messages.missingEnvDescription)"
class="mb-3"
/>
<Admonition
v-else-if="projectV3.environment.length > 1"
type="info"
:header="formatMessage(messages.multipleEnvironmentsTitle)"
:body="formatMessage(messages.multipleEnvironmentsDescription)"
class="mb-3"
/>
<Admonition
v-else-if="needsToVerify"
type="warning"
:header="formatMessage(messages.reviewOptionsTitle)"
:body="formatMessage(messages.reviewOptionsDescription)"
class="mb-3"
/>
<EnvironmentSelector
v-model="current.environment"
:disabled="!hasPermission || (projectV3?.environment?.length ?? 0) > 1"
/>
</template>
<UnsavedChangesPopup
v-if="supportsEnvironment && hasPermission && (projectV3?.environment?.length ?? 0) <= 1"
:original="saved"
:modified="current"
:saving="saving"
:can-reset="!needsToVerify"
:text="needsToVerify ? messages.verifyLabel : undefined"
:save-label="needsToVerify ? messages.verifyButton : undefined"
:save-icon="needsToVerify ? CheckIcon : undefined"
@reset="reset"
@save="save"
/>
</div>
</template>

View File

@@ -33,7 +33,10 @@ const optionLabelFormat = defineMessage({
defaultMessage: '{title}: {description}',
})
const OUTER_OPTIONS = {
const OUTER_OPTIONS: Record<
string,
EnvironmentRadioOption & { suboptions: Record<string, EnvironmentRadioOption> }
> = {
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<string, EnvironmentRadioOption> }
>
} 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="

View File

@@ -0,0 +1,40 @@
<template>
<NewModal ref="modal" :scrollable="true" max-content-height="82vh" :closable="true">
<template #title>
<span class="text-lg font-extrabold text-contrast">Edit project Environment</span>
</template>
<div class="mb-24 max-w-[600px]">
<EnvironmentMigration />
</div>
</NewModal>
</template>
<script setup lang="ts">
import { onMounted, useTemplateRef } from 'vue'
import { useRoute } from 'vue-router'
import { NewModal } from '../../../modal'
import EnvironmentMigration from './EnvironmentMigration.vue'
const modal = useTemplateRef<InstanceType<typeof NewModal>>('modal')
function show() {
modal.value?.show()
}
function hide() {
modal.value?.hide()
}
onMounted(() => {
const route = useRoute()
if (route.query.showEnvironmentMigrationWarning === 'true') {
show()
}
})
defineExpose({
show,
hide,
})
</script>

View File

@@ -0,0 +1,129 @@
import type { Labrinth } from '@modrinth/api-client'
import { ClientIcon, MonitorSmartphoneIcon, ServerIcon, UserIcon } from '@modrinth/assets'
import type { Component } from 'vue'
import { defineMessage, type MessageDescriptor } from '../../../../composables/i18n'
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.',
}),
},
}

View File

@@ -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'

View File

@@ -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"
},