Envs v3 frontend (#4267)

* New envs frontend

* lint fix

* Add blog post, user-facing changes, dashboard warning, project page member warning, and migration reviewing. maybe some other misc stuff

* lint

* lint

* ignore .data in .prettierignore

* i18n as fuck

* fix proj page

* Improve news markdown rendering

* improve phrasing of initial paragraph

* Fix environments not reloading after save

* index.ts instead of underscored name

* shrink-0 back on these icons
This commit is contained in:
Prospector
2025-08-28 15:11:35 -07:00
committed by GitHub
parent 0ac42344e7
commit 46c325f78a
49 changed files with 2509 additions and 397 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,162 @@
<script setup lang="ts">
import {
AlignLeftIcon,
BookTextIcon,
ChartIcon,
GlobeIcon,
ImageIcon,
InfoIcon,
LinkIcon,
TagsIcon,
UsersIcon,
VersionIcon,
} from '@modrinth/assets'
import { commonMessages, commonProjectSettingsMessages } from '@modrinth/ui'
import type { Project, ProjectV3Partial } from '@modrinth/utils'
import { useVIntl } from '@vintl/vintl'
import NavStack from '~/components/ui/NavStack.vue'
import NavStackItem from '~/components/ui/NavStackItem.vue'
const { formatMessage } = useVIntl()
defineProps<{
currentMember: any
patchProject: any
patchIcon: any
resetProject: any
resetOrganization: any
resetMembers: any
}>()
const flags = useFeatureFlags()
const project = defineModel<Project>('project', { required: true })
const projectV3 = defineModel<ProjectV3Partial>('projectV3', { required: true })
const versions = defineModel<any>('versions')
const featuredVersions = defineModel<any>('featuredVersions')
const members = defineModel<any>('members')
const allMembers = defineModel<any>('allMembers')
const dependencies = defineModel<any>('dependencies')
const organization = defineModel<any>('organization')
</script>
<template>
<div class="experimental-styles-within grid grid-cols-[1fr_3fr] gap-4">
<div>
<aside class="universal-card">
<NavStack>
<NavStackItem
:link="`/${project.project_type}/${project.slug ? project.slug : project.id}/settings`"
:label="formatMessage(commonProjectSettingsMessages.general)"
>
<InfoIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem
v-if="flags.newProjectGeneralSettings"
:link="`/${project.project_type}/${project.slug ? project.slug : project.id}/settings/general`"
:label="formatMessage(commonProjectSettingsMessages.general)"
:badge="formatMessage(commonMessages.newBadge)"
>
<InfoIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem
v-if="
flags.newProjectEnvironmentSettings &&
projectV3.project_types.some((type) => ['mod', 'modpack'].includes(type))
"
:link="`/${project.project_type}/${
project.slug ? project.slug : project.id
}/settings/environment`"
:label="formatMessage(commonProjectSettingsMessages.environment)"
:badge="formatMessage(commonMessages.newBadge)"
>
<GlobeIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem
:link="`/${project.project_type}/${
project.slug ? project.slug : project.id
}/settings/tags`"
:label="formatMessage(commonProjectSettingsMessages.tags)"
>
<TagsIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem
:link="`/${project.project_type}/${
project.slug ? project.slug : project.id
}/settings/description`"
:label="formatMessage(commonProjectSettingsMessages.description)"
>
<AlignLeftIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem
:link="`/${project.project_type}/${
project.slug ? project.slug : project.id
}/settings/license`"
:label="formatMessage(commonProjectSettingsMessages.license)"
>
<BookTextIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem
:link="`/${project.project_type}/${
project.slug ? project.slug : project.id
}/settings/links`"
:label="formatMessage(commonProjectSettingsMessages.links)"
>
<LinkIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem
:link="`/${project.project_type}/${
project.slug ? project.slug : project.id
}/settings/members`"
:label="formatMessage(commonProjectSettingsMessages.members)"
>
<UsersIcon aria-hidden="true" />
</NavStackItem>
<h3>{{ formatMessage(commonProjectSettingsMessages.view) }}</h3>
<NavStackItem
:link="`/${project.project_type}/${
project.slug ? project.slug : project.id
}/settings/analytics`"
:label="formatMessage(commonProjectSettingsMessages.analytics)"
chevron
>
<ChartIcon aria-hidden="true" />
</NavStackItem>
<h3>{{ formatMessage(commonProjectSettingsMessages.upload) }}</h3>
<NavStackItem
:link="`/${project.project_type}/${project.slug ? project.slug : project.id}/gallery`"
:label="formatMessage(commonProjectSettingsMessages.gallery)"
chevron
>
<ImageIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem
:link="`/${project.project_type}/${project.slug ? project.slug : project.id}/versions`"
:label="formatMessage(commonProjectSettingsMessages.versions)"
chevron
>
<VersionIcon aria-hidden="true" />
</NavStackItem>
</NavStack>
</aside>
</div>
<div>
<NuxtPage
v-model:project="project"
v-model:project-v3="projectV3"
v-model:versions="versions"
v-model:featured-versions="featuredVersions"
v-model:members="members"
v-model:all-members="allMembers"
v-model:dependencies="dependencies"
v-model:organization="organization"
:current-member="currentMember"
:patch-project="patchProject"
:patch-icon="patchIcon"
:reset-project="resetProject"
:reset-organization="resetOrganization"
:reset-members="resetMembers"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,165 @@
<script setup lang="ts">
import { CheckIcon } from '@modrinth/assets'
import {
Admonition,
commonProjectSettingsMessages,
injectNotificationManager,
injectProjectPageContext,
ProjectSettingsEnvSelector,
UnsavedChangesPopup,
useSavable,
} from '@modrinth/ui'
import { injectApi } from '@modrinth/ui/src/providers/api.ts'
import { defineMessages, useVIntl } from '@vintl/vintl'
const { formatMessage } = useVIntl()
const { projectV2, projectV3, refreshProject } = injectProjectPageContext()
const { handleError } = injectNotificationManager()
const api = injectApi()
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 &&
projectV3.value.environment?.[0] !== 'unknown' &&
supportsEnvironment.value,
)
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'
api.projects
.editV3(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>
<UnsavedChangesPopup
v-if="supportsEnvironment"
: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 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)"
class="mb-3"
>
{{ formatMessage(messages.wrongProjectTypeDescription) }}
</Admonition>
<template v-else>
<Admonition
v-if="
!projectV3.environment ||
projectV3.environment.length === 0 ||
projectV3.environment[0] === 'unknown'
"
type="critical"
:header="formatMessage(messages.missingEnvTitle)"
class="mb-3"
>
{{ formatMessage(messages.missingEnvDescription) }}
</Admonition>
<Admonition
v-else-if="projectV3.environment.length > 1"
type="info"
:header="formatMessage(messages.multipleEnvironmentsTitle)"
class="mb-3"
>
{{ formatMessage(messages.multipleEnvironmentsDescription) }}
</Admonition>
<Admonition
v-else-if="needsToVerify"
type="warning"
:header="formatMessage(messages.reviewOptionsTitle)"
class="mb-3"
>
{{ formatMessage(messages.reviewOptionsDescription) }}
</Admonition>
<ProjectSettingsEnvSelector v-model="current.environment" />
</template>
</div>
</div>
</template>

View File

@@ -0,0 +1,192 @@
<script setup lang="ts">
import {
IconSelect,
injectNotificationManager,
injectProjectPageContext,
SettingsLabel,
UnsavedChangesPopup,
useSavable,
} from '@modrinth/ui'
import { injectApi } from '@modrinth/ui/src/providers/api.ts'
import { defineMessages, type MessageDescriptor, useVIntl } from '@vintl/vintl'
const { formatMessage } = useVIntl()
const { projectV2: project, refreshProject } = injectProjectPageContext()
const { handleError } = injectNotificationManager()
const api = injectApi()
const saving = ref(false)
const { saved, current, reset, save } = useSavable(
() => ({
title: project.value.title,
tagline: project.value.description,
url: project.value.slug,
icon: project.value.icon_url,
}),
({ title, tagline, url }) => {
const data: Record<string, string> = {
...(title !== undefined && { title }),
...(tagline !== undefined && { description: tagline }),
...(url !== undefined && { slug: url }),
}
if (data) {
saving.value = true
api.projects
.edit(project.value.id, { title, description: tagline, slug: url })
.then(() => refreshProject().then(reset))
.catch(handleError)
.finally(() => (saving.value = false))
}
},
)
const messages = defineMessages({
nameTitle: {
id: 'project.settings.general.name.title',
defaultMessage: 'Name',
},
nameDescription: {
id: 'project.settings.general.name.description',
defaultMessage:
"Avoid prefixes, suffixes, parentheticals, or added descriptions—just the project's actual name.",
},
taglineTitle: {
id: 'project.settings.general.tagline.title',
defaultMessage: 'Tagline',
},
taglineDescription: {
id: 'project.settings.general.tagline.description',
defaultMessage: 'Summarize your project in no more than one sentence.',
},
urlTitle: {
id: 'project.settings.general.url.title',
defaultMessage: 'URL',
},
})
const placeholders: { name: MessageDescriptor; tagline: MessageDescriptor }[] = [
defineMessages({
name: {
id: 'project.settings.general.name.placeholder.1',
defaultMessage: 'e.g. Nether Overhaul 2',
},
tagline: {
id: 'project.settings.general.tagline.placeholder.1',
defaultMessage: 'e.g. Overhauls game progression to revolve around the Nether.',
},
}),
defineMessages({
name: {
id: 'project.settings.general.name.placeholder.2',
defaultMessage: 'e.g. Construction Equipment',
},
tagline: {
id: 'project.settings.general.tagline.placeholder.2',
defaultMessage: 'e.g. Adds wearable construction gear.',
},
}),
defineMessages({
name: {
id: 'project.settings.general.name.placeholder.3',
defaultMessage: 'e.g. Better than Caving',
},
tagline: {
id: 'project.settings.general.tagline.placeholder.3',
defaultMessage: 'e.g. Adds realistic mineshaft-building mechanics.',
},
}),
defineMessages({
name: {
id: 'project.settings.general.name.placeholder.4',
defaultMessage: 'e.g. Enhanced Portals',
},
tagline: {
id: 'project.settings.general.tagline.placeholder.4',
defaultMessage: 'e.g. Improves how Nether portals link to each other.',
},
}),
defineMessages({
name: {
id: 'project.settings.general.name.placeholder.5',
defaultMessage: 'e.g. Dangerous Mobs',
},
tagline: {
id: 'project.settings.general.tagline.placeholder.5',
defaultMessage:
'e.g. Adds powerful boss versions of the normal mobs to encounter in the night.',
},
}),
]
const placeholderIndex = useState<number>('project-settings-random-placeholder', () =>
Math.floor(Math.random() * (placeholders.length + 1)),
)
const placeholder = computed(() => placeholders[placeholderIndex.value] ?? placeholders[0])
</script>
<template>
<div>
<UnsavedChangesPopup
:original="saved"
:modified="current"
:saving="saving"
@reset="reset"
@save="save"
/>
<div class="base-card block">
<div class="group relative float-end ml-4">
<IconSelect v-model="current.icon" />
</div>
<div>
<SettingsLabel
id="project-name"
:title="messages.nameTitle"
:description="messages.nameDescription"
/>
<div class="flex">
<input
id="project-name"
v-model="current.title"
:placeholder="formatMessage(placeholder.name)"
autocomplete="off"
maxlength="50"
class="flex-grow"
type="text"
/>
</div>
</div>
<div class="mt-4">
<SettingsLabel
id="project-tagline"
:title="messages.taglineTitle"
:description="messages.taglineDescription"
/>
<input
id="project-tagline"
v-model="current.tagline"
:placeholder="formatMessage(placeholder.tagline)"
autocomplete="off"
maxlength="120"
class="w-full"
type="text"
/>
</div>
<div class="mt-4">
<SettingsLabel id="project-url" :title="messages.urlTitle" />
<div class="text-input-wrapper">
<div class="text-input-wrapper__before">https://modrinth.com/project/</div>
<input
id="project-url"
v-model="current.url"
type="text"
maxlength="64"
autocomplete="off"
/>
</div>
</div>
</div>
</div>
</template>

View File

@@ -96,6 +96,7 @@
</div>
<template
v-if="
!flags.newProjectEnvironmentSettings &&
project.versions?.length !== 0 &&
project.project_type !== 'resourcepack' &&
project.project_type !== 'plugin' &&
@@ -258,15 +259,23 @@ import { formatProjectStatus, formatProjectType } from '@modrinth/utils'
import { Multiselect } from 'vue-multiselect'
import FileInput from '~/components/ui/FileInput.vue'
import { useFeatureFlags } from '~/composables/featureFlags.ts'
const { addNotification } = injectNotificationManager()
const flags = useFeatureFlags()
const props = defineProps({
project: {
type: Object,
required: true,
default: () => ({}),
},
projectV3: {
type: Object,
required: true,
default: () => ({}),
},
currentMember: {
type: Object,
required: true,

View File

@@ -282,9 +282,24 @@
<ProjectStatusBadge v-if="project.status" :status="project.status" />
</div>
<div>
<div class="flex !flex-row items-center !justify-end gap-2">
<ButtonStyled
v-if="projectsWithMigrationWarning.includes(project.id)"
circular
color="orange"
>
<nuxt-link
v-tooltip="'Please review environment metadata'"
:to="`/${getProjectTypeForUrl(project.project_type, project.loaders)}/${
project.slug ? project.slug : project.id
}/settings/environment`"
>
<TriangleAlertIcon />
</nuxt-link>
</ButtonStyled>
<ButtonStyled circular>
<nuxt-link
v-tooltip="formatMessage(commonMessages.settingsLabel)"
:to="`/${getProjectTypeForUrl(project.project_type, project.loaders)}/${
project.slug ? project.slug : project.id
}/settings`"
@@ -310,6 +325,7 @@ import {
SortAscIcon,
SortDescIcon,
TrashIcon,
TriangleAlertIcon,
XIcon,
} from '@modrinth/assets'
import {
@@ -344,6 +360,7 @@ const { formatMessage } = useVIntl()
const user = await useUser()
const projects = ref([])
const projectsWithMigrationWarning = ref([])
const selectedProjects = ref([])
const sortBy = ref('Name')
const descending = ref(false)
@@ -437,6 +454,15 @@ async function bulkEditLinks() {
await initUserProjects()
if (user.value?.projects) {
projects.value = updateSort(user.value.projects, 'Name', false)
user.value?.projectsV3?.forEach((project) => {
if (
(project.side_types_migration_review_status === 'pending' &&
project.project_types.includes('mod')) ||
project.project_types.includes('modpack')
) {
projectsWithMigrationWarning.value.push(project.id)
}
})
}
</script>
<style lang="scss" scoped>

View File

@@ -181,7 +181,8 @@ useSeoMeta({
padding: 0;
}
ul > li:not(:last-child) {
ul,
ol > li:not(:last-child) {
margin-bottom: 0.5rem;
}
@@ -203,7 +204,7 @@ useSeoMeta({
h1,
h2,
h3 {
margin-bottom: 0.25rem;
margin-bottom: 0.5rem;
}
h1 {