1
0

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

View File

@@ -1,6 +1,7 @@
**/.nuxt
**/dist
**/.output
**/.data
src/generated/**
src/locales/**
src/public/news/feed

View File

@@ -7,10 +7,30 @@
</template>
<script setup lang="ts">
import { NotificationPanel, provideNotificationManager } from '@modrinth/ui'
import { provideApi } from '@modrinth/ui/src/providers/api.ts'
import { RestModrinthApi } from '@modrinth/utils'
import ModrinthLoadingIndicator from '~/components/ui/modrinth-loading-indicator.ts'
import { FrontendNotificationManager } from './providers/frontend-notifications.ts'
provideNotificationManager(new FrontendNotificationManager())
provideApi(
new RestModrinthApi((url: string, options?: object) => {
const match = url.match(/^\/v(\d+)\/(.+)$/)
if (match) {
const apiVersion = Number(match[1])
const path = match[2]
return useBaseFetch(path, {
...options,
apiVersion,
}) as Promise<Response>
} else {
throw new Error('Invalid format')
}
}),
)
</script>

View File

@@ -42,6 +42,7 @@
padding: 0 1.5rem;
grid-template:
'header'
'sidebar'
'content'
'info'

View File

@@ -1,23 +1,18 @@
<template>
<NuxtLink v-if="link !== null" class="nav-link button-base" :to="link">
<div class="nav-content">
<slot />
<span>{{ label }}</span>
<span v-if="beta" class="beta-badge">BETA</span>
<span v-if="chevron" class="chevron"><ChevronRightIcon /></span>
</div>
<NuxtLink v-if="link !== null" :to="link" class="nav-item">
<slot />
<span>{{ label }}</span>
<span v-if="badge" class="rounded-full bg-brand-highlight px-2 text-sm font-bold text-brand">{{
badge
}}</span>
<span v-if="chevron" class="ml-auto"><ChevronRightIcon /></span>
</NuxtLink>
<button
v-else-if="action"
class="nav-link button-base"
:class="{ 'danger-button': danger }"
@click="action"
>
<span class="nav-content">
<slot />
<span>{{ label }}</span>
<span v-if="beta" class="beta-badge">BETA</span>
</span>
<button v-else-if="action" class="nav-item" :class="{ 'danger-button': danger }" @click="action">
<slot />
<span>{{ label }}</span>
<span v-if="badge" class="rounded-full bg-brand-highlight px-2 text-sm font-bold text-brand">{{
badge
}}</span>
</button>
<span v-else>i forgor 💀</span>
</template>
@@ -42,9 +37,9 @@ export default {
required: true,
type: String,
},
beta: {
default: false,
type: Boolean,
badge: {
default: null,
type: String,
},
chevron: {
default: false,
@@ -59,58 +54,11 @@ export default {
</script>
<style lang="scss" scoped>
.nav-link {
font-weight: var(--font-weight-bold);
background-color: transparent;
color: var(--text-color);
position: relative;
display: flex;
flex-direction: row;
gap: 0.25rem;
box-shadow: none;
padding: 0;
width: 100%;
outline: none;
.nav-item {
@apply flex w-full cursor-pointer items-center gap-2 text-nowrap rounded-xl border-none bg-transparent px-4 py-2 text-left font-semibold text-button-text transition-all hover:bg-button-bg hover:text-contrast active:scale-[0.97];
}
:where(.nav-link) {
--text-color: var(--color-text);
--background-color: var(--color-raised-bg);
}
.nav-content {
box-sizing: border-box;
padding: 0.5rem 0.75rem;
border-radius: var(--size-rounded-sm);
display: flex;
align-items: center;
gap: 0.4rem;
flex-grow: 1;
background-color: var(--background-color);
}
&:focus-visible {
.nav-content {
border-radius: 0.25rem;
}
}
&.router-link-exact-active {
outline: 2px solid transparent;
border-radius: 0.25rem;
.nav-content {
color: var(--color-button-text-active);
background-color: var(--color-button-bg);
box-shadow: none;
}
}
.beta-badge {
margin: 0;
}
.chevron {
margin-left: auto;
}
.router-link-exact-active.nav-item {
@apply bg-button-bgSelected text-button-textSelected;
}
</style>

View File

@@ -7,17 +7,17 @@
</h2>
<div class="flex flex-row gap-2">
<div class="flex items-center gap-1">
<AsteriskIcon class="size-4 text-red" />
<AsteriskIcon class="size-4 shrink-0 text-red" />
<span class="text-secondary">{{ getFormattedMessage(messages.required) }}</span>
</div>
|
<div class="flex items-center gap-1">
<TriangleAlertIcon class="size-4 text-orange" />
<TriangleAlertIcon class="size-4 shrink-0 text-orange" />
<span class="text-secondary">{{ getFormattedMessage(messages.warning) }}</span>
</div>
|
<div class="flex items-center gap-1">
<LightBulbIcon class="size-4 text-purple" />
<LightBulbIcon class="size-4 shrink-0 text-purple" />
<span class="text-secondary">{{ getFormattedMessage(messages.suggestion) }}</span>
</div>
</div>

View File

@@ -35,6 +35,8 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
showProjectPageDownloadModalServersPromo: false,
showProjectPageCreateServersTooltip: true,
showProjectPageQuickServerButton: false,
newProjectGeneralSettings: false,
newProjectEnvironmentSettings: true,
// advancedRendering: true,
// externalLinksNewTab: true,
// notUsingBlockers: false,

View File

@@ -77,6 +77,7 @@ export const initUserProjects = async () => {
if (auth.user && auth.user.id) {
try {
user.projects = await useBaseFetch(`user/${auth.user.id}/projects`)
user.projectsV3 = await useBaseFetch(`user/${auth.user.id}/projects`, { apiVersion: 3 })
} catch (err) {
console.error(err)
}

View File

@@ -683,21 +683,231 @@
"project.about.details.updated": {
"message": "Updated {date}"
},
"project.actions.create-server": {
"message": "Create a server"
},
"project.actions.create-server-tooltip": {
"message": "Create a server"
},
"project.actions.dont-show-again": {
"message": "Don't show again"
},
"project.actions.review-project": {
"message": "Review project"
},
"project.actions.servers-promo.description": {
"message": "Modrinth Servers is the easiest way to play with your friends without hassle!"
},
"project.actions.servers-promo.monthly": {
"message": " / month"
},
"project.actions.servers-promo.pricing": {
"message": "Starting at $5{monthly}"
},
"project.actions.servers-promo.title": {
"message": "Create a server"
},
"project.collections.create-new": {
"message": "Create new collection"
},
"project.collections.none-found": {
"message": "No collections found."
},
"project.description.title": {
"message": "Description"
},
"project.details.licensed": {
"message": "Licensed"
},
"project.download.game-version": {
"message": "Game version: {version}"
},
"project.download.game-version-error": {
"message": "Error: no game versions found"
},
"project.download.game-version-tooltip": {
"message": "{title} is only available for {version}"
},
"project.download.game-version-unsupported-tooltip": {
"message": "{title} does not support {gameVersion} for {platform}"
},
"project.download.install-with-app": {
"message": "Install with Modrinth App"
},
"project.download.no-app": {
"message": "Don't have Modrinth App?"
},
"project.download.no-versions-available": {
"message": "No versions available for {gameVersion} and {platform}."
},
"project.download.platform": {
"message": "Platform: {platform}"
},
"project.download.platform-error": {
"message": "Error: no platforms found"
},
"project.download.platform-tooltip": {
"message": "{title} is only available for {platform}"
},
"project.download.platform-unsupported-tooltip": {
"message": "{title} does not support {platform} for {gameVersion}"
},
"project.download.search-game-versions": {
"message": "Search game versions..."
},
"project.download.search-game-versions-label": {
"message": "Search game versions..."
},
"project.download.select-game-version": {
"message": "Select game version"
},
"project.download.select-platform": {
"message": "Select platform"
},
"project.download.show-all-versions": {
"message": "Show all versions"
},
"project.download.title": {
"message": "Download {title}"
},
"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."
},
"project.environment.migration.review-button": {
"message": "Review environment settings"
},
"project.environment.migration.title": {
"message": "Please review environment metadata"
},
"project.error.loading": {
"message": "Error loading project data{message}"
},
"project.error.page-not-found": {
"message": "The page could not be found"
},
"project.error.project-not-found": {
"message": "Project not found"
},
"project.gallery.title": {
"message": "Gallery"
},
"project.license.error": {
"message": "License text could not be retrieved."
},
"project.license.loading": {
"message": "Loading license text..."
},
"project.license.title": {
"message": "License"
},
"project.moderation.title": {
"message": "Moderation"
},
"project.navigation.changelog": {
"message": "Changelog"
},
"project.notification.icon-updated.message": {
"message": "Your project's icon has been updated."
},
"project.notification.icon-updated.title": {
"message": "Project icon updated"
},
"project.notification.updated.message": {
"message": "Your project has been updated."
},
"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."
},
"project.settings.general.name.placeholder.1": {
"message": "e.g. Nether Overhaul 2"
},
"project.settings.general.name.placeholder.2": {
"message": "e.g. Construction Equipment"
},
"project.settings.general.name.placeholder.3": {
"message": "e.g. Better than Caving"
},
"project.settings.general.name.placeholder.4": {
"message": "e.g. Enhanced Portals"
},
"project.settings.general.name.placeholder.5": {
"message": "e.g. Dangerous Mobs"
},
"project.settings.general.name.title": {
"message": "Name"
},
"project.settings.general.tagline.description": {
"message": "Summarize your project in no more than one sentence."
},
"project.settings.general.tagline.placeholder.1": {
"message": "e.g. Overhauls game progression to revolve around the Nether."
},
"project.settings.general.tagline.placeholder.2": {
"message": "e.g. Adds wearable construction gear."
},
"project.settings.general.tagline.placeholder.3": {
"message": "e.g. Adds realistic mineshaft-building mechanics."
},
"project.settings.general.tagline.placeholder.4": {
"message": "e.g. Improves how Nether portals link to each other."
},
"project.settings.general.tagline.placeholder.5": {
"message": "e.g. Adds powerful boss versions of the normal mobs to encounter in the night."
},
"project.settings.general.tagline.title": {
"message": "Tagline"
},
"project.settings.general.url.title": {
"message": "URL"
},
"project.settings.title": {
"message": "Settings"
},
"project.settings.visit-dashboard": {
"message": "Visit projects dashboard"
},
"project.stats.downloads-label": {
"message": "download{count, plural, one {} other {s}}"
},
"project.stats.followers-label": {
"message": "follower{count, plural, one {} other {s}}"
},
"project.status.archived.message": {
"message": "{title} has been archived. {title} will not receive any further updates unless the author decides to unarchive the project."
},
"project.version.all-versions": {
"message": "All versions"
},

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 {

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -1,5 +1,12 @@
{
"articles": [
{
"title": "Creators: Verify Your Environment Metadata",
"summary": "We've overhauled the environment metadata on Modrinth, and all creators must verify their settings.",
"thumbnail": "https://modrinth.com/news/article/new-environments/thumbnail.webp",
"date": "2025-08-28T07:00:00.000Z",
"link": "https://modrinth.com/news/article/new-environments"
},
{
"title": "Get a Free Modrinth Server",
"summary": "In partnership with Medal.tv, get a 5-day free trial for Modrinth Servers",

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,80 @@
---
title: 'Creators: Verify Your Environment Metadata'
summary: We've overhauled the environment metadata on Modrinth, and all creators must verify their settings.
date: 2025-08-28T00:00:00-07:00
authors: ['Dc7EYhxG']
---
**Hey creators!**
Over the years, we've taken in lots of feedback regarding how we identify client-side and server-side mods and modpacks on Modrinth. It's a surprisingly nuanced issue, and careful consideration has finally led us to implementing a new system that fixes many of the issues with the old one.
## What do I need to do?
If you want to jump right into what you need to do now, just visit your [Projects page](/dashboard/projects) and look for any of your mod or modpack projects with an orange warning button next to the settings button. This will take you to the new Environments page in your project's settings with a bunch of different options for configuring your project's environment.
![Screenshot of the new Modrinth Environment settings page. A warning message explains that the environment system has been updated. Options are listed with radio buttons, including Client-side only (selected), Server-side only, Client and server, and Singleplayer only, each with descriptions and sub-options.](./env-settings.webp)
**If you do not verify your environments, in the future a warning may display on your project to inform users that the environment information may be outdated or incorrect.**
Read on to learn more about why we've made this change and for a more thorough explanation of each option, in case you find any of them confusing.
## What was wrong with the old system?
Originally, Modrinth's environment metadata came in the form of two fields for Client-side and Server-side compatibility. Each option could be set to 'Required', 'Optional', or 'Unsupported'. This did the job, but it left some ambiguities and led to many confused creators mis-labeling their mods.
1. **Certain combinations of options don't make logical sense**, such as both sides being 'Unsupported', or one side being 'Optional' and the other being 'Unsupported'. To some people, they may feel that since _installing_ the mod is optional, that might be a logical choice, when users and automated tools might be expecting it to be labeled as 'Required'
2. **Terms like 'Unsupported' are interpreted differently** by different people. If something is 'Unsupported' on the server-side, does that mean it crashes when installed on a server?
3. **Most server-side only mods also work in Singleplayer**, even if they don't perform any functions on the client-side directly. Some creators of server-sided mods chose to mark client-side as 'Optional' because of this, even if it did absolutely nothing on the client-side because in order to use it in Singleplayer, you technically install it on the "client"
4. **Not all real-world combinations even could be represented** by this old system. There are some mods that only make sense in a singleplayer environment, or some that only make sense on dedicated servers and _not_ in Singleplayer.
5. **Conflicting information is out there** on what exactly these terms meant. The website told creators to treat the client and server as the _logical_ client and servers, but some other people's guides and tooling treated them as referring to the _physical_ client and server. This includes the Modrinth Pack (.mrpack) specification, which confusingly uses the same required/optional/unsupported terminology to refer to the physical sides when defining which files should be installed in the client and server distributions.
## How does the new system work?
The new system enumerates all expected use-cases into distinct options that can be handled in unique ways by tools like launchers, mod managers, and modpack assemblers.
The new options are as follows:
- **Client-side only** (`client_only`)
- All functionality is performed exclusively on the client side. Should be compatible with vanilla servers.
- Example: [Mod Menu](/mod/modmenu). It only adds a menu to view the list of mods installed on your client, which doesn't need to be installed on the server.
- **Server-side only / Works in singleplayer** (`server_only`)
- All functionality is performed exclusively on the server side. Should be compatible with vanilla clients if only installed on the server. Also works in Singleplayer.
- Example: [YUNG's Bridges](/mod/yungs-bridges). It only adds structures which don't need to be present on the client-side.
- **Server-side only / Dedicated server only** (`dedicated_server_only`)
- Only runs on a dedicated server, and not in Singleplayer.
- Example: [Better Fabric Console](/mod/better-fabric-console). Its functionality does not work in singleplayer, because it modifies the dedicated server console.
- **Client and server / Required on both** (`client_and_server`)
- Must be installed on both the client and server.
- Example: [Cobblemon](/mod/cobblemon). It adds entities, blocks, and items that need to be on both the client and server to work.
- **Client and server / Optional on client** (`server_only_client_optional`)
- Must be on the server, but can be on the client as well for enhanced functionality
- Example: [Polymer](/mod/polymer). It functions on the server-side, but if installed on the client it can improve the experience when playing on a server running Polymer.
- **Client and server / Optional on server** (`client_only_server_optional`)
- Must be on the client, but can be on the server as well for enhanced functionality
- Example: [AppleSkin](/mod/appleskin). It functions on the client-side, but if installed on the server it can provide more accurate saturation information.
- **Client or server / Works best on both** (`client_or_server_prefers_both`)
- Can be installed on just the client or just the server to function, but functionality is enhanced when it is on both.
- Example: [No Chat Reports](/mod/no-chat-reports). The mod functions on just the client or just the server, but each comes with drawbacks. For the best functionality, you need to install it on both.
- **Client or server / Works the same on either** (`client_or_server`)
- Can be installed on just the client or just the server, and either one would enable full functionality. There would be no reason to install it on both.
- Example: [Entity View Distance](/mod/entity-view-distance). It lets you perform the same functionality of limiting entity view distance on either the client or the server.
- **Singleplayer only** (`singleplayer_only`)
- Only works in Singleplayer, does not function in a Multiplayer environment.
- Example: [LAN Server Properties](/mod/lan-server-properties). It modifies a feature that only exists in Singleplayer, the Open to LAN menu.
## What's next
This is a great first step towards us fixing many common issues that have been affecting Modrinth users, such as:
- Client-side mods being installed to Modrinth Servers, causing crashes
- Modpack exporting in Modrinth App and other launchers using the Modrinth API such as Prism Launcher, MultiMC, and ATLauncher not having accurate and reliable metadata to pull from in order to build universal client and server Modrinth Pack files.
However, this is just the first step. Before we can improve the tooling around creating and using modpacks, we need as many Modrinth projects as possible to have accurate metadata.
Currently, the new environment metadata is also only available in the experimental API v3, which is _not_ intended for general use. When we're ready, we plan to integrate this metadata into API v2, so that it can be used in production by third parties. For now, developers using the Modrinth API should not worry about these environment changes, just keep them in mind for the future.
Thank you all for continuing to support Modrinth!
**Prospector**\
_Founding Software Engineer_

View File

@@ -17,6 +17,7 @@ import { article as modpacks_alpha } from "./modpacks_alpha";
import { article as modrinth_app_beta } from "./modrinth_app_beta";
import { article as modrinth_beta } from "./modrinth_beta";
import { article as modrinth_servers_beta } from "./modrinth_servers_beta";
import { article as new_environments } from "./new_environments";
import { article as new_site_beta } from "./new_site_beta";
import { article as plugins_resource_packs } from "./plugins_resource_packs";
import { article as pride_campaign_2025 } from "./pride_campaign_2025";
@@ -37,6 +38,7 @@ export const articles = [
pride_campaign_2025,
plugins_resource_packs,
new_site_beta,
new_environments,
modrinth_servers_beta,
modrinth_beta,
modrinth_app_beta,

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,11 @@
// AUTO-GENERATED FILE - DO NOT EDIT
export const article = {
html: () => import(`./new_environments.content`).then(m => m.html),
title: "Creators: Verify Your Environment Metadata",
summary: "We've overhauled the environment metadata on Modrinth, and all creators must verify their settings.",
date: "2025-08-28T07:00:00.000Z",
slug: "new-environments",
authors: ["Dc7EYhxG"],
thumbnail: true,
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -224,7 +224,7 @@ export const coreNags: Nag[] = [
id: 'select-environments',
title: defineMessage({
id: 'nags.select-environments.title',
defaultMessage: 'Select supported environments',
defaultMessage: 'Select environments',
}),
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
@@ -232,7 +232,7 @@ export const coreNags: Nag[] = [
return formatMessage(
defineMessage({
id: 'nags.select-environments.description',
defaultMessage: `Select if the {projectType} functions on the client-side and/or server-side.`,
defaultMessage: `Select whether the {projectType} functions on the client and/or server-side.`,
}),
{
projectType: formatProjectType(context.project.project_type).toLowerCase(),
@@ -252,12 +252,12 @@ export const coreNags: Nag[] = [
)
},
link: {
path: 'settings',
path: 'settings/environment',
title: defineMessage({
id: 'nags.settings.environments.title',
defaultMessage: 'Visit general settings',
defaultMessage: 'Visit environment settings',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings',
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-environment',
},
},
{

View File

@@ -123,10 +123,10 @@
"defaultMessage": "Select correct resolution"
},
"nags.select-environments.description": {
"defaultMessage": "Select if the {projectType} functions on the client-side and/or server-side."
"defaultMessage": "Select whether the {projectType} functions on the client and/or server-side."
},
"nags.select-environments.title": {
"defaultMessage": "Select supported environments"
"defaultMessage": "Select environments"
},
"nags.select-license.description": {
"defaultMessage": "Select the license your {projectType} is distributed under."
@@ -144,7 +144,7 @@
"defaultMessage": "Visit description settings"
},
"nags.settings.environments.title": {
"defaultMessage": "Visit general settings"
"defaultMessage": "Visit environment settings"
},
"nags.settings.license.title": {
"defaultMessage": "Visit license settings"

View File

@@ -0,0 +1,76 @@
<script setup lang="ts">
import { EditIcon, TrashIcon, UploadIcon } from '@modrinth/assets'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { Avatar, OverflowMenu } from '../index'
const { formatMessage } = useVIntl()
const icon = defineModel<string | undefined>()
const emit = defineEmits<{
(e: 'select' | 'reset' | 'remove'): void
}>()
type IconSelectOption = 'select' | 'replace' | 'reset' | 'remove'
withDefaults(
defineProps<{
options?: IconSelectOption[]
}>(),
{
options: () => ['select', 'replace', 'reset', 'remove'],
},
)
const messages = defineMessages({
editIcon: {
id: 'icon-select.edit',
defaultMessage: 'Edit icon',
},
selectIcon: {
id: 'icon-select.select',
defaultMessage: 'Select icon',
},
replaceIcon: {
id: 'icon-select.replace',
defaultMessage: 'Replace icon',
},
removeIcon: {
id: 'icon-select.remove',
defaultMessage: 'Remove icon',
},
})
</script>
<template>
<OverflowMenu
v-tooltip="formatMessage(messages.editIcon)"
class="m-0 cursor-pointer appearance-none border-none bg-transparent p-0 transition-transform group-active:scale-95"
:options="[
{
id: 'select',
action: () => emit('select'),
},
{
id: 'remove',
color: 'danger',
action: () => emit('remove'),
shown: !!icon,
},
]"
>
<Avatar :src="icon" size="108px" class="!border-4 group-hover:brightness-75" no-shadow />
<div class="absolute right-0 top-0 m-2">
<div
class="hovering-icon-shadow m-0 flex aspect-square items-center justify-center rounded-full border-[1px] border-solid border-button-border bg-button-bg p-2 text-primary"
>
<EditIcon aria-hidden="true" class="h-4 w-4 text-primary" />
</div>
</div>
<template #select>
<UploadIcon />
{{ icon ? formatMessage(messages.replaceIcon) : formatMessage(messages.selectIcon) }}
</template>
<template #remove> <TrashIcon /> {{ formatMessage(messages.removeIcon) }} </template>
</OverflowMenu>
</template>

View File

@@ -0,0 +1,22 @@
<template>
<button
class="px-4 py-3 text-left border-0 font-medium border-2 border-button-bg border-solid flex gap-2 transition-all cursor-pointer active:scale-[0.98] hover:bg-button-bg hover:brightness-[--hover-brightness] rounded-xl"
:class="selected ? 'text-contrast bg-button-bg' : 'text-primary bg-transparent'"
@click="emit('select')"
>
<RadioButtonCheckedIcon v-if="selected" class="text-brand h-5 w-5 shrink-0" />
<RadioButtonIcon v-else class="h-5 w-5 shrink-0" />
<slot />
</button>
</template>
<script setup lang="ts" generic="T">
import { RadioButtonCheckedIcon, RadioButtonIcon } from '@modrinth/assets'
const emit = defineEmits<{
(e: 'select'): void
}>()
defineProps<{
selected: boolean
}>()
</script>

View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
import type { MessageDescriptor } from '@vintl/vintl'
import { useVIntl } from '@vintl/vintl'
import { computed } from 'vue'
const { formatMessage } = useVIntl()
const props = withDefaults(
defineProps<{
id?: string
title: string | MessageDescriptor
description?: string | MessageDescriptor
}>(),
{
id: undefined,
description: undefined,
},
)
const formattedTitle = computed(() =>
typeof props.title === 'string' ? props.title : formatMessage(props.title),
)
const formattedDescription = computed(() =>
typeof props.description === 'string'
? props.description
: props.description
? formatMessage(props.description)
: undefined,
)
</script>
<template>
<div class="mb-2">
<label v-if="id" :for="id" class="text-lg font-extrabold text-contrast">
{{ formattedTitle }}
</label>
<p v-else class="m-0 text-lg font-extrabold text-contrast">
{{ formattedTitle }}
</p>
<p v-if="formattedDescription" class="text-sm m-0 text-secondary">
{{ formattedDescription }}
</p>
</div>
</template>

View File

@@ -0,0 +1,106 @@
<script setup lang="ts" generic="T">
import { HistoryIcon, SaveIcon, SpinnerIcon } from '@modrinth/assets'
import { defineMessage, type MessageDescriptor, useVIntl } from '@vintl/vintl'
import { type Component, computed } from 'vue'
import { commonMessages } from '../../utils'
import ButtonStyled from './ButtonStyled.vue'
const { formatMessage } = useVIntl()
const emit = defineEmits<{
(e: 'reset' | 'save', event: MouseEvent): void
}>()
const props = withDefaults(
defineProps<{
canReset?: boolean
original: T
modified: Partial<T>
saving?: boolean
text?: MessageDescriptor | string
saveLabel?: MessageDescriptor | string
savingLabel?: MessageDescriptor | string
saveIcon?: Component
}>(),
{
canReset: true,
saving: false,
text: () =>
defineMessage({
id: 'ui.component.unsaved-changes-popup.body',
defaultMessage: 'You have unsaved changes.',
}),
saveLabel: () => commonMessages.saveButton,
savingLabel: () => commonMessages.savingButton,
saveIcon: SaveIcon,
},
)
const shown = computed(() => {
let changed = false
for (const key of Object.keys(props.modified)) {
if (props.original[key] !== props.modified[key]) {
changed = true
}
}
return changed
})
function localizeIfPossible(message: MessageDescriptor | string) {
return typeof message === 'string' ? message : formatMessage(message)
}
const bodyText = computed(() => localizeIfPossible(props.text))
const saveText = computed(() =>
localizeIfPossible(props.saving ? props.savingLabel : props.saveLabel),
)
</script>
<template>
<Transition name="pop-in">
<div v-if="shown" class="fixed w-full z-10 left-0 bottom-0 p-4">
<div
class="flex items-center rounded-2xl bg-bg-raised border-2 border-divider border-solid mx-auto max-w-[77rem] p-4"
>
<p class="m-0 font-semibold">{{ bodyText }}</p>
<div class="ml-auto flex gap-2">
<ButtonStyled v-if="canReset" type="transparent">
<button :disabled="saving" @click="(e) => emit('reset', e)">
<HistoryIcon /> {{ formatMessage(commonMessages.resetButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="saving" @click="(e) => emit('save', e)">
<SpinnerIcon v-if="saving" class="animate-spin" />
<component :is="saveIcon" v-else />
{{ saveText }}
</button>
</ButtonStyled>
</div>
</div>
</div>
</Transition>
</template>
<style scoped>
.pop-in-enter-active {
transition: all 0.5s cubic-bezier(0.15, 1.4, 0.64, 0.96);
}
.pop-in-leave-active {
transition: all 0.25s ease;
}
.pop-in-enter-from {
scale: 0.5;
translate: 0 10rem;
opacity: 0;
}
.pop-in-leave-to {
scale: 0.96;
translate: 0 0.25rem;
opacity: 0;
}
</style>

View File

@@ -22,6 +22,7 @@ export { default as FileInput } from './base/FileInput.vue'
export type { FilterBarOption } from './base/FilterBar.vue'
export { default as FilterBar } from './base/FilterBar.vue'
export { default as HeadingLink } from './base/HeadingLink.vue'
export { default as IconSelect } from './base/IconSelect.vue'
export { default as LoadingIndicator } from './base/LoadingIndicator.vue'
export { default as ManySelect } from './base/ManySelect.vue'
export { default as MarkdownEditor } from './base/MarkdownEditor.vue'
@@ -38,6 +39,7 @@ export { default as RadialHeader } from './base/RadialHeader.vue'
export { default as RadioButtons } from './base/RadioButtons.vue'
export { default as ScrollablePanel } from './base/ScrollablePanel.vue'
export { default as ServerNotice } from './base/ServerNotice.vue'
export { default as SettingsLabel } from './base/SettingsLabel.vue'
export { default as SimpleBadge } from './base/SimpleBadge.vue'
export { default as Slider } from './base/Slider.vue'
export { default as SmartClickable } from './base/SmartClickable.vue'
@@ -46,6 +48,7 @@ export { default as TagItem } from './base/TagItem.vue'
export { default as TeleportDropdownMenu } from './base/TeleportDropdownMenu.vue'
export { default as Timeline } from './base/Timeline.vue'
export { default as Toggle } from './base/Toggle.vue'
export { default as UnsavedChangesPopup } from './base/UnsavedChangesPopup.vue'
// Branding
export { default as AnimatedLogo } from './brand/AnimatedLogo.vue'
@@ -80,16 +83,7 @@ export { default as NotificationPanel } from './nav/NotificationPanel.vue'
export { default as PagewideBanner } from './nav/PagewideBanner.vue'
// Project
export { default as NewProjectCard } from './project/NewProjectCard.vue'
export { default as ProjectBackgroundGradient } from './project/ProjectBackgroundGradient.vue'
export { default as ProjectHeader } from './project/ProjectHeader.vue'
export { default as ProjectPageDescription } from './project/ProjectPageDescription.vue'
export { default as ProjectPageVersions } from './project/ProjectPageVersions.vue'
export { default as ProjectSidebarCompatibility } from './project/ProjectSidebarCompatibility.vue'
export { default as ProjectSidebarCreators } from './project/ProjectSidebarCreators.vue'
export { default as ProjectSidebarDetails } from './project/ProjectSidebarDetails.vue'
export { default as ProjectSidebarLinks } from './project/ProjectSidebarLinks.vue'
export { default as ProjectStatusBadge } from './project/ProjectStatusBadge.vue'
export * from './project'
// Search
export { default as BrowseFiltersPanel } from './search/BrowseFiltersPanel.vue'

View File

@@ -26,8 +26,17 @@
</TagItem>
</div>
</section>
<section v-if="showEnvironments" class="flex flex-col gap-2">
<h3 class="text-primary text-base m-0">{{ formatMessage(messages.environments) }}</h3>
<div class="flex flex-wrap gap-1">
<TagItem v-for="tag in primaryEnvironmentTags" :key="`environment-tag-${tag.message.id}`">
<component :is="tag.icon" />
{{ formatMessage(tag.message) }}
</TagItem>
</div>
</section>
<section
v-if="
v-else-if="
(project.project_type === 'mod' || project.project_type === 'modpack') &&
!(project.client_side === 'unsupported' && project.server_side === 'unsupported') &&
!(project.client_side === 'unknown' && project.server_side === 'unknown')
@@ -76,9 +85,10 @@
</template>
<script setup lang="ts">
import { ClientIcon, MonitorSmartphoneIcon, ServerIcon, UserIcon } from '@modrinth/assets'
import type { GameVersionTag, PlatformTag } from '@modrinth/utils'
import type { EnvironmentV3, GameVersionTag, PlatformTag, ProjectV3Partial } from '@modrinth/utils'
import { formatCategory, getVersionsToDisplay } from '@modrinth/utils'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { defineMessage, defineMessages, type MessageDescriptor, useVIntl } from '@vintl/vintl'
import { type Component, computed } from 'vue'
import { useRouter } from 'vue-router'
import TagItem from '../base/TagItem.vue'
@@ -88,7 +98,9 @@ const router = useRouter()
type EnvironmentValue = 'optional' | 'required' | 'unsupported' | 'unknown'
defineProps<{
const TYPES_WITH_ENVS = ['mod', 'modpack'] as const
const props = defineProps<{
project: {
actualProjectType: string
project_type: string
@@ -102,23 +114,112 @@ defineProps<{
gameVersions: GameVersionTag[]
loaders: PlatformTag[]
}
v3Metadata?: ProjectV3Partial
}>()
const showEnvironments = computed(
() =>
TYPES_WITH_ENVS.some((x) => props.v3Metadata?.project_types.includes(x)) &&
primaryEnvironment.value,
)
const primaryEnvironment = computed<EnvironmentV3 | undefined>(() =>
props.v3Metadata?.environment?.find((x) => x !== 'unknown'),
)
type EnvironmentTag = {
icon: Component
message: MessageDescriptor
environments: EnvironmentV3[]
}
const environmentTags: EnvironmentTag[] = [
{
icon: ClientIcon,
message: defineMessage({
id: `project.about.compatibility.environments.client-side`,
defaultMessage: 'Client-side',
}),
environments: [
'client_only',
'client_only_server_optional',
'client_or_server',
'client_or_server_prefers_both',
],
},
{
icon: ServerIcon,
message: defineMessage({
id: `project.about.compatibility.environments.server-side`,
defaultMessage: 'Server-side',
}),
environments: [
'server_only',
'server_only_client_optional',
'client_or_server',
'client_or_server_prefers_both',
],
},
{
icon: ServerIcon,
message: defineMessage({
id: `project.about.compatibility.environments.dedicated-servers-only`,
defaultMessage: 'Dedicated servers only',
}),
environments: ['dedicated_server_only'],
},
{
icon: UserIcon,
message: defineMessage({
id: `project.about.compatibility.environments.singleplayer-only`,
defaultMessage: 'Singleplayer only',
}),
environments: ['singleplayer_only'],
},
{
icon: UserIcon,
message: defineMessage({
id: `project.about.compatibility.environments.singleplayer`,
defaultMessage: 'Singleplayer',
}),
environments: ['server_only'],
},
{
icon: MonitorSmartphoneIcon,
message: defineMessage({
id: `project.about.compatibility.environments.client-and-server`,
defaultMessage: 'Client and server',
}),
environments: [
'client_and_server',
'client_only_server_optional',
'server_only_client_optional',
'client_or_server_prefers_both',
],
},
]
const primaryEnvironmentTags = computed(() => {
return primaryEnvironment.value
? environmentTags.filter((x) => x.environments.includes(primaryEnvironment.value ?? 'unknown'))
: []
})
const messages = defineMessages({
title: {
id: 'project.about.compatibility.title',
id: `project.about.compatibility.title`,
defaultMessage: 'Compatibility',
},
minecraftJava: {
id: 'project.about.compatibility.game.minecraftJava',
id: `project.about.compatibility.game.minecraftJava`,
defaultMessage: 'Minecraft: Java Edition',
},
platforms: {
id: 'project.about.compatibility.platforms',
id: `project.about.compatibility.platforms`,
defaultMessage: 'Platforms',
},
environments: {
id: 'project.about.compatibility.environments',
id: `project.about.compatibility.environments`,
defaultMessage: 'Supported environments',
},
})

View File

@@ -0,0 +1,14 @@
// Settings
export * from './settings'
// Other
export { default as NewProjectCard } from './NewProjectCard.vue'
export { default as ProjectBackgroundGradient } from './ProjectBackgroundGradient.vue'
export { default as ProjectHeader } from './ProjectHeader.vue'
export { default as ProjectPageDescription } from './ProjectPageDescription.vue'
export { default as ProjectPageVersions } from './ProjectPageVersions.vue'
export { default as ProjectSidebarCompatibility } from './ProjectSidebarCompatibility.vue'
export { default as ProjectSidebarCreators } from './ProjectSidebarCreators.vue'
export { default as ProjectSidebarDetails } from './ProjectSidebarDetails.vue'
export { default as ProjectSidebarLinks } from './ProjectSidebarLinks.vue'
export { default as ProjectStatusBadge } from './ProjectStatusBadge.vue'

View File

@@ -0,0 +1,271 @@
<script setup lang="ts">
import type { EnvironmentV3 } from '@modrinth/utils'
import { defineMessage, type MessageDescriptor, useVIntl } from '@vintl/vintl'
import { computed, ref, watch } from 'vue'
import LargeRadioButton from '../../../base/LargeRadioButton.vue'
const { formatMessage } = useVIntl()
const value = defineModel<EnvironmentV3 | undefined>({ required: true })
type EnvironmentRadioOption = {
title: MessageDescriptor
description?: MessageDescriptor
}
const OUTER_OPTIONS = {
client: {
title: defineMessage({
id: 'project.settings.environment.client_only.title',
defaultMessage: 'Client-side only',
}),
description: defineMessage({
id: 'project.settings.environment.client_only.description',
defaultMessage:
'All functionality is done client-side and is compatible with vanilla servers.',
}),
suboptions: {},
},
server: {
title: defineMessage({
id: 'project.settings.environment.server_only.title',
defaultMessage: 'Server-side only',
}),
description: defineMessage({
id: 'project.settings.environment.server_only.description',
defaultMessage:
'All functionality is done server-side and is compatible with vanilla clients.',
}),
suboptions: {
singleplayer: {
title: defineMessage({
id: 'project.settings.environment.server_only.supports_singleplayer.title',
defaultMessage: 'Works in singleplayer too',
}),
},
dedicated: {
title: defineMessage({
id: 'project.settings.environment.server_only.dedicated_only.title',
defaultMessage: 'Dedicated server only',
}),
},
},
},
client_and_server: {
title: defineMessage({
id: 'project.settings.environment.client_and_server.title',
defaultMessage: 'Client and server',
}),
description: defineMessage({
id: 'project.settings.environment.client_and_server.description',
defaultMessage:
'Has some functionality on both the client and server, even if only partially.',
}),
suboptions: {
required_both: {
title: defineMessage({
id: 'project.settings.environment.client_and_server.required_both.title',
defaultMessage: 'Required on both',
}),
},
optional_client: {
title: defineMessage({
id: 'project.settings.environment.client_and_server.optional_client.title',
defaultMessage: 'Optional on client',
}),
},
optional_server: {
title: defineMessage({
id: 'project.settings.environment.client_and_server.optional_server.title',
defaultMessage: 'Optional on server',
}),
},
optional_both_prefers_both: {
title: defineMessage({
id: 'project.settings.environment.client_and_server.optional_both_prefers_both.title',
defaultMessage: 'Optional on both, works best when installed on both sides',
}),
},
optional_both: {
title: defineMessage({
id: 'project.settings.environment.client_and_server.optional_both.title',
defaultMessage: 'Optional on both, works the same if installed on either side',
}),
},
},
},
singleplayer: {
title: defineMessage({
id: 'project.settings.environment.singleplayer.title',
defaultMessage: 'Singleplayer only',
}),
description: defineMessage({
id: 'project.settings.environment.singleplayer.description',
defaultMessage: `Only functions in Singleplayer or when not connected to a Multiplayer server.`,
}),
suboptions: {},
},
} as const satisfies Record<
string,
EnvironmentRadioOption & { suboptions: Record<string, EnvironmentRadioOption> }
>
type OuterOptionKey = keyof typeof OUTER_OPTIONS
type SubOptionKey = ValidKeys<(typeof OUTER_OPTIONS)[keyof typeof OUTER_OPTIONS]['suboptions']>
const currentOuterOption = ref<OuterOptionKey>()
const currentSubOption = ref<SubOptionKey>()
const computedOption = computed<EnvironmentV3>(() => {
switch (currentOuterOption.value) {
case 'client':
return 'client_only'
case 'server':
switch (currentSubOption.value) {
case 'singleplayer':
return 'server_only'
case 'dedicated':
return 'dedicated_server_only'
default:
return 'unknown'
}
case 'client_and_server':
switch (currentSubOption.value) {
case 'required_both':
return 'client_and_server'
case 'optional_client':
return 'server_only_client_optional'
case 'optional_server':
return 'client_only_server_optional'
case 'optional_both_prefers_both':
return 'client_or_server_prefers_both'
case 'optional_both':
return 'client_or_server'
default:
return 'unknown'
}
case 'singleplayer':
return 'singleplayer_only'
default:
return 'unknown'
}
})
function loadEnvironmentValues(env?: EnvironmentV3) {
switch (env) {
case 'client_and_server':
currentOuterOption.value = 'client_and_server'
currentSubOption.value = 'required_both'
break
case 'client_only':
currentOuterOption.value = 'client'
currentSubOption.value = undefined
break
case 'client_only_server_optional':
currentOuterOption.value = 'client_and_server'
currentSubOption.value = 'optional_server'
break
case 'singleplayer_only':
currentOuterOption.value = 'singleplayer'
currentSubOption.value = undefined
break
case 'server_only':
currentOuterOption.value = 'server'
currentSubOption.value = 'singleplayer'
break
case 'server_only_client_optional':
currentOuterOption.value = 'client_and_server'
currentSubOption.value = 'optional_client'
break
case 'dedicated_server_only':
currentOuterOption.value = 'server'
currentSubOption.value = 'dedicated'
break
case 'client_or_server':
currentOuterOption.value = 'client_and_server'
currentSubOption.value = 'optional_both'
break
case 'client_or_server_prefers_both':
currentOuterOption.value = 'client_and_server'
currentSubOption.value = 'optional_both_prefers_both'
break
default:
currentOuterOption.value = undefined
currentSubOption.value = undefined
break
}
}
// Keep parent in sync when local radio selections change
watch(computedOption, (newValue) => {
if (value.value !== newValue) {
value.value = newValue
}
})
// Keep local selections in sync when parent model changes
watch(
() => value.value,
(newVal) => {
loadEnvironmentValues(newVal)
},
{ immediate: true },
)
const simulateSave = ref(false)
</script>
<template>
<template
v-for="({ title, description, suboptions }, key, index) in OUTER_OPTIONS"
:key="`env-option-${key}`"
>
<LargeRadioButton
class="!w-full"
:class="{ 'mt-2': index > 0 }"
:selected="currentOuterOption === key"
@select="
() => {
if (currentOuterOption !== key) {
currentSubOption = suboptions ? (Object.keys(suboptions)[0] as SubOptionKey) : undefined
}
currentOuterOption = key
simulateSave = false
}
"
>
<span class="flex flex-col">
<span>{{ formatMessage(title) }}</span>
<span v-if="description" class="text-sm text-secondary">{{
formatMessage(description)
}}</span>
</span>
</LargeRadioButton>
<div v-if="suboptions" class="pl-8">
<LargeRadioButton
v-for="(
{ title: suboptionTitle, description: suboptionDescription }, suboptionKey
) in suboptions"
:key="`env-option-${key}-${suboptionKey}`"
class="!w-full mt-2"
:class="{
'opacity-50': currentOuterOption !== key,
}"
:selected="currentSubOption === suboptionKey"
@select="
() => {
currentOuterOption = key
currentSubOption = suboptionKey
}
"
>
<span class="flex flex-col">
<span>{{ formatMessage(suboptionTitle) }}</span>
<span v-if="suboptionDescription" class="text-sm text-secondary">{{
formatMessage(suboptionDescription)
}}</span>
</span>
</LargeRadioButton>
</div>
</template>
</template>

View File

@@ -0,0 +1,2 @@
// Environment
export { default as ProjectSettingsEnvSelector } from './environment/ProjectSettingsEnvSelector.vue'

View File

@@ -1,4 +1,10 @@
{
"badge.new": {
"defaultMessage": "New"
},
"button.analytics": {
"defaultMessage": "Analytics"
},
"button.back": {
"defaultMessage": "Back"
},
@@ -26,6 +32,12 @@
"button.edit": {
"defaultMessage": "Edit"
},
"button.follow": {
"defaultMessage": "Follow"
},
"button.more-options": {
"defaultMessage": "More options"
},
"button.next": {
"defaultMessage": "Next"
},
@@ -47,12 +59,18 @@
"button.report": {
"defaultMessage": "Report"
},
"button.reset": {
"defaultMessage": "Reset"
},
"button.save": {
"defaultMessage": "Save"
},
"button.save-changes": {
"defaultMessage": "Save changes"
},
"button.saving": {
"defaultMessage": "Saving"
},
"button.sign-in": {
"defaultMessage": "Sign in"
},
@@ -62,6 +80,9 @@
"button.stop": {
"defaultMessage": "Stop"
},
"button.unfollow": {
"defaultMessage": "Unfollow"
},
"button.upload-image": {
"defaultMessage": "Upload image"
},
@@ -83,6 +104,21 @@
"collection.label.private": {
"defaultMessage": "Private"
},
"icon-select.edit": {
"defaultMessage": "Edit icon"
},
"icon-select.remove": {
"defaultMessage": "Remove icon"
},
"icon-select.replace": {
"defaultMessage": "Replace icon"
},
"icon-select.select": {
"defaultMessage": "Select icon"
},
"input.search.placeholder": {
"defaultMessage": "Search..."
},
"input.view.gallery": {
"defaultMessage": "Gallery view"
},
@@ -140,6 +176,9 @@
"label.notifications": {
"defaultMessage": "Notifications"
},
"label.or": {
"defaultMessage": "or"
},
"label.password": {
"defaultMessage": "Password"
},
@@ -152,6 +191,9 @@
"label.rejected": {
"defaultMessage": "Rejected"
},
"label.saved": {
"defaultMessage": "Saved"
},
"label.scopes": {
"defaultMessage": "Scopes"
},
@@ -311,6 +353,24 @@
"project.about.compatibility.environments": {
"defaultMessage": "Supported environments"
},
"project.about.compatibility.environments.client-and-server": {
"defaultMessage": "Client and server"
},
"project.about.compatibility.environments.client-side": {
"defaultMessage": "Client-side"
},
"project.about.compatibility.environments.dedicated-servers-only": {
"defaultMessage": "Dedicated servers only"
},
"project.about.compatibility.environments.server-side": {
"defaultMessage": "Server-side"
},
"project.about.compatibility.environments.singleplayer": {
"defaultMessage": "Singleplayer"
},
"project.about.compatibility.environments.singleplayer-only": {
"defaultMessage": "Singleplayer only"
},
"project.about.compatibility.game.minecraftJava": {
"defaultMessage": "Minecraft: Java Edition"
},
@@ -377,6 +437,87 @@
"project.about.links.wiki": {
"defaultMessage": "Visit wiki"
},
"project.settings.analytics.title": {
"defaultMessage": "Analytics"
},
"project.settings.description.title": {
"defaultMessage": "Description"
},
"project.settings.environment.client_and_server.description": {
"defaultMessage": "Has some functionality on both the client and server, even if only partially."
},
"project.settings.environment.client_and_server.optional_both.title": {
"defaultMessage": "Optional on both, works the same if installed on either side"
},
"project.settings.environment.client_and_server.optional_both_prefers_both.title": {
"defaultMessage": "Optional on both, works best when installed on both sides"
},
"project.settings.environment.client_and_server.optional_client.title": {
"defaultMessage": "Optional on client"
},
"project.settings.environment.client_and_server.optional_server.title": {
"defaultMessage": "Optional on server"
},
"project.settings.environment.client_and_server.required_both.title": {
"defaultMessage": "Required on both"
},
"project.settings.environment.client_and_server.title": {
"defaultMessage": "Client and server"
},
"project.settings.environment.client_only.description": {
"defaultMessage": "All functionality is done client-side and is compatible with vanilla servers."
},
"project.settings.environment.client_only.title": {
"defaultMessage": "Client-side only"
},
"project.settings.environment.server_only.dedicated_only.title": {
"defaultMessage": "Dedicated server only"
},
"project.settings.environment.server_only.description": {
"defaultMessage": "All functionality is done server-side and is compatible with vanilla clients."
},
"project.settings.environment.server_only.supports_singleplayer.title": {
"defaultMessage": "Works in singleplayer too"
},
"project.settings.environment.server_only.title": {
"defaultMessage": "Server-side only"
},
"project.settings.environment.singleplayer.description": {
"defaultMessage": "Only functions in Singleplayer or when not connected to a Multiplayer server."
},
"project.settings.environment.singleplayer.title": {
"defaultMessage": "Singleplayer only"
},
"project.settings.environment.title": {
"defaultMessage": "Environment"
},
"project.settings.gallery.title": {
"defaultMessage": "Gallery"
},
"project.settings.general.title": {
"defaultMessage": "General"
},
"project.settings.license.title": {
"defaultMessage": "License"
},
"project.settings.links.title": {
"defaultMessage": "Links"
},
"project.settings.members.title": {
"defaultMessage": "Members"
},
"project.settings.tags.title": {
"defaultMessage": "Tags"
},
"project.settings.upload.title": {
"defaultMessage": "Upload"
},
"project.settings.versions.title": {
"defaultMessage": "Versions"
},
"project.settings.view.title": {
"defaultMessage": "View"
},
"project.versions.channel.alpha.symbol": {
"defaultMessage": "A"
},
@@ -619,5 +760,8 @@
},
"tooltip.date-at-time": {
"defaultMessage": "{date, date, long} at {time, time, short}"
},
"ui.component.unsaved-changes-popup.body": {
"defaultMessage": "You have unsaved changes."
}
}

View File

@@ -0,0 +1,5 @@
import type { ModrinthApi } from '@modrinth/utils'
import { createContext } from '.'
export const [injectApi, provideApi] = createContext<ModrinthApi>('root', 'apiContext')

View File

@@ -78,4 +78,5 @@ export function createContext<ContextValue>(
return [injectContext, provideContext] as const
}
export * from './project-page'
export * from './web-notifications'

View File

@@ -0,0 +1,13 @@
import type { Project, ProjectV3Partial } from '@modrinth/utils'
import type { Ref } from 'vue'
import { createContext } from '.'
export interface ProjectPageContext {
projectV2: Ref<Project>
projectV3: Ref<ProjectV3Partial>
refreshProject: () => Promise<void>
}
export const [injectProjectPageContext, provideProjectPageContext] =
createContext<ProjectPageContext>('root', 'projectPageContext')

2
packages/ui/src/types.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
type NonEmptyObject<T> = T extends object ? (keyof T extends never ? never : T) : never
type ValidKeys<T> = NonEmptyObject<T> extends infer O ? (O extends object ? keyof O : never) : never

View File

@@ -1,14 +1,26 @@
import { defineMessages } from '@vintl/vintl'
export const commonMessages = defineMessages({
analyticsButton: {
id: 'button.analytics',
defaultMessage: 'Analytics',
},
allProjectType: {
id: 'project-type.all',
defaultMessage: 'All',
},
backButton: {
id: 'button.back',
defaultMessage: 'Back',
},
cancelButton: {
id: 'button.cancel',
defaultMessage: 'Cancel',
},
changesSavedLabel: {
id: 'label.changes-saved',
defaultMessage: 'Changes saved',
},
collectionsLabel: {
id: 'label.collections',
defaultMessage: 'Collections',
@@ -17,14 +29,6 @@ export const commonMessages = defineMessages({
id: 'button.continue',
defaultMessage: 'Continue',
},
nextButton: {
id: 'button.next',
defaultMessage: 'Next',
},
backButton: {
id: 'button.back',
defaultMessage: 'Back',
},
copyIdButton: {
id: 'button.copy-id',
defaultMessage: 'Copy ID',
@@ -33,10 +37,6 @@ export const commonMessages = defineMessages({
id: 'button.copy-permalink',
defaultMessage: 'Copy permanent link',
},
changesSavedLabel: {
id: 'label.changes-saved',
defaultMessage: 'Changes saved',
},
createAProjectButton: {
id: 'button.create-a-project',
defaultMessage: 'Create a project',
@@ -81,6 +81,10 @@ export const commonMessages = defineMessages({
id: 'notification.error.title',
defaultMessage: 'An error occurred',
},
followButton: {
id: 'button.follow',
defaultMessage: 'Follow',
},
followedProjectsLabel: {
id: 'label.followed-projects',
defaultMessage: 'Followed projects',
@@ -105,10 +109,38 @@ export const commonMessages = defineMessages({
id: 'label.moderation',
defaultMessage: 'Moderation',
},
moreOptionsButton: {
id: 'button.more-options',
defaultMessage: 'More options',
},
newBadge: {
id: 'badge.new',
defaultMessage: 'New',
},
nextButton: {
id: 'button.next',
defaultMessage: 'Next',
},
notificationsLabel: {
id: 'label.notifications',
defaultMessage: 'Notifications',
},
openFolderButton: {
id: 'button.open-folder',
defaultMessage: 'Open folder',
},
orLabel: {
id: 'label.or',
defaultMessage: 'or',
},
passwordLabel: {
id: 'label.password',
defaultMessage: 'Password',
},
paymentMethodCardDisplay: {
id: 'omorphia.component.purchase_modal.payment_method_card_display',
defaultMessage: '{card_brand} ending in {last_four}',
},
playButton: {
id: 'button.play',
defaultMessage: 'Play',
@@ -137,17 +169,17 @@ export const commonMessages = defineMessages({
id: 'button.remove',
defaultMessage: 'Remove',
},
removeImageButton: {
id: 'button.remove-image',
defaultMessage: 'Remove image',
},
reportButton: {
id: 'button.report',
defaultMessage: 'Report',
},
openFolderButton: {
id: 'button.open-folder',
defaultMessage: 'Open folder',
},
passwordLabel: {
id: 'label.password',
defaultMessage: 'Password',
resetButton: {
id: 'button.reset',
defaultMessage: 'Reset',
},
saveButton: {
id: 'button.save',
@@ -157,10 +189,22 @@ export const commonMessages = defineMessages({
id: 'button.save-changes',
defaultMessage: 'Save changes',
},
savedLabel: {
id: 'label.saved',
defaultMessage: 'Saved',
},
savingButton: {
id: 'button.saving',
defaultMessage: 'Saving',
},
scopesLabel: {
id: 'label.scopes',
defaultMessage: 'Scopes',
},
searchPlaceholder: {
id: 'input.search.placeholder',
defaultMessage: 'Search...',
},
serverLabel: {
id: 'label.server',
defaultMessage: 'Server',
@@ -193,6 +237,10 @@ export const commonMessages = defineMessages({
id: 'label.title',
defaultMessage: 'Title',
},
unfollowButton: {
id: 'button.unfollow',
defaultMessage: 'Unfollow',
},
unlistedLabel: {
id: 'label.unlisted',
defaultMessage: 'Unlisted',
@@ -201,10 +249,6 @@ export const commonMessages = defineMessages({
id: 'button.upload-image',
defaultMessage: 'Upload image',
},
removeImageButton: {
id: 'button.remove-image',
defaultMessage: 'Remove image',
},
visibilityLabel: {
id: 'label.visibility',
defaultMessage: 'Visibility',
@@ -213,60 +257,111 @@ export const commonMessages = defineMessages({
id: 'label.visit-your-profile',
defaultMessage: 'Visit your profile',
},
paymentMethodCardDisplay: {
id: 'omorphia.component.purchase_modal.payment_method_card_display',
defaultMessage: '{card_brand} ending in {last_four}',
},
})
export const commonSettingsMessages = defineMessages({
appearance: {
id: 'settings.appearance.title',
defaultMessage: 'Appearance',
},
language: {
id: 'settings.language.title',
defaultMessage: 'Language',
},
profile: {
id: 'settings.profile.title',
defaultMessage: 'Public profile',
},
account: {
id: 'settings.account.title',
defaultMessage: 'Account and security',
},
authorizedApps: {
id: 'settings.authorized-apps.title',
defaultMessage: 'Authorized apps',
},
sessions: {
id: 'settings.sessions.title',
defaultMessage: 'Sessions',
},
pats: {
id: 'settings.pats.title',
defaultMessage: 'Personal access tokens',
appearance: {
id: 'settings.appearance.title',
defaultMessage: 'Appearance',
},
applications: {
id: 'settings.applications.title',
defaultMessage: 'Your applications',
},
authorizedApps: {
id: 'settings.authorized-apps.title',
defaultMessage: 'Authorized apps',
},
billing: {
id: 'settings.billing.title',
defaultMessage: 'Billing and subscriptions',
},
language: {
id: 'settings.language.title',
defaultMessage: 'Language',
},
pats: {
id: 'settings.pats.title',
defaultMessage: 'Personal access tokens',
},
profile: {
id: 'settings.profile.title',
defaultMessage: 'Public profile',
},
sessions: {
id: 'settings.sessions.title',
defaultMessage: 'Sessions',
},
})
export const commonProjectSettingsMessages = defineMessages({
analytics: {
id: 'project.settings.analytics.title',
defaultMessage: 'Analytics',
},
description: {
id: 'project.settings.description.title',
defaultMessage: 'Description',
},
environment: {
id: 'project.settings.environment.title',
defaultMessage: 'Environment',
},
gallery: {
id: 'project.settings.gallery.title',
defaultMessage: 'Gallery',
},
general: {
id: 'project.settings.general.title',
defaultMessage: 'General',
},
license: {
id: 'project.settings.license.title',
defaultMessage: 'License',
},
links: {
id: 'project.settings.links.title',
defaultMessage: 'Links',
},
members: {
id: 'project.settings.members.title',
defaultMessage: 'Members',
},
tags: {
id: 'project.settings.tags.title',
defaultMessage: 'Tags',
},
upload: {
id: 'project.settings.upload.title',
defaultMessage: 'Upload',
},
versions: {
id: 'project.settings.versions.title',
defaultMessage: 'Versions',
},
view: {
id: 'project.settings.view.title',
defaultMessage: 'View',
},
})
export const paymentMethodMessages = defineMessages({
visa: {
id: 'omorphia.component.purchase_modal.payment_method_type.visa',
defaultMessage: 'Visa',
amazon_pay: {
id: 'omorphia.component.purchase_modal.payment_method_type.amazon_pay',
defaultMessage: 'Amazon Pay',
},
amex: {
id: 'omorphia.component.purchase_modal.payment_method_type.amex',
defaultMessage: 'American Express',
},
cashapp: {
id: 'omorphia.component.purchase_modal.payment_method_type.cashapp',
defaultMessage: 'Cash App',
},
diners: {
id: 'omorphia.component.purchase_modal.payment_method_type.diners',
defaultMessage: 'Diners Club',
@@ -279,29 +374,28 @@ export const paymentMethodMessages = defineMessages({
id: 'omorphia.component.purchase_modal.payment_method_type.eftpos',
defaultMessage: 'EFTPOS',
},
jcb: { id: 'omorphia.component.purchase_modal.payment_method_type.jcb', defaultMessage: 'JCB' },
jcb: {
id: 'omorphia.component.purchase_modal.payment_method_type.jcb',
defaultMessage: 'JCB',
},
mastercard: {
id: 'omorphia.component.purchase_modal.payment_method_type.mastercard',
defaultMessage: 'MasterCard',
},
unionpay: {
id: 'omorphia.component.purchase_modal.payment_method_type.unionpay',
defaultMessage: 'UnionPay',
},
paypal: {
id: 'omorphia.component.purchase_modal.payment_method_type.paypal',
defaultMessage: 'PayPal',
},
cashapp: {
id: 'omorphia.component.purchase_modal.payment_method_type.cashapp',
defaultMessage: 'Cash App',
},
amazon_pay: {
id: 'omorphia.component.purchase_modal.payment_method_type.amazon_pay',
defaultMessage: 'Amazon Pay',
unionpay: {
id: 'omorphia.component.purchase_modal.payment_method_type.unionpay',
defaultMessage: 'UnionPay',
},
unknown: {
id: 'omorphia.component.purchase_modal.payment_method_type.unknown',
defaultMessage: 'Unknown payment method',
},
visa: {
id: 'omorphia.component.purchase_modal.payment_method_type.visa',
defaultMessage: 'Visa',
},
})

View File

@@ -1,4 +1,5 @@
export * from './common-messages'
export * from './game-modes'
export * from './notices'
export * from './savable'
export * from './search'

View File

@@ -0,0 +1,38 @@
import type { ComputedRef, Ref } from 'vue'
import { computed, ref } from 'vue'
export function useSavable<T extends Record<string, unknown>>(
data: () => T,
save: (changes: Partial<T>) => void,
): {
saved: ComputedRef<T>
current: Ref<T>
reset: () => void
save: () => void
} {
const savedValues = computed(data)
const currentValues = ref({ ...data() }) as Ref<T>
const changes = computed<Partial<T>>(() => {
const values: Partial<T> = {}
const keys = Object.keys(currentValues.value) as (keyof T)[]
for (const key of keys) {
if (savedValues.value[key] !== currentValues.value[key]) {
values[key] = currentValues.value[key]
}
}
return values
})
const reset = () => {
currentValues.value = data()
}
const saveInternal = () => (changes.value ? save(changes.value) : {})
return {
saved: savedValues,
current: currentValues,
reset,
save: saveInternal,
}
}

View File

@@ -0,0 +1,41 @@
import type { Project, ProjectV3Partial } from '../types'
import type { ModrinthApi } from './index'
import type { ModrinthApiProjects, ProjectEditBody, ProjectV3EditBodyPartial } from './projects'
export class RestModrinthApi implements ModrinthApi {
projects: ModrinthApiProjects
constructor(requestApi: (url: string, options?: object) => Promise<Response>) {
this.projects = new RestModrinthApiProjects(requestApi)
}
}
class RestModrinthApiProjects implements ModrinthApiProjects {
constructor(private request: (url: string, options?: object) => Promise<Response>) {}
async get(id: string): Promise<Project> {
const res = await this.request(`/v2/project/${id}`)
return res.json()
}
async getV3(id: string): Promise<ProjectV3Partial> {
const res = await this.request(`/v3/project/${id}`)
return res.json()
}
async edit(id: string, data: ProjectEditBody): Promise<void> {
await this.request(`/v2/project/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
}
async editV3(id: string, data: ProjectV3EditBodyPartial): Promise<void> {
await this.request(`/v3/project/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
}
}

View File

@@ -0,0 +1,7 @@
import type { ModrinthApiProjects } from './projects'
export interface ModrinthApi {
projects: ModrinthApiProjects
}
export { RestModrinthApi } from './default_impl'

View File

@@ -0,0 +1,41 @@
import type {
DonationLink,
DonationPlatform,
Environment,
EnvironmentMigrationReviewStatus,
EnvironmentV3,
Project,
ProjectStatus,
ProjectV3Partial,
RequestableStatus,
} from '../types'
export type ProjectEditBody = {
slug?: string
title?: string
description?: string
categories?: string[]
client_side?: Environment
server_side?: Environment
status?: ProjectStatus
requested_status?: RequestableStatus
additional_categories?: string[]
issues_url?: string
source_url?: string
wiki_url?: string
discord_url?: string
donation_urls?: DonationLink<DonationPlatform>[]
license_id?: string
license_url?: string
}
export type ProjectV3EditBodyPartial = {
environment?: EnvironmentV3
side_types_migration_review_status: EnvironmentMigrationReviewStatus
}
export interface ModrinthApiProjects {
get(id: string): Promise<Project>
getV3(id: string): Promise<ProjectV3Partial>
edit(id: string, data: ProjectEditBody): Promise<void>
editV3(id: string, data: ProjectV3EditBodyPartial): Promise<void>
}

View File

@@ -1,3 +1,4 @@
export * from './api'
export * from './billing'
export * from './changelog'
export * from './highlight'

View File

@@ -112,13 +112,11 @@ export interface ProjectV3 {
color?: number
thread_id: ModrinthId
monetization_status: MonetizationStatus
side_types_migration_review_status: 'reviewed' | 'pending'
side_types_migration_review_status: EnvironmentMigrationReviewStatus
[key: string]: unknown
}
export type SideTypesMigrationReviewStatus = 'reviewed' | 'pending'
export interface Project {
id: ModrinthId
project_type: ProjectType
@@ -172,6 +170,26 @@ export interface Project {
}
}
export type EnvironmentMigrationReviewStatus = 'reviewed' | 'pending'
export type EnvironmentV3 =
| 'client_and_server'
| 'client_only'
| 'client_only_server_optional'
| 'singleplayer_only'
| 'server_only'
| 'server_only_client_optional'
| 'dedicated_server_only'
| 'client_or_server'
| 'client_or_server_prefers_both'
| 'unknown'
// This is only the fields we care about from v3, since we use v2 for the vast majority of project metadata.
export interface ProjectV3Partial {
side_types_migration_review_status: EnvironmentMigrationReviewStatus
environment: EnvironmentV3[]
project_types: ProjectType[]
}
export interface SearchResult {
id: ModrinthId
project_type: ProjectType