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

@@ -155,7 +155,14 @@
<script lang="ts" setup>
import { EditIcon } from '@modrinth/assets'
import { ButtonStyled, Chips, TagItem } from '@modrinth/ui'
import {
ButtonStyled,
Chips,
defineMessages,
ENVIRONMENTS_COPY,
TagItem,
useVIntl,
} from '@modrinth/ui'
import { formatCategory } from '@modrinth/utils'
import { useGeneratedState } from '~/composables/generated'
@@ -215,61 +222,51 @@ const usingDetectedLoaders = computed(() => {
return loadersMatch
})
const { formatMessage } = useVIntl()
const noEnvironmentMessage = defineMessages({
title: {
id: 'version.environment.none.title',
defaultMessage: 'No environment set',
},
description: {
id: 'version.environment.none.description',
defaultMessage: 'The environment for this version has not been specified.',
},
})
const unknownEnvironmentMessage = defineMessages({
title: {
id: 'version.environment.unknown.title',
defaultMessage: 'Unknown environment',
},
description: {
id: 'version.environment.unknown.description',
defaultMessage: 'The environment: "{environment}" is not recognized.',
},
})
const environmentCopy = computed(() => {
const emptyMessage = {
title: 'No environment set',
description: 'The environment for this version has not been specified.',
}
if (!draftVersion.value.environment) return emptyMessage
const envCopy: Record<string, { title: string; description: string }> = {
client_only: {
title: 'Client-side only',
description: 'All functionality is done client-side and is compatible with vanilla servers.',
},
server_only: {
title: 'Server-side only',
description: 'All functionality is done server-side and is compatible with vanilla clients.',
},
singleplayer_only: {
title: 'Singleplayer only',
description: 'Only functions in Singleplayer or when not connected to a Multiplayer server.',
},
dedicated_server_only: {
title: 'Server-side only',
description: 'All functionality is done server-side and is compatible with vanilla clients.',
},
client_and_server: {
title: 'Client and server',
description: 'Has some functionality on both the client and server, even if only partially.',
},
client_only_server_optional: {
title: 'Client and server',
description: 'Has some functionality on both the client and server, even if only partially.',
},
server_only_client_optional: {
title: 'Client and server',
description: 'Has some functionality on both the client and server, even if only partially.',
},
client_or_server: {
title: 'Client and server',
description: 'Has some functionality on both the client and server, even if only partially.',
},
client_or_server_prefers_both: {
title: 'Client and server',
description: 'Has some functionality on both the client and server, even if only partially.',
},
unknown: {
title: 'Unknown environment',
description: 'The environment for this version could not be determined.',
},
}
return (
envCopy[draftVersion.value.environment] || {
title: 'Unknown environment',
description: `The environment: "${draftVersion.value.environment}" is not recognized.`,
if (!draftVersion.value.environment) {
return {
title: formatMessage(noEnvironmentMessage.title),
description: formatMessage(noEnvironmentMessage.description),
}
)
}
const envCopy = ENVIRONMENTS_COPY[draftVersion.value.environment]
if (envCopy) {
return {
title: formatMessage(envCopy.title),
description: formatMessage(envCopy.description),
}
}
return {
title: formatMessage(unknownEnvironmentMessage.title),
description: formatMessage(unknownEnvironmentMessage.description, {
environment: draftVersion.value.environment,
}),
}
})
</script>

View File

@@ -1,11 +1,11 @@
<template>
<div class="sm:w-[512px]">
<ProjectSettingsEnvSelector v-model="draftVersion.environment" />
<EnvironmentSelector v-model="draftVersion.environment" />
</div>
</template>
<script lang="ts" setup>
import { ProjectSettingsEnvSelector } from '@modrinth/ui'
import { EnvironmentSelector } from '@modrinth/ui'
import { injectManageVersionContext } from '~/providers/version/manage-version-modal'

View File

@@ -2130,7 +2130,7 @@
"message": "Learn more about this change"
},
"project.environment.migration.message": {
"message": "We've just overhauled the Environments system on Modrinth and new options are now available. Please visit your project's settings and verify that the metadata is correct."
"message": "We've just overhauled the Environments system on Modrinth and new options are now available. Please verify that the metadata is correct."
},
"project.environment.migration.review-button": {
"message": "Review environment settings"
@@ -2177,36 +2177,6 @@
"project.notification.updated.title": {
"message": "Project updated"
},
"project.settings.environment.notice.missing-env.description": {
"message": "Your project is missing environment metadata, please select the appropriate option below."
},
"project.settings.environment.notice.missing-env.title": {
"message": "Please select an environment for your project"
},
"project.settings.environment.notice.multiple-environments.description": {
"message": "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": {
"message": "Your project has multiple environments"
},
"project.settings.environment.notice.review-options.description": {
"message": "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": {
"message": "Please review the options below"
},
"project.settings.environment.notice.wrong-project-type.description": {
"message": "Only mod or modpack projects can have environment metadata."
},
"project.settings.environment.notice.wrong-project-type.title": {
"message": "This project type does not support environment metadata"
},
"project.settings.environment.verification.verify-button": {
"message": "Verify"
},
"project.settings.environment.verification.verify-text": {
"message": "Verify that this project's environment is set correctly."
},
"project.settings.general.name.description": {
"message": "Avoid prefixes, suffixes, parentheticals, or added descriptions—just the project's actual name."
},
@@ -2917,5 +2887,17 @@
},
"ui.latest-news-row.view-all": {
"message": "View all news"
},
"version.environment.none.description": {
"message": "The environment for this version has not been specified."
},
"version.environment.none.title": {
"message": "No environment set"
},
"version.environment.unknown.description": {
"message": "The environment: \"{environment}\" is not recognized."
},
"version.environment.unknown.title": {
"message": "Unknown environment"
}
}

View File

@@ -43,11 +43,11 @@
<NuxtPage
v-model:project="project"
v-model:project-v3="projectV3"
v-model:versions="versions"
v-model:members="members"
v-model:all-members="allMembers"
v-model:dependencies="dependencies"
v-model:organization="organization"
v-model:versions="versions"
:current-member="currentMember"
:patch-project="patchProject"
:patch-icon="patchIcon"
@@ -457,9 +457,6 @@
<div class="hidden sm:contents">
<ButtonStyled
v-tooltip="
auth.user && currentMember ? formatMessage(commonMessages.downloadButton) : ''
"
size="large"
:color="
(auth.user && currentMember) || route.name === 'type-id-version-version'
@@ -468,7 +465,12 @@
"
:circular="auth.user && currentMember"
>
<button @click="(event) => downloadModal.show(event)">
<button
v-tooltip="
auth.user && currentMember ? formatMessage(commonMessages.downloadButton) : ''
"
@click="(event) => downloadModal.show(event)"
>
<DownloadIcon aria-hidden="true" />
{{
auth.user && currentMember ? '' : formatMessage(commonMessages.downloadButton)
@@ -778,9 +780,9 @@
{{ formatMessage(messages.environmentMigrationLink) }}
</nuxt-link>
<ButtonStyled v-if="hasEditDetailsPermission" color="orange">
<nuxt-link :to="`/project/${project.id}/settings/environment`" class="mt-3 w-fit">
<button class="mt-3 w-fit" @click="() => projectEnvironmentModal.show()">
<SettingsIcon /> {{ formatMessage(messages.reviewEnvironmentSettings) }}
</nuxt-link>
</button>
</ButtonStyled>
</Admonition>
<MessageBanner v-if="project.status === 'archived'" message-type="warning" class="my-4">
@@ -935,6 +937,10 @@
@toggle-collapsed="collapsedModerationChecklist = !collapsedModerationChecklist"
/>
</div>
<template v-if="hasEditDetailsPermission">
<ProjectEnvironmentModal ref="projectEnvironmentModal" />
</template>
</template>
<script setup>
@@ -977,6 +983,7 @@ import {
OverflowMenu,
PopoutMenu,
ProjectBackgroundGradient,
ProjectEnvironmentModal,
ProjectHeader,
ProjectSidebarCompatibility,
ProjectSidebarCreators,
@@ -994,6 +1001,7 @@ import { formatCategory, formatPrice, formatProjectType, renderString } from '@m
import { useLocalStorage } from '@vueuse/core'
import dayjs from 'dayjs'
import { Tooltip } from 'floating-vue'
import { useTemplateRef } from 'vue'
import { navigateTo } from '#app'
import Accordion from '~/components/ui/Accordion.vue'
@@ -1037,6 +1045,8 @@ const gameVersionFilterInput = ref()
const versionFilter = ref('')
const projectEnvironmentModal = useTemplateRef('projectEnvironmentModal')
const baseId = useId()
const currentGameVersion = computed(() => {
@@ -1186,7 +1196,7 @@ const messages = defineMessages({
environmentMigrationMessage: {
id: 'project.environment.migration.message',
defaultMessage:
"We've just overhauled the Environments system on Modrinth and new options are now available. Please visit your project's settings and verify that the metadata is correct.",
"We've just overhauled the Environments system on Modrinth and new options are now available. Please verify that the metadata is correct.",
},
environmentMigrationTitle: {
id: 'project.environment.migration.title',
@@ -1459,21 +1469,25 @@ let project,
resetMembers,
dependencies,
versions,
resetVersions,
versionsV3,
resetVersionsV2,
organization,
resetOrganization,
projectV2Error,
projectV3Error,
membersError,
dependenciesError,
versionsError
versionsError,
versionsV3Error,
resetVersionsV3
try {
;[
{ data: project, error: projectV2Error, refresh: resetProjectV2 },
{ data: projectV3, error: projectV3Error, refresh: resetProjectV3 },
{ data: allMembers, error: membersError, refresh: resetMembers },
{ data: dependencies, error: dependenciesError },
{ data: versions, error: versionsError, refresh: resetVersions },
{ data: versions, error: versionsError, refresh: resetVersionsV2 },
{ data: versionsV3, error: versionsV3Error, refresh: resetVersionsV3 },
{ data: organization, refresh: resetOrganization },
] = await Promise.all([
useAsyncData(`project/${projectId.value}`, () => useBaseFetch(`project/${projectId.value}`), {
@@ -1515,6 +1529,9 @@ try {
useAsyncData(`project/${projectId.value}/version`, () =>
useBaseFetch(`project/${projectId.value}/version`),
),
useAsyncData(`project/${projectId.value}/version/v3`, () =>
useBaseFetch(`project/${projectId.value}/version`, { apiVersion: 3 }),
),
useAsyncData(`project/${projectId.value}/organization`, () =>
useBaseFetch(`project/${projectId.value}/organization`, { apiVersion: 3 }),
),
@@ -1523,6 +1540,11 @@ try {
await updateProjectRoute()
versions = shallowRef(toRaw(versions))
versionsV3 = shallowRef(toRaw(versionsV3))
versions.value = versions.value.map((v) => ({
...v,
environment: versionsV3.value?.find((v3) => v3.id === v.id)?.environment,
}))
} catch (err) {
throw createError({
fatal: true,
@@ -1555,6 +1577,16 @@ async function resetProject() {
await resetProjectV3()
}
async function resetVersions() {
await resetVersionsV2()
await resetVersionsV3()
versions.value = versions.value.map((v) => ({
...v,
environment: versionsV3.value?.find((v3) => v3.id === v.id)?.environment,
}))
}
function handleError(err, project = false) {
if (err.value && err.value.statusCode) {
throw createError({
@@ -1573,6 +1605,7 @@ handleError(projectV3Error)
handleError(membersError)
handleError(dependenciesError)
handleError(versionsError)
handleError(versionsV3Error)
if (!project.value) {
throw createError({

View File

@@ -3,6 +3,7 @@ import {
AlignLeftIcon,
BookTextIcon,
ChartIcon,
GlobeIcon,
ImageIcon,
InfoIcon,
LinkIcon,
@@ -16,7 +17,7 @@ import {
injectNotificationManager,
useVIntl,
} from '@modrinth/ui'
import type { Project, ProjectV3Partial } from '@modrinth/utils'
import { isStaff, type Project, type ProjectV3Partial } from '@modrinth/utils'
import { useLocalStorage, useScroll } from '@vueuse/core'
import { computed } from 'vue'
@@ -25,7 +26,7 @@ import NavStack from '~/components/ui/NavStack.vue'
const { formatMessage } = useVIntl()
defineProps<{
const props = defineProps<{
currentMember: any
patchProject: any
patchIcon: any
@@ -47,6 +48,11 @@ const organization = defineModel<any>('organization')
const navItems = computed(() => {
const base = `${project.value.project_type}/${project.value.slug ? project.value.slug : project.value.id}`
const showEnvironment =
projectV3.value.project_types.some((type) => ['mod', 'modpack'].includes(type)) &&
isStaff(props.currentMember.user)
const items = [
{
link: `/${base}/settings`,
@@ -101,6 +107,13 @@ const navItems = computed(() => {
label: formatMessage(commonProjectSettingsMessages.analytics),
icon: ChartIcon,
},
{ type: 'heading', label: 'moderation', shown: showEnvironment },
{
link: `/${base}/settings/environment`,
label: formatMessage(commonProjectSettingsMessages.environment),
icon: GlobeIcon,
shown: showEnvironment,
},
]
return items.filter(Boolean) as any[]
})

View File

@@ -1,177 +1,36 @@
<script setup lang="ts">
import { CheckIcon } from '@modrinth/assets'
import {
Admonition,
commonProjectSettingsMessages,
defineMessages,
injectModrinthClient,
injectNotificationManager,
injectProjectPageContext,
ProjectSettingsEnvSelector,
UnsavedChangesPopup,
useSavable,
useVIntl,
} from '@modrinth/ui'
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>
<div class="card experimental-styles-within">
<h2 class="m-0 mb-2 block text-lg font-extrabold text-contrast">
{{ formatMessage(commonProjectSettingsMessages.environment) }}
</h2>
<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"
/>
<ProjectSettingsEnvSelector
v-model="current.environment"
:disabled="!hasPermission || (projectV3?.environment?.length ?? 0) > 1"
/>
</template>
<div v-if="showEnvironmentMigration" class="card experimental-styles-within">
<h2 class="m-0 mb-2 block text-lg font-extrabold text-contrast">Project environment</h2>
<EnvironmentMigration />
</div>
<div v-else class="grid place-content-center py-32">
<div class="flex flex-col items-center gap-5 text-center">
<div class="flex flex-col gap-2">
<div class="text-xl font-semibold text-contrast">
Environments are now managed per version.
</div>
<div>Visit Project Settings to manage environments for each version.</div>
</div>
<ButtonStyled color="green">
<nuxt-link
:to="`/${projectV2.project_type}/${projectV2.id}/settings/versions`"
class="items flex"
>
<SettingsIcon /> Edit versions
</nuxt-link>
</ButtonStyled>
</div>
<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>
<script setup lang="ts">
import { SettingsIcon } from '@modrinth/assets'
import { ButtonStyled, EnvironmentMigration, injectProjectPageContext } from '@modrinth/ui'
import { isStaff } from '@modrinth/utils'
const { currentMember, projectV2 } = injectProjectPageContext()
const showEnvironmentMigration = computed(() => {
return isStaff(currentMember.value.user)
})
</script>

View File

@@ -560,6 +560,17 @@
</template>
<span v-else>{{ $formatVersion(version.game_versions) }}</span>
</div>
<div v-if="!isEditing && environment">
<h4>Environment</h4>
<div class="flex items-center gap-1.5">
<template v-if="environment.icon">
<component :is="environment.icon" />
</template>
<span>
{{ environment.title.defaultMessage }}
</span>
</div>
</div>
<div v-if="!isEditing">
<h4>Downloads</h4>
<span>{{ version.downloads }}</span>
@@ -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'() {

View File

@@ -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`"
>
<TriangleAlertIcon />
</nuxt-link>

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