forked from didirus/AstralRinth
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:
@@ -1,6 +1,7 @@
|
||||
**/.nuxt
|
||||
**/dist
|
||||
**/.output
|
||||
**/.data
|
||||
src/generated/**
|
||||
src/locales/**
|
||||
src/public/news/feed
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
padding: 0 1.5rem;
|
||||
|
||||
grid-template:
|
||||
'header'
|
||||
'sidebar'
|
||||
'content'
|
||||
'info'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
162
apps/frontend/src/pages/[type]/[id]/settings.vue
Normal file
162
apps/frontend/src/pages/[type]/[id]/settings.vue
Normal 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>
|
||||
165
apps/frontend/src/pages/[type]/[id]/settings/environment.vue
Normal file
165
apps/frontend/src/pages/[type]/[id]/settings/environment.vue
Normal 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>
|
||||
192
apps/frontend/src/pages/[type]/[id]/settings/general.vue
Normal file
192
apps/frontend/src/pages/[type]/[id]/settings/general.vue
Normal 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>
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 |
@@ -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
80
packages/blog/articles/new-environments.md
Normal file
80
packages/blog/articles/new-environments.md
Normal 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.
|
||||
|
||||

|
||||
|
||||
**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_
|
||||
@@ -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,
|
||||
|
||||
2
packages/blog/compiled/new_environments.content.ts
Normal file
2
packages/blog/compiled/new_environments.content.ts
Normal file
File diff suppressed because one or more lines are too long
11
packages/blog/compiled/new_environments.ts
Normal file
11
packages/blog/compiled/new_environments.ts
Normal 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,
|
||||
|
||||
};
|
||||
BIN
packages/blog/public/new-environments/env-settings.webp
Normal file
BIN
packages/blog/public/new-environments/env-settings.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 124 KiB |
BIN
packages/blog/public/new-environments/thumbnail.webp
Normal file
BIN
packages/blog/public/new-environments/thumbnail.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
@@ -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',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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"
|
||||
|
||||
76
packages/ui/src/components/base/IconSelect.vue
Normal file
76
packages/ui/src/components/base/IconSelect.vue
Normal 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>
|
||||
22
packages/ui/src/components/base/LargeRadioButton.vue
Normal file
22
packages/ui/src/components/base/LargeRadioButton.vue
Normal 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>
|
||||
44
packages/ui/src/components/base/SettingsLabel.vue
Normal file
44
packages/ui/src/components/base/SettingsLabel.vue
Normal 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>
|
||||
106
packages/ui/src/components/base/UnsavedChangesPopup.vue
Normal file
106
packages/ui/src/components/base/UnsavedChangesPopup.vue
Normal 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>
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
|
||||
14
packages/ui/src/components/project/index.ts
Normal file
14
packages/ui/src/components/project/index.ts
Normal 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'
|
||||
@@ -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>
|
||||
2
packages/ui/src/components/project/settings/index.ts
Normal file
2
packages/ui/src/components/project/settings/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Environment
|
||||
export { default as ProjectSettingsEnvSelector } from './environment/ProjectSettingsEnvSelector.vue'
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
5
packages/ui/src/providers/api.ts
Normal file
5
packages/ui/src/providers/api.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { ModrinthApi } from '@modrinth/utils'
|
||||
|
||||
import { createContext } from '.'
|
||||
|
||||
export const [injectApi, provideApi] = createContext<ModrinthApi>('root', 'apiContext')
|
||||
@@ -78,4 +78,5 @@ export function createContext<ContextValue>(
|
||||
return [injectContext, provideContext] as const
|
||||
}
|
||||
|
||||
export * from './project-page'
|
||||
export * from './web-notifications'
|
||||
|
||||
13
packages/ui/src/providers/project-page.ts
Normal file
13
packages/ui/src/providers/project-page.ts
Normal 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
2
packages/ui/src/types.d.ts
vendored
Normal 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
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './common-messages'
|
||||
export * from './game-modes'
|
||||
export * from './notices'
|
||||
export * from './savable'
|
||||
export * from './search'
|
||||
|
||||
38
packages/ui/src/utils/savable.ts
Normal file
38
packages/ui/src/utils/savable.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
41
packages/utils/api/default_impl.ts
Normal file
41
packages/utils/api/default_impl.ts
Normal 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),
|
||||
})
|
||||
}
|
||||
}
|
||||
7
packages/utils/api/index.ts
Normal file
7
packages/utils/api/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { ModrinthApiProjects } from './projects'
|
||||
|
||||
export interface ModrinthApi {
|
||||
projects: ModrinthApiProjects
|
||||
}
|
||||
|
||||
export { RestModrinthApi } from './default_impl'
|
||||
41
packages/utils/api/projects.ts
Normal file
41
packages/utils/api/projects.ts
Normal 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>
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './api'
|
||||
export * from './billing'
|
||||
export * from './changelog'
|
||||
export * from './highlight'
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user