fix: various smaller fixes (#5917)

* fix: try fix email templates rendering links for variables

* fix: b is not a function

* fix: wording on modpack btn on setup type stage

* fix: respect launcher-meta info

* feat: i18n pass on creation flow modal

* fix: prefetch loader manifests

* fix: lint
This commit is contained in:
Calum H.
2026-04-27 17:27:41 +01:00
committed by GitHub
parent e8be67d41f
commit 6afda48e70
45 changed files with 1435 additions and 261 deletions

View File

@@ -176,6 +176,7 @@ const {
handleBrowseModpacks, handleBrowseModpacks,
searchModpacks, searchModpacks,
getProjectVersions, getProjectVersions,
getLoaderManifest,
setModpackAlreadyInstalledModal, setModpackAlreadyInstalledModal,
handleModpackDuplicateCreateAnyway, handleModpackDuplicateCreateAnyway,
handleModpackDuplicateGoToInstance, handleModpackDuplicateGoToInstance,
@@ -1108,6 +1109,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
:fetch-existing-instance-names="fetchExistingInstanceNames" :fetch-existing-instance-names="fetchExistingInstanceNames"
:search-modpacks="searchModpacks" :search-modpacks="searchModpacks"
:get-project-versions="getProjectVersions" :get-project-versions="getProjectVersions"
:get-loader-manifest="getLoaderManifest"
@create="handleCreate" @create="handleCreate"
@browse-modpacks="handleBrowseModpacks" @browse-modpacks="handleBrowseModpacks"
/> />

View File

@@ -38,6 +38,18 @@
"app.browse.install-content-to-instance": { "app.browse.install-content-to-instance": {
"message": "Install content to instance" "message": "Install content to instance"
}, },
"app.browse.project-type.modpacks": {
"message": "Modpacks"
},
"app.browse.server.install": {
"message": "Install"
},
"app.browse.server.installed": {
"message": "Installed"
},
"app.browse.server.installing": {
"message": "Installing"
},
"app.export-modal.description-placeholder": { "app.export-modal.description-placeholder": {
"message": "Enter modpack description..." "message": "Enter modpack description..."
}, },

View File

@@ -34,6 +34,7 @@ import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
import ContextMenu from '@/components/ui/ContextMenu.vue' import ContextMenu from '@/components/ui/ContextMenu.vue'
import { get_project_v3, get_search_results_v3 } from '@/helpers/cache.js' import { get_project_v3, get_search_results_v3 } from '@/helpers/cache.js'
import { process_listener } from '@/helpers/events' import { process_listener } from '@/helpers/events'
import { get_loader_versions as getLoaderManifest } from '@/helpers/metadata'
import { get_by_profile_path } from '@/helpers/process' import { get_by_profile_path } from '@/helpers/process'
import { import {
get as getInstance, get as getInstance,
@@ -441,10 +442,26 @@ const messages = defineMessages({
id: 'app.browse.install-content-to-instance', id: 'app.browse.install-content-to-instance',
defaultMessage: 'Install content to instance', defaultMessage: 'Install content to instance',
}, },
installToServer: {
id: 'app.browse.server.install',
defaultMessage: 'Install',
},
installedToServer: {
id: 'app.browse.server.installed',
defaultMessage: 'Installed',
},
installingToServer: {
id: 'app.browse.server.installing',
defaultMessage: 'Installing',
},
modLoaderProvidedByInstance: { modLoaderProvidedByInstance: {
id: 'search.filter.locked.instance-loader.title', id: 'search.filter.locked.instance-loader.title',
defaultMessage: 'Loader is provided by the instance', defaultMessage: 'Loader is provided by the instance',
}, },
modpacksProjectType: {
id: 'app.browse.project-type.modpacks',
defaultMessage: 'Modpacks',
},
modLoaderProvidedByServer: { modLoaderProvidedByServer: {
id: 'search.filter.locked.server-loader.title', id: 'search.filter.locked.server-loader.title',
defaultMessage: 'Loader is provided by the server', defaultMessage: 'Loader is provided by the server',
@@ -550,7 +567,9 @@ const selectableProjectTypes = computed(() => {
const suffix = queryString ? `?${queryString}` : '' const suffix = queryString ? `?${queryString}` : ''
if (isSetupServerContext.value) { if (isSetupServerContext.value) {
return [{ label: 'Modpacks', href: `/browse/modpack${suffix}` }] return [
{ label: formatMessage(messages.modpacksProjectType), href: `/browse/modpack${suffix}` },
]
} }
if (isFromWorlds.value) { if (isFromWorlds.value) {
@@ -730,7 +749,13 @@ function getCardActions(
return [ return [
{ {
key: 'install', key: 'install',
label: isInstalling ? 'Installing' : isInstalled ? 'Installed' : 'Install', label: formatMessage(
isInstalling
? messages.installingToServer
: isInstalled
? messages.installedToServer
: messages.installToServer,
),
icon: isInstalled ? CheckIcon : PlusIcon, icon: isInstalled ? CheckIcon : PlusIcon,
disabled: isInstalled || isInstalling, disabled: isInstalled || isInstalling,
color: 'brand', color: 'brand',
@@ -972,6 +997,7 @@ provideBrowseManager({
:on-back="onServerFlowBack" :on-back="onServerFlowBack"
:search-modpacks="searchServerModpacks" :search-modpacks="searchServerModpacks"
:get-project-versions="getServerProjectVersions" :get-project-versions="getServerProjectVersions"
:get-loader-manifest="getLoaderManifest"
@hide="() => {}" @hide="() => {}"
@browse-modpacks="() => {}" @browse-modpacks="() => {}"
@create="handleServerModpackFlowCreate" @create="handleServerModpackFlowCreate"

View File

@@ -11,6 +11,7 @@ import type ModpackAlreadyInstalledModal from '@/components/ui/modal/ModpackAlre
import { trackEvent } from '@/helpers/analytics' import { trackEvent } from '@/helpers/analytics'
import { get_project_versions, get_search_results } from '@/helpers/cache.js' import { get_project_versions, get_search_results } from '@/helpers/cache.js'
import { import_instance } from '@/helpers/import.js' import { import_instance } from '@/helpers/import.js'
import { get_loader_versions as getLoaderManifest } from '@/helpers/metadata.js'
import { create_profile_and_install, create_profile_and_install_from_file } from '@/helpers/pack' import { create_profile_and_install, create_profile_and_install_from_file } from '@/helpers/pack'
import { create, list } from '@/helpers/profile.js' import { create, list } from '@/helpers/profile.js'
import type { InstanceLoader } from '@/helpers/types' import type { InstanceLoader } from '@/helpers/types'
@@ -165,6 +166,7 @@ export function setupCreationModal(notificationManager: AbstractWebNotificationM
handleBrowseModpacks, handleBrowseModpacks,
searchModpacks, searchModpacks,
getProjectVersions, getProjectVersions,
getLoaderManifest,
setModpackAlreadyInstalledModal, setModpackAlreadyInstalledModal,
handleModpackDuplicateCreateAnyway, handleModpackDuplicateCreateAnyway,
handleModpackDuplicateGoToInstance, handleModpackDuplicateGoToInstance,

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { CopyIcon, LibraryIcon, PlayIcon, SearchIcon } from '@modrinth/assets' import { CopyIcon, LibraryIcon, PlayIcon, SearchIcon } from '@modrinth/assets'
import { ButtonStyled, Card, StyledInput } from '@modrinth/ui' import { ButtonStyled, Card, NewModal, StyledInput } from '@modrinth/ui'
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import emails from '~/templates/emails' import emails from '~/templates/emails'
@@ -14,7 +14,7 @@ const filtered = computed(() =>
function openAll() { function openAll() {
let offset = 0 let offset = 0
for (const id of filtered.value) { for (const id of filtered.value) {
openPreview(id, offset) openPopupPreview(id, offset)
offset++ offset++
} }
} }
@@ -23,7 +23,81 @@ function copy(id: string) {
navigator.clipboard?.writeText(`/_internal/templates/email/${id}`).catch(() => {}) navigator.clipboard?.writeText(`/_internal/templates/email/${id}`).catch(() => {})
} }
function openPreview(id: string, offset = 0) { const previewModal = ref<{ hide: () => void; show: () => void } | null>(null)
const previewTemplate = ref<string | null>(null)
const previewLoading = ref(false)
const previewError = ref<string | null>(null)
const previewHtml = ref('')
const previewVariables = ref<string[]>([])
const variableValues = ref<Record<string, string>>({})
function extractVariables(html: string): string[] {
const tokens = new Set<string>()
const regex = /\{([a-zA-Z0-9_.-]+)\}/g
let match = regex.exec(html)
while (match !== null) {
tokens.add(match[1])
match = regex.exec(html)
}
return [...tokens]
}
const renderedPreview = computed(() => {
let html = previewHtml.value
for (const [key, value] of Object.entries(variableValues.value)) {
if (!value) {
continue
}
const pattern = new RegExp(`\\{${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\}`, 'g')
html = html.replace(pattern, value)
}
return html
})
async function openPreview(id: string, event?: MouseEvent) {
if (event?.shiftKey) {
openPopupPreview(id)
return
}
previewTemplate.value = id
previewLoading.value = true
previewError.value = null
previewHtml.value = ''
previewVariables.value = []
variableValues.value = {}
try {
const response = await fetch(`/_internal/templates/email/${id}`)
previewHtml.value = await response.text()
if (!response.ok) {
throw new Error(`Failed to load template ${id}`)
}
const variables = extractVariables(previewHtml.value)
previewVariables.value = variables
variableValues.value = Object.fromEntries(variables.map((value) => [value, '']))
previewModal.value?.show()
} catch (error) {
previewError.value = 'Failed to load email preview.'
console.error(error)
previewModal.value?.show()
} finally {
previewLoading.value = false
}
}
function closePreview() {
previewModal.value?.hide()
}
function openPopupPreview(id: string, offset = 0) {
const width = 600 const width = 600
const height = 850 const height = 850
const left = window.screenX + (window.outerWidth - width) / 2 + ((offset * 28) % 320) const left = window.screenX + (window.outerWidth - width) / 2 + ((offset * 28) % 320)
@@ -48,6 +122,69 @@ onMounted(() => {
<template> <template>
<div class="normal-page no-sidebar"> <div class="normal-page no-sidebar">
<h1 class="mb-4 text-3xl font-extrabold text-heading">Email templates</h1> <h1 class="mb-4 text-3xl font-extrabold text-heading">Email templates</h1>
<NewModal
ref="previewModal"
header="Preview email"
width="min(92vw, 1000px)"
:max-content-height="'88vh'"
scrollable
>
<div class="flex flex-col gap-4">
<p class="label__title text-base">Template: {{ previewTemplate }}</p>
<div
v-if="previewError"
class="border-danger bg-danger/10 text-danger my-2 rounded border px-3 py-2 text-sm"
>
{{ previewError }}
</div>
<div v-if="previewLoading" class="my-4 text-sm text-secondary">Loading preview</div>
<div v-else>
<div v-if="previewVariables.length" class="mt-2 grid gap-3 md:grid-cols-2">
<label
v-for="variable in previewVariables"
:key="variable"
:for="`preview-${variable}`"
class="flex flex-col"
>
<span class="label__title">{{ variable }}</span>
<StyledInput
:id="`preview-${variable}`"
v-model="variableValues[variable]"
type="text"
:placeholder="`Enter ${variable}`"
/>
</label>
</div>
<p v-else class="mt-2 text-xs text-secondary">
No template variables were detected; preview shown using default values.
</p>
<div class="mt-4">
<div class="label__title mb-2">Rendered template</div>
<iframe
v-if="!previewError"
:srcdoc="renderedPreview"
class="h-[60vh] w-full rounded border border-divider bg-white"
sandbox="allow-same-origin"
/>
<div
v-else
class="rounded border border-divider bg-white px-4 py-3 text-sm text-secondary"
>
Could not render template preview.
</div>
</div>
<div class="input-group mt-4">
<button class="iconified-button transparent" type="button" @click="closePreview">
Close
</button>
</div>
</div>
</div>
</NewModal>
<div class="normal-page__content"> <div class="normal-page__content">
<Card class="mb-6 flex flex-col gap-4"> <Card class="mb-6 flex flex-col gap-4">
<div class="flex flex-wrap items-center gap-3"> <div class="flex flex-wrap items-center gap-3">
@@ -97,7 +234,7 @@ onMounted(() => {
<div class="mt-auto flex gap-2"> <div class="mt-auto flex gap-2">
<ButtonStyled color="brand" class="flex-1"> <ButtonStyled color="brand" class="flex-1">
<button class="w-full justify-center" @click="openPreview(id)"> <button class="w-full justify-center" @click="openPreview(id, $event)">
<PlayIcon class="h-4 w-4" aria-hidden="true" /> <PlayIcon class="h-4 w-4" aria-hidden="true" />
Preview Preview
</button> </button>

View File

@@ -11,7 +11,7 @@ import StyledEmail from '../shared/StyledEmail.vue'
> >
<Heading as="h1" class="mb-2 text-2xl font-bold"> New sign-in method added </Heading> <Heading as="h1" class="mb-2 text-2xl font-bold"> New sign-in method added </Heading>
<Text class="text-muted text-base">Hi {user.name},</Text> <Text class="text-muted text-base">Hi <span class="no-auto-link">{user.name}</span>,</Text>
<Text class="text-muted text-base"> <Text class="text-muted text-base">
Your {authprovider.name} account has been connected and you can now use it to sign in to your Your {authprovider.name} account has been connected and you can now use it to sign in to your
Modrinth account. Modrinth account.

View File

@@ -11,7 +11,7 @@ import StyledEmail from '../shared/StyledEmail.vue'
> >
<Heading as="h1" class="mb-2 text-2xl font-bold"> Sign-in method removed</Heading> <Heading as="h1" class="mb-2 text-2xl font-bold"> Sign-in method removed</Heading>
<Text class="text-muted text-base">Hi {user.name},</Text> <Text class="text-muted text-base">Hi <span class="no-auto-link">{user.name}</span>,</Text>
<Text class="text-muted text-base"> <Text class="text-muted text-base">
Your <b>{authprovider.name}</b> account has been disconnected and you can no longer use it to Your <b>{authprovider.name}</b> account has been disconnected and you can no longer use it to
sign in to your Modrinth account. sign in to your Modrinth account.

View File

@@ -11,7 +11,7 @@ import StyledEmail from '../shared/StyledEmail.vue'
> >
<Heading as="h1" class="mb-2 text-2xl font-bold"> Your email has been changed </Heading> <Heading as="h1" class="mb-2 text-2xl font-bold"> Your email has been changed </Heading>
<Text class="text-muted text-base">Hi {user.name},</Text> <Text class="text-muted text-base">Hi <span class="no-auto-link">{user.name}</span>,</Text>
<Text class="text-muted text-base"> <Text class="text-muted text-base">
At your request, we've successfully updated your Modrinth account's email to At your request, we've successfully updated your Modrinth account's email to
{emailchanged.new_email}. {emailchanged.new_email}.

View File

@@ -11,7 +11,7 @@ import StyledEmail from '../shared/StyledEmail.vue'
> >
<Heading as="h1" class="mb-2 text-2xl font-bold"> Sign in from new device </Heading> <Heading as="h1" class="mb-2 text-2xl font-bold"> Sign in from new device </Heading>
<Text class="text-muted text-base">Hi {user.name},</Text> <Text class="text-muted text-base">Hi <span class="no-auto-link">{user.name}</span>,</Text>
<Text class="text-muted text-base"> <Text class="text-muted text-base">
We noticed that your account was just signed into from a new device or location. If this was We noticed that your account was just signed into from a new device or location. If this was
you, you can safely ignore this email. you, you can safely ignore this email.

View File

@@ -13,7 +13,7 @@ import StyledEmail from '../shared/StyledEmail.vue'
A new personal access token has been created A new personal access token has been created
</Heading> </Heading>
<Text class="text-muted text-base">Hi {user.name},</Text> <Text class="text-muted text-base">Hi <span class="no-auto-link">{user.name}</span>,</Text>
<Text class="text-muted text-base"> <Text class="text-muted text-base">
A new personal access token, <b>{newpat.token_name}</b>, has been added to your account. A new personal access token, <b>{newpat.token_name}</b>, has been added to your account.
</Text> </Text>

View File

@@ -11,7 +11,7 @@ import StyledEmail from '../shared/StyledEmail.vue'
> >
<Heading as="h1" class="mb-2 text-2xl font-bold"> Your password has been changed </Heading> <Heading as="h1" class="mb-2 text-2xl font-bold"> Your password has been changed </Heading>
<Text class="text-muted text-base">Hi {user.name},</Text> <Text class="text-muted text-base">Hi <span class="no-auto-link">{user.name}</span>,</Text>
<Text class="text-muted text-base"> Your password has been changed on your account. </Text> <Text class="text-muted text-base"> Your password has been changed on your account. </Text>
<Text class="text-muted text-base"> <Text class="text-muted text-base">
If you did not make this change, please contact us immediately through our If you did not make this change, please contact us immediately through our

View File

@@ -11,7 +11,7 @@ import StyledEmail from '../shared/StyledEmail.vue'
> >
<Heading as="h1" class="mb-2 text-2xl font-bold"> Your password has been removed </Heading> <Heading as="h1" class="mb-2 text-2xl font-bold"> Your password has been removed </Heading>
<Text class="text-muted text-base">Hi {user.name},</Text> <Text class="text-muted text-base">Hi <span class="no-auto-link">{user.name}</span>,</Text>
<Text class="text-muted text-base"> <Text class="text-muted text-base">
At your request, your password has been removed from your account. You must now use a linked At your request, your password has been removed from your account. You must now use a linked
authentication provider (such as your {passremoved.provider} account) to log into your authentication provider (such as your {passremoved.provider} account) to log into your

View File

@@ -14,7 +14,7 @@ import StyledEmail from '../shared/StyledEmail.vue'
Payment failed for {paymentfailed.service} Payment failed for {paymentfailed.service}
</Heading> </Heading>
<Text class="text-muted text-base">Hi {user.name},</Text> <Text class="text-muted text-base">Hi <span class="no-auto-link">{user.name}</span>,</Text>
<Text class="text-muted text-base"> <Text class="text-muted text-base">
Our attempt to collect payment for {paymentfailed.amount} from the payment card on file was Our attempt to collect payment for {paymentfailed.amount} from the payment card on file was
unsuccessful. Please update your billing settings to avoid suspension of your service. unsuccessful. Please update your billing settings to avoid suspension of your service.

View File

@@ -14,7 +14,7 @@ import StyledEmail from '../shared/StyledEmail.vue'
> >
<Heading as="h1" class="mb-2 text-2xl font-bold">Revenue available to withdraw!</Heading> <Heading as="h1" class="mb-2 text-2xl font-bold">Revenue available to withdraw!</Heading>
<Text class="text-base">Hi {user.name},</Text> <Text class="text-base">Hi <span class="no-auto-link">{user.name}</span>,</Text>
<Text class="text-base"> <Text class="text-base">
The {payout.amount} earned during {payout.period} has been processed and is now available to The {payout.amount} earned during {payout.period} has been processed and is now available to

View File

@@ -11,7 +11,7 @@ import StyledEmail from '../shared/StyledEmail.vue'
> >
<Heading as="h1" class="mb-2 text-2xl font-bold"> Reset your password </Heading> <Heading as="h1" class="mb-2 text-2xl font-bold"> Reset your password </Heading>
<Text class="text-muted text-base">Hi {user.name},</Text> <Text class="text-muted text-base">Hi <span class="no-auto-link">{user.name}</span>,</Text>
<Text class="text-muted text-base"> <Text class="text-muted text-base">
Please visit the link below to reset your password. If you did not request for your password Please visit the link below to reset your password. If you did not request for your password
to be reset, you can safely ignore this email. to be reset, you can safely ignore this email.

View File

@@ -8,7 +8,7 @@ import StyledEmail from '../shared/StyledEmail.vue'
<StyledEmail title="Weve added time to your server"> <StyledEmail title="Weve added time to your server">
<Heading as="h1" class="mb-2 text-2xl font-bold">Weve added time to your server</Heading> <Heading as="h1" class="mb-2 text-2xl font-bold">Weve added time to your server</Heading>
<Text class="text-muted text-base">Hi {user.name},</Text> <Text class="text-muted text-base">Hi <span class="no-auto-link">{user.name}</span>,</Text>
<Text class="text-muted text-base">{credit.header_message}</Text> <Text class="text-muted text-base">{credit.header_message}</Text>
<Text class="text-muted text-base"> <Text class="text-muted text-base">

View File

@@ -14,7 +14,7 @@ import StyledEmail from '../shared/StyledEmail.vue'
Price change for {taxnotification.service} Price change for {taxnotification.service}
</Heading> </Heading>
<Text class="text-muted text-base">Hi {user.name},</Text> <Text class="text-muted text-base">Hi <span class="no-auto-link">{user.name}</span>,</Text>
<Text class="text-muted text-base"> <Text class="text-muted text-base">
We're writing to let you know about an update to your {taxnotification.service} subscription. We're writing to let you know about an update to your {taxnotification.service} subscription.
</Text> </Text>

View File

@@ -11,7 +11,7 @@ import StyledEmail from '../shared/StyledEmail.vue'
> >
<Heading as="h1" class="mb-2 text-2xl font-bold"> Two-factor authentication enabled </Heading> <Heading as="h1" class="mb-2 text-2xl font-bold"> Two-factor authentication enabled </Heading>
<Text class="text-muted text-base">Hi {user.name},</Text> <Text class="text-muted text-base">Hi <span class="no-auto-link">{user.name}</span>,</Text>
<Text class="text-muted text-base"> <Text class="text-muted text-base">
You've secured your account with two-factor authentication. Now, when signing in, you will You've secured your account with two-factor authentication. Now, when signing in, you will
need to submit the code generated by your authenticator app. need to submit the code generated by your authenticator app.

View File

@@ -13,7 +13,7 @@ import StyledEmail from '../shared/StyledEmail.vue'
You've disabled two-factor authentication security on your account. You've disabled two-factor authentication security on your account.
</Heading> </Heading>
<Text class="text-muted text-base">Hi {user.name},</Text> <Text class="text-muted text-base">Hi <span class="no-auto-link">{user.name}</span>,</Text>
<Text class="text-muted text-base"> <Text class="text-muted text-base">
At your request, we've removed two-factor authentication from your Modrinth account. At your request, we've removed two-factor authentication from your Modrinth account.
</Text> </Text>

View File

@@ -11,7 +11,7 @@ import StyledEmail from '../shared/StyledEmail.vue'
> >
<Heading as="h1" class="mb-2 text-2xl font-bold"> Verify your email </Heading> <Heading as="h1" class="mb-2 text-2xl font-bold"> Verify your email </Heading>
<Text class="text-muted text-base">Hi {user.name},</Text> <Text class="text-muted text-base">Hi <span class="no-auto-link">{user.name}</span>,</Text>
<Text class="text-muted text-base"> <Text class="text-muted text-base">
Please visit the link below to verify your email. If the button does not work, you can copy Please visit the link below to verify your email. If the button does not work, you can copy
the link and paste it into your browser. This link expires in 24 hours. the link and paste it into your browser. This link expires in 24 hours.

View File

@@ -29,7 +29,7 @@ import StyledEmail from '../shared/StyledEmail.vue'
>New message from moderators on {project.name}</Heading >New message from moderators on {project.name}</Heading
> >
<Text class="text-base">Hi {user.name},</Text> <Text class="text-base">Hi <span class="no-auto-link">{user.name}</span>,</Text>
<Text class="text-base"> <Text class="text-base">
Modrinth's moderation team has left a message on your project, Modrinth's moderation team has left a message on your project,

View File

@@ -20,7 +20,7 @@ import StyledEmail from '../shared/StyledEmail.vue'
>Report of '{report.title}' has been updated</Heading >Report of '{report.title}' has been updated</Heading
> >
<Text class="text-base">Hi {user.name},</Text> <Text class="text-base">Hi <span class="no-auto-link">{user.name}</span>,</Text>
<Text class="text-base" <Text class="text-base"
>Your report of {report.title} from {report.date} has been updated by our moderation >Your report of {report.title} from {report.date} has been updated by our moderation

View File

@@ -20,7 +20,7 @@ import StyledEmail from '../shared/StyledEmail.vue'
>Report of {report.title} has been submitted</Heading >Report of {report.title} has been submitted</Heading
> >
<Text class="text-base">Hi {user.name},</Text> <Text class="text-base">Hi <span class="no-auto-link">{user.name}</span>,</Text>
<Text class="text-base"> <Text class="text-base">
We've received your report of {report.title} and our moderation team will review it shortly. We've received your report of {report.title} and our moderation team will review it shortly.

View File

@@ -27,7 +27,7 @@ import StyledEmail from '../shared/StyledEmail.vue'
>You've been invited to an organization</Heading >You've been invited to an organization</Heading
> >
<Text class="text-base">Hi {user.name},</Text> <Text class="text-base">Hi <span class="no-auto-link">{user.name}</span>,</Text>
<Text class="text-base" <Text class="text-base"
>Modrinth user >Modrinth user

View File

@@ -24,7 +24,7 @@ import StyledEmail from '../shared/StyledEmail.vue'
</Section> </Section>
<Heading as="h1" class="mb-2 text-2xl font-bold">You've been invited to a project</Heading> <Heading as="h1" class="mb-2 text-2xl font-bold">You've been invited to a project</Heading>
<Text class="text-base">Hi {user.name},</Text> <Text class="text-base">Hi <span class="no-auto-link">{user.name}</span>,</Text>
<Text class="text-base"> <Text class="text-base">
Modrinth user Modrinth user

View File

@@ -26,7 +26,7 @@ import StyledEmail from '../shared/StyledEmail.vue'
>Your project, {project.name}, has been approved 🎉</Heading >Your project, {project.name}, has been approved 🎉</Heading
> >
<Text class="text-base">Congratulations {user.name},</Text> <Text class="text-base">Congratulations <span class="no-auto-link">{user.name}</span>,</Text>
<Text class="text-base"> <Text class="text-base">
Your project Your project

View File

@@ -29,7 +29,7 @@ import StyledEmail from '../shared/StyledEmail.vue'
>Your project, {project.name}, status has been updated</Heading >Your project, {project.name}, status has been updated</Heading
> >
<Text class="text-base">Hi {user.name},</Text> <Text class="text-base">Hi <span class="no-auto-link">{user.name}</span>,</Text>
<Text class="text-base"> <Text class="text-base">
Your project's status has been changed from <b>{project.oldstatus}</b> to Your project's status has been changed from <b>{project.oldstatus}</b> to

View File

@@ -24,7 +24,7 @@ import StyledEmail from '../shared/StyledEmail.vue'
</Section> </Section>
<Heading as="h1" class="mb-2 text-2xl font-bold">Project ownership transferred</Heading> <Heading as="h1" class="mb-2 text-2xl font-bold">Project ownership transferred</Heading>
<Text class="text-base">Hi {user.name},</Text> <Text class="text-base">Hi <span class="no-auto-link">{user.name}</span>,</Text>
<Text class="text-base"> <Text class="text-base">
The ownership of The ownership of

View File

@@ -67,6 +67,7 @@ const tailwindConfig = {
<meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="format-detection" content="telephone=no,date=no,address=no,email=no,url=no" />
<link <link
href="https://fonts.googleapis.com/css?family=Inter:700,400" href="https://fonts.googleapis.com/css?family=Inter:700,400"
rel="stylesheet" rel="stylesheet"
@@ -78,6 +79,9 @@ const tailwindConfig = {
line-height:100%; } table { border-collapse:separate; } a, a:link, a:visited { line-height:100%; } table { border-collapse:separate; } a, a:link, a:visited {
text-decoration:none; color:#1f68c0; } a:hover { text-decoration:underline; } text-decoration:none; color:#1f68c0; } a:hover { text-decoration:underline; }
h1,h2,h3,h4,h5,h6 { color:#000 !important; margin:0; mso-line-height-rule:exactly; } h1,h2,h3,h4,h5,h6 { color:#000 !important; margin:0; mso-line-height-rule:exactly; }
.no-auto-link, .no-auto-link a, .no-auto-link a:link, .no-auto-link a:visited, .no-auto-link
a[x-apple-data-detectors] { color:inherit !important; text-decoration:none !important;
cursor:default !important; pointer-events:none !important; }
</Style> </Style>
</Head> </Head>

View File

@@ -7,13 +7,13 @@
<ButtonStyled type="outlined"> <ButtonStyled type="outlined">
<button class="!border-surface-5" @click="triggerIconInput"> <button class="!border-surface-5" @click="triggerIconInput">
<UploadIcon /> <UploadIcon />
Select icon {{ formatMessage(messages.selectIcon) }}
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled type="outlined"> <ButtonStyled type="outlined">
<button class="!border-surface-5" :disabled="!ctx.instanceIcon.value" @click="removeIcon"> <button class="!border-surface-5" :disabled="!ctx.instanceIcon.value" @click="removeIcon">
<XIcon /> <XIcon />
Remove icon {{ formatMessage(messages.removeIcon) }}
</button> </button>
</ButtonStyled> </ButtonStyled>
</div> </div>
@@ -21,17 +21,19 @@
<!-- Instance-specific: Name field --> <!-- Instance-specific: Name field -->
<div v-if="ctx.flowType === 'instance'" class="flex flex-col gap-2"> <div v-if="ctx.flowType === 'instance'" class="flex flex-col gap-2">
<span class="font-semibold text-contrast">Name</span> <span class="font-semibold text-contrast">{{ formatMessage(messages.nameLabel) }}</span>
<StyledInput <StyledInput
v-model="ctx.instanceName.value" v-model="ctx.instanceName.value"
:placeholder="ctx.autoInstanceName.value || 'Enter instance name'" :placeholder="ctx.autoInstanceName.value || formatMessage(messages.instanceNamePlaceholder)"
/> />
</div> </div>
<!-- Loader chips --> <!-- Loader chips -->
<div v-if="!hideLoaderChips" class="flex flex-col gap-2"> <div v-if="!hideLoaderChips" class="flex flex-col gap-2">
<span class="font-semibold text-contrast">{{ <span class="font-semibold text-contrast">{{
ctx.flowType === 'instance' ? 'Loader' : 'Content loader' ctx.flowType === 'instance'
? formatMessage(messages.loaderLabel)
: formatMessage(messages.contentLoaderLabel)
}}</span> }}</span>
<Chips <Chips
v-model="selectedLoader" v-model="selectedLoader"
@@ -43,14 +45,21 @@
<!-- Game version --> <!-- Game version -->
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">Game version</span> <span class="font-semibold text-contrast">{{
formatMessage(commonMessages.gameVersionLabel)
}}</span>
<Combobox <Combobox
v-model="selectedGameVersion" v-model="selectedGameVersion"
:options="gameVersionOptions" :options="gameVersionOptions"
:no-options-message="
gameVersionsLoading
? formatMessage(commonMessages.loadingLabel)
: formatMessage(messages.noVersionsAvailable)
"
searchable searchable
sync-with-selection sync-with-selection
placeholder="Select game version" :placeholder="formatMessage(messages.selectGameVersion)"
search-placeholder="Search game version..." :search-placeholder="formatMessage(messages.searchGameVersion)"
@option-hover="handleGameVersionHover" @option-hover="handleGameVersionHover"
> >
<template v-if="ctx.showSnapshotToggle" #dropdown-footer> <template v-if="ctx.showSnapshotToggle" #dropdown-footer>
@@ -61,7 +70,11 @@
> >
<EyeOffIcon v-if="ctx.showSnapshots.value" class="size-4" /> <EyeOffIcon v-if="ctx.showSnapshots.value" class="size-4" />
<EyeIcon v-else class="size-4" /> <EyeIcon v-else class="size-4" />
{{ ctx.showSnapshots.value ? 'Hide snapshots' : 'Show all versions' }} {{
ctx.showSnapshots.value
? formatMessage(commonMessages.hideSnapshotsButton)
: formatMessage(commonMessages.showAllVersionsButton)
}}
</button> </button>
</template> </template>
</Combobox> </Combobox>
@@ -72,24 +85,36 @@
<Collapsible :collapsed="!selectedLoader || !selectedGameVersion" overflow-visible> <Collapsible :collapsed="!selectedLoader || !selectedGameVersion" overflow-visible>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">{{ <span class="font-semibold text-contrast">{{
isPaperLike ? 'Build number' : 'Loader version' isPaperLike
? formatMessage(messages.buildNumberLabel)
: formatMessage(messages.loaderVersionLabel)
}}</span> }}</span>
<Chips <Chips
v-if="!isPaperLike" v-if="!isPaperLike"
v-model="loaderVersionType" v-model="loaderVersionType"
:items="loaderVersionTypeItems" :items="loaderVersionTypeItems"
:format-label="capitalize" :format-label="formatLoaderVersionTypeLabel"
/> />
<div v-if="isPaperLike || loaderVersionType === 'other'"> <div v-if="isPaperLike || loaderVersionType === 'other'">
<Combobox <Combobox
v-model="selectedLoaderVersion" v-model="selectedLoaderVersion"
:options="loaderVersionOptions" :options="loaderVersionOptions"
:no-options-message="loaderVersionsLoading ? 'Loading...' : 'No versions available'" :no-options-message="
loaderVersionsLoading
? formatMessage(commonMessages.loadingLabel)
: formatMessage(messages.noVersionsAvailable)
"
searchable searchable
sync-with-selection sync-with-selection
:placeholder="isPaperLike ? 'Select build number' : 'Select loader version'" :placeholder="
isPaperLike
? formatMessage(messages.selectBuildNumber)
: formatMessage(messages.selectLoaderVersion)
"
:search-placeholder=" :search-placeholder="
isPaperLike ? 'Search build number...' : 'Search loader version...' isPaperLike
? formatMessage(messages.searchBuildNumber)
: formatMessage(messages.searchLoaderVersion)
" "
> >
<!-- When not Paper, this scoped slot is omitted and Combobox uses default option markup. --> <!-- When not Paper, this scoped slot is omitted and Combobox uses default option markup. -->
@@ -123,6 +148,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Paper } from '@modrinth/api-client' import type { Paper } from '@modrinth/api-client'
import { EyeIcon, EyeOffIcon, UploadIcon, XIcon } from '@modrinth/assets' import { EyeIcon, EyeOffIcon, UploadIcon, XIcon } from '@modrinth/assets'
import { commonMessages, defineMessages, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { useDebugLogger } from '#ui/composables/debug-logger' import { useDebugLogger } from '#ui/composables/debug-logger'
@@ -135,13 +161,14 @@ import Collapsible from '../../../base/Collapsible.vue'
import Combobox, { type ComboboxOption } from '../../../base/Combobox.vue' import Combobox, { type ComboboxOption } from '../../../base/Combobox.vue'
import PaperChannelBadge from '../../../base/PaperChannelBadge.vue' import PaperChannelBadge from '../../../base/PaperChannelBadge.vue'
import StyledInput from '../../../base/StyledInput.vue' import StyledInput from '../../../base/StyledInput.vue'
import type { LoaderVersionType } from '../creation-flow-context' import type { LoaderVersionEntry, LoaderVersionType } from '../creation-flow-context'
import { injectCreationFlowContext } from '../creation-flow-context' import { injectCreationFlowContext } from '../creation-flow-context'
import { capitalize, formatLoaderLabel } from '../shared' import { formatLoaderLabel } from '../shared'
const debug = useDebugLogger('CustomSetupStage') const debug = useDebugLogger('CustomSetupStage')
const client = injectModrinthClient() const client = injectModrinthClient()
const ctx = injectCreationFlowContext() const ctx = injectCreationFlowContext()
const { formatMessage } = useVIntl()
const { const {
selectedLoader, selectedLoader,
selectedGameVersion, selectedGameVersion,
@@ -151,6 +178,92 @@ const {
hideLoaderVersion, hideLoaderVersion,
} = ctx } = ctx
const messages = defineMessages({
selectIcon: {
id: 'creation-flow.modal.custom-setup.icon.select',
defaultMessage: 'Select icon',
},
removeIcon: {
id: 'creation-flow.modal.custom-setup.icon.remove',
defaultMessage: 'Remove icon',
},
nameLabel: {
id: 'creation-flow.modal.custom-setup.name.label',
defaultMessage: 'Name',
},
instanceNamePlaceholder: {
id: 'creation-flow.modal.custom-setup.name.placeholder',
defaultMessage: 'Enter instance name',
},
loaderLabel: {
id: 'creation-flow.modal.custom-setup.loader.label',
defaultMessage: 'Loader',
},
contentLoaderLabel: {
id: 'creation-flow.modal.custom-setup.content-loader.label',
defaultMessage: 'Content loader',
},
noVersionsAvailable: {
id: 'creation-flow.modal.custom-setup.options.no-versions-available',
defaultMessage: 'No versions available',
},
selectGameVersion: {
id: 'creation-flow.modal.custom-setup.game-version.placeholder',
defaultMessage: 'Select game version',
},
searchGameVersion: {
id: 'creation-flow.modal.custom-setup.game-version.search-placeholder',
defaultMessage: 'Search game version...',
},
buildNumberLabel: {
id: 'creation-flow.modal.custom-setup.build-number.label',
defaultMessage: 'Build number',
},
loaderVersionLabel: {
id: 'creation-flow.modal.custom-setup.loader-version.label',
defaultMessage: 'Loader version',
},
selectBuildNumber: {
id: 'creation-flow.modal.custom-setup.build-number.placeholder',
defaultMessage: 'Select build number',
},
selectLoaderVersion: {
id: 'creation-flow.modal.custom-setup.loader-version.placeholder',
defaultMessage: 'Select loader version',
},
searchBuildNumber: {
id: 'creation-flow.modal.custom-setup.build-number.search-placeholder',
defaultMessage: 'Search build number...',
},
searchLoaderVersion: {
id: 'creation-flow.modal.custom-setup.loader-version.search-placeholder',
defaultMessage: 'Search loader version...',
},
stableLoaderVersionType: {
id: 'creation-flow.modal.custom-setup.loader-version-type.stable',
defaultMessage: 'Stable',
},
latestLoaderVersionType: {
id: 'creation-flow.modal.custom-setup.loader-version-type.latest',
defaultMessage: 'Latest',
},
otherLoaderVersionType: {
id: 'creation-flow.modal.custom-setup.loader-version-type.other',
defaultMessage: 'Other',
},
})
function formatLoaderVersionTypeLabel(type: LoaderVersionType): string {
switch (type) {
case 'stable':
return formatMessage(messages.stableLoaderVersionType)
case 'latest':
return formatMessage(messages.latestLoaderVersionType)
case 'other':
return formatMessage(messages.otherLoaderVersionType)
}
}
// For instance flow, prepend 'vanilla' to available loaders. // For instance flow, prepend 'vanilla' to available loaders.
// For server flows, vanilla is a separate option in the setup type stage, so exclude it here. // For server flows, vanilla is a separate option in the setup type stage, so exclude it here.
const effectiveLoaders = computed(() => { const effectiveLoaders = computed(() => {
@@ -205,23 +318,24 @@ function removeIcon() {
ctx.instanceIconPath.value = null ctx.instanceIconPath.value = null
} }
// Loader versions fetched from launcher-meta
interface LoaderVersionEntry {
id: string
stable: boolean
}
const loaderVersionsLoading = ref(false) const loaderVersionsLoading = ref(false)
const loaderVersionsData = ref<LoaderVersionEntry[]>([]) const loaderVersionsData = ref<LoaderVersionEntry[]>([])
const loaderVersionsCache = ref<Record<string, { id: string; loaders: LoaderVersionEntry[] }[]>>({})
// Paper/Purpur build caches // Paper/Purpur build caches
const paperVersions = ref<Record<string, Paper.Versions.v3.Build[]>>({}) const paperVersions = ref<Record<string, Paper.Versions.v3.Build[]>>({})
const purpurVersions = ref<Record<string, string[]>>({}) const purpurVersions = ref<Record<string, string[]>>({})
// Paper/Purpur supported game version sets (for filtering the game version combobox) function toApiLoaderName(loader: string): string {
const paperSupportedVersions = ref<Set<string> | null>(null) return loader === 'neoforge' ? 'neo' : loader
const purpurSupportedVersions = ref<Set<string> | null>(null) }
const gameVersionsLoading = computed(() => {
const loader = selectedLoader.value
if (!loader || loader === 'vanilla') return false
if (loader === 'paper') return ctx.paperSupportedVersions.value === null
if (loader === 'purpur') return ctx.purpurSupportedVersions.value === null
return ctx.loaderVersionsCache.value[toApiLoaderName(loader)] === undefined
})
// Game versions from tags provider, filtered by loader support // Game versions from tags provider, filtered by loader support
const gameVersionOptions = computed<ComboboxOption<string>[]>(() => { const gameVersionOptions = computed<ComboboxOption<string>[]>(() => {
@@ -231,34 +345,36 @@ const gameVersionOptions = computed<ComboboxOption<string>[]>(() => {
// For loaders with per-version data, only show game versions that have builds // For loaders with per-version data, only show game versions that have builds
if (selectedLoader.value && selectedLoader.value !== 'vanilla') { if (selectedLoader.value && selectedLoader.value !== 'vanilla') {
if (selectedLoader.value === 'paper' && paperSupportedVersions.value) { if (selectedLoader.value === 'paper') {
if (!ctx.paperSupportedVersions.value) return []
return versions return versions
.filter((v) => paperSupportedVersions.value!.has(v.version)) .filter((v) => ctx.paperSupportedVersions.value!.has(v.version))
.map((v) => ({ value: v.version, label: v.version })) .map((v) => ({ value: v.version, label: v.version }))
} }
if (selectedLoader.value === 'purpur' && purpurSupportedVersions.value) { if (selectedLoader.value === 'purpur') {
if (!ctx.purpurSupportedVersions.value) return []
return versions return versions
.filter((v) => purpurSupportedVersions.value!.has(v.version)) .filter((v) => ctx.purpurSupportedVersions.value!.has(v.version))
.map((v) => ({ value: v.version, label: v.version })) .map((v) => ({ value: v.version, label: v.version }))
} }
let apiLoader = selectedLoader.value const apiLoader = toApiLoaderName(selectedLoader.value)
if (apiLoader === 'neoforge') apiLoader = 'neo' const manifest = ctx.loaderVersionsCache.value[apiLoader]
if (!manifest) return []
const manifest = loaderVersionsCache.value[apiLoader]
if (manifest) {
const hasPlaceholder = manifest.some((x) => x.id === '${modrinth.gameVersion}') const hasPlaceholder = manifest.some((x) => x.id === '${modrinth.gameVersion}')
if (!hasPlaceholder) {
const supportedVersions = new Set( const supportedVersions = new Set(
manifest.filter((x) => x.loaders.length > 0).map((x) => x.id), manifest
.filter(
(x) => x.id !== '${modrinth.gameVersion}' && (hasPlaceholder || x.loaders.length > 0),
)
.map((x) => x.id),
) )
return versions return versions
.filter((v) => supportedVersions.has(v.version)) .filter((v) => supportedVersions.has(v.version))
.map((v) => ({ value: v.version, label: v.version })) .map((v) => ({ value: v.version, label: v.version }))
} }
}
}
return versions.map((v) => ({ value: v.version, label: v.version })) return versions.map((v) => ({ value: v.version, label: v.version }))
}) })
@@ -267,7 +383,10 @@ const gameVersionOptions = computed<ComboboxOption<string>[]>(() => {
watch( watch(
gameVersionOptions, gameVersionOptions,
(options) => { (options) => {
if (options.length === 0) return if (options.length === 0) {
selectedGameVersion.value = null
return
}
if (!selectedGameVersion.value || !options.some((o) => o.value === selectedGameVersion.value)) { if (!selectedGameVersion.value || !options.some((o) => o.value === selectedGameVersion.value)) {
selectedGameVersion.value = options[0].value selectedGameVersion.value = options[0].value
} }
@@ -276,50 +395,20 @@ watch(
) )
async function fetchLoaderManifest(loader: string) { async function fetchLoaderManifest(loader: string) {
let apiLoader = loader const apiLoader = toApiLoaderName(loader)
if (apiLoader === 'neoforge') apiLoader = 'neo'
debug( debug(
'fetchLoaderManifest:', 'fetchLoaderManifest:',
loader, loader,
'apiLoader:', 'apiLoader:',
apiLoader, apiLoader,
'cached:', 'cached:',
!!loaderVersionsCache.value[apiLoader], !!ctx.loaderVersionsCache.value[apiLoader],
) )
if (loaderVersionsCache.value[apiLoader]) return await ctx.fetchLoaderMetadata(loader)
try {
const res = await fetch(`https://launcher-meta.modrinth.com/${apiLoader}/v0/manifest.json`)
const data = (await res.json()) as {
gameVersions: { id: string; loaders: LoaderVersionEntry[] }[]
}
loaderVersionsCache.value[apiLoader] = data.gameVersions
debug('fetchLoaderManifest: loaded', apiLoader, 'gameVersions:', data.gameVersions.length)
} catch (e) {
debug('fetchLoaderManifest: FAILED', apiLoader, e)
loaderVersionsCache.value[apiLoader] = []
}
} }
async function fetchPaperSupportedVersions() { async function fetchLoaderMetadata(loader?: string | null) {
if (paperSupportedVersions.value) return await ctx.fetchLoaderMetadata(loader)
try {
const project = await client.paper.versions_v3.getProject()
paperSupportedVersions.value = new Set(Object.values(project.versions).flat())
} catch {
paperSupportedVersions.value = new Set()
}
}
async function fetchPurpurSupportedVersions() {
if (purpurSupportedVersions.value) return
try {
const project = await client.purpur.versions_v2.getProject()
purpurSupportedVersions.value = new Set(project.versions)
} catch {
purpurSupportedVersions.value = new Set()
}
} }
function paperBuildChannelTag(buildId: string): 'ALPHA' | 'BETA' | null { function paperBuildChannelTag(buildId: string): 'ALPHA' | 'BETA' | null {
@@ -363,10 +452,8 @@ function getLoaderVersionsForGameVersion(
loader: string, loader: string,
gameVersion: string, gameVersion: string,
): LoaderVersionEntry[] { ): LoaderVersionEntry[] {
let apiLoader = loader const apiLoader = toApiLoaderName(loader)
if (apiLoader === 'neoforge') apiLoader = 'neo' const manifest = ctx.loaderVersionsCache.value[apiLoader]
const manifest = loaderVersionsCache.value[apiLoader]
debug('getLoaderVersionsForGameVersion:', { debug('getLoaderVersionsForGameVersion:', {
loader, loader,
apiLoader, apiLoader,
@@ -379,6 +466,7 @@ function getLoaderVersionsForGameVersion(
// Some loaders (e.g. Fabric) list all versions under a placeholder entry // Some loaders (e.g. Fabric) list all versions under a placeholder entry
const placeholder = manifest.find((x) => x.id === '${modrinth.gameVersion}') const placeholder = manifest.find((x) => x.id === '${modrinth.gameVersion}')
if (placeholder) { if (placeholder) {
if (!manifest.some((x) => x.id === gameVersion)) return []
debug( debug(
'getLoaderVersionsForGameVersion: using placeholder, loaders:', 'getLoaderVersionsForGameVersion: using placeholder, loaders:',
placeholder.loaders.length, placeholder.loaders.length,
@@ -400,16 +488,7 @@ function getLoaderVersionsForGameVersion(
watch( watch(
() => selectedLoader.value, () => selectedLoader.value,
async (loader) => { async (loader) => {
if (!loader || loader === 'vanilla') return await fetchLoaderMetadata(loader)
if (loader === 'paper') {
await fetchPaperSupportedVersions()
return
}
if (loader === 'purpur') {
await fetchPurpurSupportedVersions()
return
}
await fetchLoaderManifest(loader)
}, },
{ immediate: true }, { immediate: true },
) )

View File

@@ -4,18 +4,23 @@
v-if="ctx.flowType !== 'server-onboarding' && ctx.flowType !== 'reset-server'" v-if="ctx.flowType !== 'server-onboarding' && ctx.flowType !== 'reset-server'"
class="flex flex-col gap-2" class="flex flex-col gap-2"
> >
<span class="font-semibold text-contrast">World name</span> <span class="font-semibold text-contrast">{{ formatMessage(messages.worldNameLabel) }}</span>
<StyledInput v-model="worldName" placeholder="Enter world name" /> <StyledInput
v-model="worldName"
:placeholder="formatMessage(messages.worldNamePlaceholder)"
/>
</div> </div>
<div v-if="ctx.setupType.value === 'vanilla'" class="flex flex-col gap-2"> <div v-if="ctx.setupType.value === 'vanilla'" class="flex flex-col gap-2">
<span class="font-semibold text-contrast">Game version</span> <span class="font-semibold text-contrast">{{
formatMessage(commonMessages.gameVersionLabel)
}}</span>
<Combobox <Combobox
v-model="selectedGameVersion" v-model="selectedGameVersion"
:options="gameVersionOptions" :options="gameVersionOptions"
searchable searchable
sync-with-selection sync-with-selection
placeholder="Select game version" :placeholder="formatMessage(messages.gameVersionPlaceholder)"
> >
<template v-if="ctx.showSnapshotToggle" #dropdown-footer> <template v-if="ctx.showSnapshotToggle" #dropdown-footer>
<button <button
@@ -25,37 +30,50 @@
> >
<EyeOffIcon v-if="ctx.showSnapshots.value" class="size-4" /> <EyeOffIcon v-if="ctx.showSnapshots.value" class="size-4" />
<EyeIcon v-else class="size-4" /> <EyeIcon v-else class="size-4" />
{{ ctx.showSnapshots.value ? 'Hide snapshots' : 'Show all versions' }} {{
ctx.showSnapshots.value
? formatMessage(commonMessages.hideSnapshotsButton)
: formatMessage(commonMessages.showAllVersionsButton)
}}
</button> </button>
</template> </template>
</Combobox> </Combobox>
</div> </div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">Gamemode</span> <span class="font-semibold text-contrast">{{ formatMessage(messages.gamemodeLabel) }}</span>
<Chips v-model="gamemode" :items="gamemodeItems" :format-label="capitalize" /> <Chips v-model="gamemode" :items="gamemodeItems" :format-label="formatGamemodeLabel" />
</div> </div>
<div v-if="gamemode !== 'hardcore'" class="flex flex-col gap-2"> <div v-if="gamemode !== 'hardcore'" class="flex flex-col gap-2">
<span class="font-semibold text-contrast">Difficulty</span> <span class="font-semibold text-contrast">{{ formatMessage(messages.difficultyLabel) }}</span>
<Chips v-model="difficulty" :items="difficultyItems" :format-label="capitalize" /> <Chips v-model="difficulty" :items="difficultyItems" :format-label="formatDifficultyLabel" />
</div> </div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">World type</span> <span class="font-semibold text-contrast">{{ formatMessage(messages.worldTypeLabel) }}</span>
<Combobox <Combobox
v-model="worldTypeOption" v-model="worldTypeOption"
:options="worldTypeOptions" :options="worldTypeOptions"
placeholder="Select world type" :placeholder="formatMessage(messages.worldTypePlaceholder)"
/> />
</div> </div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<span class="font-semibold text-contrast" <span class="font-semibold text-contrast">
>World seed <span class="text-secondary font-normal">(Optional)</span></span <IntlFormatted :message-id="messages.worldSeedLabelWithOptional">
> <template #optional="{ children }">
<StyledInput v-model="worldSeed" placeholder="Enter world seed" /> <span class="text-secondary font-normal">
<span class="text-sm text-secondary">Leave blank for a random seed.</span> <component :is="() => children" />
</span>
</template>
</IntlFormatted>
</span>
<StyledInput
v-model="worldSeed"
:placeholder="formatMessage(messages.worldSeedPlaceholder)"
/>
<span class="text-sm text-secondary">{{ formatMessage(messages.worldSeedDescription) }}</span>
</div> </div>
<div class="h-px w-full bg-surface-5" /> <div class="h-px w-full bg-surface-5" />
@@ -63,36 +81,42 @@
<Accordion overflow-visible button-class="w-full bg-transparent m-0 p-0 border-none"> <Accordion overflow-visible button-class="w-full bg-transparent m-0 p-0 border-none">
<template #title> <template #title>
<SettingsIcon class="size-4 shrink-0 text-primary" /> <SettingsIcon class="size-4 shrink-0 text-primary" />
<span class="font-semibold text-contrast text-lg">Additional settings</span> <span class="font-semibold text-contrast text-lg">{{
formatMessage(messages.additionalSettingsTitle)
}}</span>
</template> </template>
<div class="flex flex-col gap-4 pt-4"> <div class="flex flex-col gap-4 pt-4">
<div class="flex w-full flex-row items-center justify-between gap-4"> <div class="flex w-full flex-row items-center justify-between gap-4">
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<span class="font-semibold text-contrast">Generate structures</span> <span class="font-semibold text-contrast">{{
formatMessage(messages.generateStructuresLabel)
}}</span>
<span class="text-sm text-secondary"> <span class="text-sm text-secondary">
Controls whether villages, strongholds, and other structures generate in new chunks. {{ formatMessage(messages.generateStructuresDescription) }}
</span> </span>
</div> </div>
<Toggle v-model="generateStructures" small class="shrink-0" /> <Toggle v-model="generateStructures" small class="shrink-0" />
</div> </div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">Generator settings</span> <span class="font-semibold text-contrast">{{
formatMessage(messages.generatorSettingsLabel)
}}</span>
<Combobox <Combobox
v-model="generatorSettingsMode" v-model="generatorSettingsMode"
:options="generatorSettingsOptions" :options="generatorSettingsOptions"
placeholder="Select generator settings" :placeholder="formatMessage(messages.generatorSettingsPlaceholder)"
/> />
<StyledInput <StyledInput
v-if="generatorSettingsMode === 'custom'" v-if="generatorSettingsMode === 'custom'"
v-model="generatorSettingsCustom" v-model="generatorSettingsCustom"
multiline multiline
:rows="4" :rows="4"
placeholder="Enter generator settings JSON" :placeholder="formatMessage(messages.generatorSettingsJsonPlaceholder)"
input-class="font-mono" input-class="font-mono"
/> />
<span class="text-sm text-secondary"> <span class="text-sm text-secondary">
Used for advanced world customization such as custom Superflat layers. {{ formatMessage(messages.generatorSettingsDescription) }}
</span> </span>
</div> </div>
</div> </div>
@@ -101,7 +125,7 @@
<InlineBackupCreator <InlineBackupCreator
v-if="ctx.flowType === 'reset-server'" v-if="ctx.flowType === 'reset-server'"
ref="backupCreator" ref="backupCreator"
backup-name="Before reset server" :backup-name="formatMessage(messages.beforeResetServerBackupName)"
hide-shift-click-hint hide-shift-click-hint
@update:buttons-disabled="ctx.isBackingUp.value = $event" @update:buttons-disabled="ctx.isBackingUp.value = $event"
/> />
@@ -110,6 +134,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { EyeIcon, EyeOffIcon, SettingsIcon } from '@modrinth/assets' import { EyeIcon, EyeOffIcon, SettingsIcon } from '@modrinth/assets'
import { commonMessages, defineMessages, IntlFormatted, useVIntl } from '@modrinth/ui'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { useDebugLogger } from '#ui/composables/debug-logger' import { useDebugLogger } from '#ui/composables/debug-logger'
@@ -123,10 +148,146 @@ import StyledInput from '../../../base/StyledInput.vue'
import Toggle from '../../../base/Toggle.vue' import Toggle from '../../../base/Toggle.vue'
import type { Difficulty, Gamemode, GeneratorSettingsMode } from '../creation-flow-context' import type { Difficulty, Gamemode, GeneratorSettingsMode } from '../creation-flow-context'
import { injectCreationFlowContext } from '../creation-flow-context' import { injectCreationFlowContext } from '../creation-flow-context'
import { capitalize } from '../shared'
const debug = useDebugLogger('FinalConfigStage') const debug = useDebugLogger('FinalConfigStage')
const ctx = injectCreationFlowContext() const ctx = injectCreationFlowContext()
const { formatMessage } = useVIntl()
const messages = defineMessages({
worldNameLabel: {
id: 'creation-flow.modal.final-config.world-name.label',
defaultMessage: 'World name',
},
worldNamePlaceholder: {
id: 'creation-flow.modal.final-config.world-name.placeholder',
defaultMessage: 'Enter world name',
},
gameVersionPlaceholder: {
id: 'creation-flow.modal.final-config.game-version.placeholder',
defaultMessage: 'Select game version',
},
gamemodeLabel: {
id: 'creation-flow.modal.final-config.gamemode.label',
defaultMessage: 'Gamemode',
},
gamemodeSurvival: {
id: 'creation-flow.modal.final-config.gamemode.survival',
defaultMessage: 'Survival',
},
gamemodeCreative: {
id: 'creation-flow.modal.final-config.gamemode.creative',
defaultMessage: 'Creative',
},
gamemodeHardcore: {
id: 'creation-flow.modal.final-config.gamemode.hardcore',
defaultMessage: 'Hardcore',
},
difficultyLabel: {
id: 'creation-flow.modal.final-config.difficulty.label',
defaultMessage: 'Difficulty',
},
difficultyPeaceful: {
id: 'creation-flow.modal.final-config.difficulty.peaceful',
defaultMessage: 'Peaceful',
},
difficultyEasy: {
id: 'creation-flow.modal.final-config.difficulty.easy',
defaultMessage: 'Easy',
},
difficultyNormal: {
id: 'creation-flow.modal.final-config.difficulty.normal',
defaultMessage: 'Normal',
},
difficultyHard: {
id: 'creation-flow.modal.final-config.difficulty.hard',
defaultMessage: 'Hard',
},
worldTypeLabel: {
id: 'creation-flow.modal.final-config.world-type.label',
defaultMessage: 'World type',
},
worldTypePlaceholder: {
id: 'creation-flow.modal.final-config.world-type.placeholder',
defaultMessage: 'Select world type',
},
worldTypeDefault: {
id: 'creation-flow.modal.final-config.world-type.default',
defaultMessage: 'Default',
},
worldTypeSuperflat: {
id: 'creation-flow.modal.final-config.world-type.superflat',
defaultMessage: 'Superflat',
},
worldTypeLargeBiomes: {
id: 'creation-flow.modal.final-config.world-type.large-biomes',
defaultMessage: 'Large Biomes',
},
worldTypeAmplified: {
id: 'creation-flow.modal.final-config.world-type.amplified',
defaultMessage: 'Amplified',
},
worldTypeSingleBiome: {
id: 'creation-flow.modal.final-config.world-type.single-biome',
defaultMessage: 'Single Biome',
},
worldSeedLabelWithOptional: {
id: 'creation-flow.modal.final-config.world-seed.label-with-optional',
defaultMessage: 'World seed <optional>(Optional)</optional>',
},
worldSeedPlaceholder: {
id: 'creation-flow.modal.final-config.world-seed.placeholder',
defaultMessage: 'Enter world seed',
},
worldSeedDescription: {
id: 'creation-flow.modal.final-config.world-seed.description',
defaultMessage: 'Leave blank for a random seed.',
},
additionalSettingsTitle: {
id: 'creation-flow.modal.final-config.additional-settings.title',
defaultMessage: 'Additional settings',
},
generateStructuresLabel: {
id: 'creation-flow.modal.final-config.generate-structures.label',
defaultMessage: 'Generate structures',
},
generateStructuresDescription: {
id: 'creation-flow.modal.final-config.generate-structures.description',
defaultMessage:
'Controls whether villages, strongholds, and other structures generate in new chunks.',
},
generatorSettingsLabel: {
id: 'creation-flow.modal.final-config.generator-settings.label',
defaultMessage: 'Generator settings',
},
generatorSettingsPlaceholder: {
id: 'creation-flow.modal.final-config.generator-settings.placeholder',
defaultMessage: 'Select generator settings',
},
generatorSettingsDefault: {
id: 'creation-flow.modal.final-config.generator-settings.default',
defaultMessage: 'Default',
},
generatorSettingsFlat: {
id: 'creation-flow.modal.final-config.generator-settings.flat',
defaultMessage: 'Flat',
},
generatorSettingsCustom: {
id: 'creation-flow.modal.final-config.generator-settings.custom',
defaultMessage: 'Custom',
},
generatorSettingsJsonPlaceholder: {
id: 'creation-flow.modal.final-config.generator-settings-json.placeholder',
defaultMessage: 'Enter generator settings JSON',
},
generatorSettingsDescription: {
id: 'creation-flow.modal.final-config.generator-settings.description',
defaultMessage: 'Used for advanced world customization such as custom Superflat layers.',
},
beforeResetServerBackupName: {
id: 'creation-flow.modal.final-config.backup.before-reset-server.name',
defaultMessage: 'Before reset server',
},
})
const backupCreator = ref<InstanceType<typeof InlineBackupCreator> | null>(null) const backupCreator = ref<InstanceType<typeof InlineBackupCreator> | null>(null)
watch(backupCreator, (creator) => { watch(backupCreator, (creator) => {
@@ -189,17 +350,41 @@ watch(gamemode, (mode) => {
const gamemodeItems: Gamemode[] = ['survival', 'creative', 'hardcore'] const gamemodeItems: Gamemode[] = ['survival', 'creative', 'hardcore']
const difficultyItems: Difficulty[] = ['peaceful', 'easy', 'normal', 'hard'] const difficultyItems: Difficulty[] = ['peaceful', 'easy', 'normal', 'hard']
const worldTypeOptions: ComboboxOption<string>[] = [ function formatGamemodeLabel(mode: Gamemode): string {
{ value: 'minecraft:normal', label: 'Default' }, switch (mode) {
{ value: 'minecraft:flat', label: 'Superflat' }, case 'survival':
{ value: 'minecraft:large_biomes', label: 'Large Biomes' }, return formatMessage(messages.gamemodeSurvival)
{ value: 'minecraft:amplified', label: 'Amplified' }, case 'creative':
{ value: 'minecraft:single_biome_surface', label: 'Single Biome' }, return formatMessage(messages.gamemodeCreative)
] case 'hardcore':
return formatMessage(messages.gamemodeHardcore)
}
}
const generatorSettingsOptions: ComboboxOption<GeneratorSettingsMode>[] = [ function formatDifficultyLabel(value: Difficulty): string {
{ value: 'default', label: 'Default' }, switch (value) {
{ value: 'flat', label: 'Flat' }, case 'peaceful':
{ value: 'custom', label: 'Custom' }, return formatMessage(messages.difficultyPeaceful)
] case 'easy':
return formatMessage(messages.difficultyEasy)
case 'normal':
return formatMessage(messages.difficultyNormal)
case 'hard':
return formatMessage(messages.difficultyHard)
}
}
const worldTypeOptions = computed<ComboboxOption<string>[]>(() => [
{ value: 'minecraft:normal', label: formatMessage(messages.worldTypeDefault) },
{ value: 'minecraft:flat', label: formatMessage(messages.worldTypeSuperflat) },
{ value: 'minecraft:large_biomes', label: formatMessage(messages.worldTypeLargeBiomes) },
{ value: 'minecraft:amplified', label: formatMessage(messages.worldTypeAmplified) },
{ value: 'minecraft:single_biome_surface', label: formatMessage(messages.worldTypeSingleBiome) },
])
const generatorSettingsOptions = computed<ComboboxOption<GeneratorSettingsMode>[]>(() => [
{ value: 'default', label: formatMessage(messages.generatorSettingsDefault) },
{ value: 'flat', label: formatMessage(messages.generatorSettingsFlat) },
{ value: 'custom', label: formatMessage(messages.generatorSettingsCustom) },
])
</script> </script>

View File

@@ -2,19 +2,21 @@
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<!-- Header --> <!-- Header -->
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="font-semibold text-contrast">Launcher instances</span> <span class="font-semibold text-contrast">{{
formatMessage(messages.launcherInstancesTitle)
}}</span>
<ButtonStyled <ButtonStyled
type="transparent" type="transparent"
size="small" size="small"
:class="{ invisible: totalSelectedCount === 0 }" :class="{ invisible: totalSelectedCount === 0 }"
> >
<button @click="clearAll">Clear all</button> <button @click="clearAll">{{ formatMessage(messages.clearAll) }}</button>
</ButtonStyled> </ButtonStyled>
</div> </div>
<template v-if="loading"> <template v-if="loading">
<div class="flex items-center justify-center py-8 text-secondary text-sm"> <div class="flex items-center justify-center py-8 text-secondary text-sm">
Detecting launcher instances... {{ formatMessage(messages.detectingLauncherInstances) }}
</div> </div>
</template> </template>
<template v-else> <template v-else>
@@ -23,7 +25,7 @@
v-if="ctx.importLaunchers.value.length > 0" v-if="ctx.importLaunchers.value.length > 0"
v-model="ctx.importSearchQuery.value" v-model="ctx.importSearchQuery.value"
:icon="SearchIcon" :icon="SearchIcon"
placeholder="Search instance name" :placeholder="formatMessage(messages.searchInstanceNamePlaceholder)"
/> />
<!-- Launcher sections --> <!-- Launcher sections -->
@@ -75,7 +77,9 @@
<!-- Add launcher path --> <!-- Add launcher path -->
<div v-if="!showAddPath"> <div v-if="!showAddPath">
<ButtonStyled> <ButtonStyled>
<button class="w-full !shadow-none" @click="showAddPath = true">Add launcher path</button> <button class="w-full !shadow-none" @click="showAddPath = true">
{{ formatMessage(messages.addLauncherPath) }}
</button>
</ButtonStyled> </ButtonStyled>
</div> </div>
<div v-else class="flex items-center gap-2"> <div v-else class="flex items-center gap-2">
@@ -83,10 +87,14 @@
><button class="!shadow-none" @click="browseForLauncherPath"> ><button class="!shadow-none" @click="browseForLauncherPath">
<FolderSearchIcon class="size-5" /></button <FolderSearchIcon class="size-5" /></button
></ButtonStyled> ></ButtonStyled>
<StyledInput v-model="newLauncherPath" placeholder="Path to launcher..." class="flex-1" /> <StyledInput
v-model="newLauncherPath"
:placeholder="formatMessage(messages.launcherPathPlaceholder)"
class="flex-1"
/>
<ButtonStyled> <ButtonStyled>
<button class="!shadow-none" :disabled="!newLauncherPath.trim()" @click="addLauncherPath"> <button class="!shadow-none" :disabled="!newLauncherPath.trim()" @click="addLauncherPath">
Add {{ formatMessage(messages.add) }}
</button> </button>
</ButtonStyled> </ButtonStyled>
</div> </div>
@@ -96,6 +104,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ChevronRightIcon, FolderSearchIcon, SearchIcon } from '@modrinth/assets' import { ChevronRightIcon, FolderSearchIcon, SearchIcon } from '@modrinth/assets'
import { defineMessages, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { injectInstanceImport, injectNotificationManager } from '../../../../providers' import { injectInstanceImport, injectNotificationManager } from '../../../../providers'
@@ -109,6 +118,7 @@ import { injectCreationFlowContext } from '../creation-flow-context'
const ctx = injectCreationFlowContext() const ctx = injectCreationFlowContext()
const importProvider = injectInstanceImport() const importProvider = injectInstanceImport()
const { addNotification } = injectNotificationManager() const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
const loading = ref(false) const loading = ref(false)
const expandedLaunchers = ref(new Set<string>()) const expandedLaunchers = ref(new Set<string>())
@@ -116,6 +126,49 @@ const expandedBeforeSearch = ref<Set<string> | null>(null)
const showAddPath = ref(false) const showAddPath = ref(false)
const newLauncherPath = ref('') const newLauncherPath = ref('')
const messages = defineMessages({
launcherInstancesTitle: {
id: 'creation-flow.modal.import-instance.launcher-instances.title',
defaultMessage: 'Launcher instances',
},
clearAll: {
id: 'creation-flow.modal.import-instance.selection.clear-all',
defaultMessage: 'Clear all',
},
detectingLauncherInstances: {
id: 'creation-flow.modal.import-instance.detecting-launcher-instances',
defaultMessage: 'Detecting launcher instances...',
},
searchInstanceNamePlaceholder: {
id: 'creation-flow.modal.import-instance.search.placeholder',
defaultMessage: 'Search instance name',
},
addLauncherPath: {
id: 'creation-flow.modal.import-instance.launcher-path.add',
defaultMessage: 'Add launcher path',
},
launcherPathPlaceholder: {
id: 'creation-flow.modal.import-instance.launcher-path.placeholder',
defaultMessage: 'Path to launcher...',
},
add: {
id: 'creation-flow.modal.import-instance.action.add',
defaultMessage: 'Add',
},
noInstancesFoundTitle: {
id: 'creation-flow.modal.import-instance.notification.no-instances-found.title',
defaultMessage: 'No instances found',
},
noInstancesFoundText: {
id: 'creation-flow.modal.import-instance.notification.no-instances-found.text',
defaultMessage: 'No importable instances were found at the specified path.',
},
customLauncherName: {
id: 'creation-flow.modal.import-instance.custom-launcher.name',
defaultMessage: 'Custom ({pathName})',
},
})
// Load detected launchers on mount // Load detected launchers on mount
onMounted(async () => { onMounted(async () => {
if (ctx.importLaunchers.value.length > 0) return // Already loaded if (ctx.importLaunchers.value.length > 0) return // Already loaded
@@ -261,13 +314,15 @@ async function addLauncherPath() {
if (instances.length === 0) { if (instances.length === 0) {
addNotification({ addNotification({
type: 'error', type: 'error',
title: 'No instances found', title: formatMessage(messages.noInstancesFoundTitle),
text: `No importable instances were found at the specified path.`, text: formatMessage(messages.noInstancesFoundText),
}) })
return return
} }
const launcher: ImportableLauncher = { const launcher: ImportableLauncher = {
name: `Custom (${path.split(/[\\/]/).pop() || path})`, name: formatMessage(messages.customLauncherName, {
pathName: path.split(/[\\/]/).pop() || path,
}),
path, path,
instances, instances,
} }
@@ -277,8 +332,8 @@ async function addLauncherPath() {
} catch { } catch {
addNotification({ addNotification({
type: 'error', type: 'error',
title: 'No instances found', title: formatMessage(messages.noInstancesFoundTitle),
text: `No importable instances were found at the specified path.`, text: formatMessage(messages.noInstancesFoundText),
}) })
return return
} }

View File

@@ -1,12 +1,18 @@
<template> <template>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<span class="font-semibold text-contrast">Already know the modpack you want to install?</span> <span class="font-semibold text-contrast">{{
formatMessage(messages.knownModpackPrompt)
}}</span>
<Combobox <Combobox
v-model="ctx.modpackSearchProjectId.value" v-model="ctx.modpackSearchProjectId.value"
:options="ctx.modpackSearchOptions.value" :options="ctx.modpackSearchOptions.value"
searchable searchable
search-placeholder="Search for modpack" :search-placeholder="formatMessage(messages.searchModpackPlaceholder)"
:no-options-message="searchLoading ? 'Loading...' : 'No results found'" :no-options-message="
searchLoading
? formatMessage(commonMessages.loadingLabel)
: formatMessage(messages.noResultsFound)
"
:disable-search-filter="true" :disable-search-filter="true"
@search-input="(query) => handleSearch(query)" @search-input="(query) => handleSearch(query)"
> >
@@ -18,20 +24,20 @@
</Combobox> </Combobox>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="h-[1px] w-full flex-1 bg-surface-5" /> <div class="h-[1px] w-full flex-1 bg-surface-5" />
<span class="text-sm text-secondary">or</span> <span class="text-sm text-secondary">{{ formatMessage(commonMessages.orLabel) }}</span>
<div class="h-[1px] w-full flex-1 bg-surface-5" /> <div class="h-[1px] w-full flex-1 bg-surface-5" />
</div> </div>
<div class="flex gap-3"> <div class="flex gap-3">
<ButtonStyled type="outlined"> <ButtonStyled type="outlined">
<button class="flex-1 !border-surface-4" @click="triggerFileInput"> <button class="flex-1 !border-surface-4" @click="triggerFileInput">
<ImportIcon /> <ImportIcon />
Import modpack {{ formatMessage(messages.importModpack) }}
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled color="brand"> <ButtonStyled color="brand">
<button class="flex-1" @click="ctx.browseModpacks()"> <button class="flex-1" @click="ctx.browseModpacks()">
<CompassIcon /> <CompassIcon />
Browse modpacks {{ formatMessage(messages.browseModpacks) }}
</button> </button>
</ButtonStyled> </ButtonStyled>
</div> </div>
@@ -40,6 +46,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { CompassIcon, ImportIcon, RightArrowIcon } from '@modrinth/assets' import { CompassIcon, ImportIcon, RightArrowIcon } from '@modrinth/assets'
import { commonMessages, defineMessages, useVIntl } from '@modrinth/ui'
import { defineAsyncComponent, h, onMounted, ref, watch } from 'vue' import { defineAsyncComponent, h, onMounted, ref, watch } from 'vue'
import { useDebugLogger } from '#ui/composables/debug-logger' import { useDebugLogger } from '#ui/composables/debug-logger'
@@ -52,9 +59,33 @@ import { injectCreationFlowContext } from '../creation-flow-context'
const debug = useDebugLogger('ModpackStage') const debug = useDebugLogger('ModpackStage')
const ctx = injectCreationFlowContext() const ctx = injectCreationFlowContext()
const filePicker = injectFilePicker() const filePicker = injectFilePicker()
const { formatMessage } = useVIntl()
const searchLoading = ref(false) const searchLoading = ref(false)
const messages = defineMessages({
knownModpackPrompt: {
id: 'creation-flow.modal.modpack.known-modpack.prompt',
defaultMessage: 'Already know the modpack you want to install?',
},
searchModpackPlaceholder: {
id: 'creation-flow.modal.modpack.search.placeholder',
defaultMessage: 'Search for modpack',
},
noResultsFound: {
id: 'creation-flow.modal.modpack.search.no-results',
defaultMessage: 'No results found',
},
importModpack: {
id: 'creation-flow.modal.modpack.action.import',
defaultMessage: 'Import modpack',
},
browseModpacks: {
id: 'creation-flow.modal.modpack.action.browse',
defaultMessage: 'Browse modpacks',
},
})
function proceedWithModpack() { function proceedWithModpack() {
debug('proceedWithModpack:', { debug('proceedWithModpack:', {
flowType: ctx.flowType, flowType: ctx.flowType,

View File

@@ -1,13 +1,7 @@
<template> <template>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<span class="font-semibold text-contrast"> <span class="font-semibold text-contrast">
{{ {{ setupTypeTitle }}
ctx.flowType === 'instance'
? 'Choose instance type'
: ctx.flowType === 'server-onboarding' || ctx.flowType === 'reset-server'
? 'Select installation type'
: 'Select world type'
}}
</span> </span>
<!-- Instance flow options --> <!-- Instance flow options -->
@@ -15,25 +9,25 @@
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<BigOptionButton <BigOptionButton
:icon="BoxesIcon" :icon="BoxesIcon"
title="Custom setup" :title="formatMessage(messages.customSetupTitle)"
description="Start from scratch by picking a loader and game version." :description="formatMessage(messages.customSetupDescription)"
@click="setSetupType('custom')" @click="setSetupType('custom')"
/> />
<BigOptionButton <BigOptionButton
:icon="PackageIcon" :icon="PackageIcon"
title="Modpack base" :title="formatMessage(messages.modpackBaseTitle)"
description="Use a popular modpack as your starting point." :description="formatMessage(messages.modpackBaseDescription)"
@click="setSetupType('modpack')" @click="setSetupType('modpack')"
/> />
<BigOptionButton <BigOptionButton
:icon="BoxImportIcon" :icon="BoxImportIcon"
title="Import instance" :title="formatMessage(messages.importInstanceTitle)"
description="Import an instance from Prism, CurseForge, or similar." :description="formatMessage(messages.importInstanceDescription)"
@click="ctx.setImportMode()" @click="ctx.setImportMode()"
/> />
</div> </div>
<span class="text-sm text-secondary"> <span class="text-sm text-secondary">
An instance is a Minecraft setup with a specific loader, version, and mods. {{ formatMessage(messages.instanceDescription) }}
</span> </span>
</template> </template>
@@ -42,20 +36,20 @@
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<BigOptionButton <BigOptionButton
:icon="PackageIcon" :icon="PackageIcon"
title="Modpack base" :title="formatMessage(messages.modpackBaseTitle)"
description="Use a popular modpack as your starting point." :description="formatMessage(messages.modpackBaseDescription)"
@click="setSetupType('modpack')" @click="setSetupType('modpack')"
/> />
<BigOptionButton <BigOptionButton
:icon="BoxesIcon" :icon="BoxesIcon"
title="Custom setup" :title="formatMessage(messages.customSetupTitle)"
description="Start from scratch by picking a loader and game version." :description="formatMessage(messages.customSetupDescription)"
@click="setSetupType('custom')" @click="setSetupType('custom')"
/> />
<BigOptionButton <BigOptionButton
:icon="BoxIcon" :icon="BoxIcon"
title="Vanilla Minecraft" :title="formatMessage(messages.vanillaMinecraftTitle)"
description="Classic Minecraft with no mods or plugins." :description="formatMessage(messages.vanillaMinecraftDescription)"
@click="setSetupType('vanilla')" @click="setSetupType('vanilla')"
/> />
</div> </div>
@@ -65,6 +59,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { BoxesIcon, BoxIcon, BoxImportIcon, PackageIcon } from '@modrinth/assets' import { BoxesIcon, BoxIcon, BoxImportIcon, PackageIcon } from '@modrinth/assets'
import { defineMessages, useVIntl } from '@modrinth/ui'
import { computed } from 'vue'
import { useDebugLogger } from '#ui/composables/debug-logger' import { useDebugLogger } from '#ui/composables/debug-logger'
@@ -74,6 +70,68 @@ import { injectCreationFlowContext } from '../creation-flow-context'
const debug = useDebugLogger('SetupTypeStage') const debug = useDebugLogger('SetupTypeStage')
const ctx = injectCreationFlowContext() const ctx = injectCreationFlowContext()
const { setSetupType: _setSetupType } = ctx const { setSetupType: _setSetupType } = ctx
const { formatMessage } = useVIntl()
const messages = defineMessages({
instanceTypeTitle: {
id: 'creation-flow.modal.setup-type.title.instance',
defaultMessage: 'Choose instance type',
},
installationTypeTitle: {
id: 'creation-flow.modal.setup-type.title.installation',
defaultMessage: 'Select installation type',
},
worldTypeTitle: {
id: 'creation-flow.modal.setup-type.title.world',
defaultMessage: 'Select world type',
},
customSetupTitle: {
id: 'creation-flow.modal.setup-type.option.custom-setup.title',
defaultMessage: 'Custom setup',
},
customSetupDescription: {
id: 'creation-flow.modal.setup-type.option.custom-setup.description',
defaultMessage: 'Start from scratch by picking a loader and game version.',
},
modpackBaseTitle: {
id: 'creation-flow.modal.setup-type.option.modpack-base.title',
defaultMessage: 'Modpack base',
},
modpackBaseDescription: {
id: 'creation-flow.modal.setup-type.option.modpack-base.description',
defaultMessage: 'Use a popular modpack or upload one as your starting point.',
},
importInstanceTitle: {
id: 'creation-flow.modal.setup-type.option.import-instance.title',
defaultMessage: 'Import instance',
},
importInstanceDescription: {
id: 'creation-flow.modal.setup-type.option.import-instance.description',
defaultMessage: 'Import an instance from Prism, CurseForge, or similar.',
},
instanceDescription: {
id: 'creation-flow.modal.setup-type.instance.description',
defaultMessage: 'An instance is a Minecraft setup with a specific loader, version, and mods.',
},
vanillaMinecraftTitle: {
id: 'creation-flow.modal.setup-type.option.vanilla-minecraft.title',
defaultMessage: 'Vanilla Minecraft',
},
vanillaMinecraftDescription: {
id: 'creation-flow.modal.setup-type.option.vanilla-minecraft.description',
defaultMessage: 'Classic Minecraft with no mods or plugins.',
},
})
const setupTypeTitle = computed(() => {
if (ctx.flowType === 'instance') {
return formatMessage(messages.instanceTypeTitle)
}
if (ctx.flowType === 'server-onboarding' || ctx.flowType === 'reset-server') {
return formatMessage(messages.installationTypeTitle)
}
return formatMessage(messages.worldTypeTitle)
})
function setSetupType(type: 'modpack' | 'custom' | 'vanilla') { function setSetupType(type: 'modpack' | 'custom' | 'vanilla') {
debug('selected:', type) debug('selected:', type)

View File

@@ -1,11 +1,18 @@
import type { Archon } from '@modrinth/api-client' import type { Archon, LauncherMeta } from '@modrinth/api-client'
import { useQueryClient } from '@tanstack/vue-query'
import { computed, type ComputedRef, type Ref, ref, type ShallowRef, watch } from 'vue' import { computed, type ComputedRef, type Ref, ref, type ShallowRef, watch } from 'vue'
import type { ComponentExposed } from 'vue-component-type-helpers' import type { ComponentExposed } from 'vue-component-type-helpers'
import { useDebugLogger } from '#ui/composables/debug-logger' import { useDebugLogger } from '#ui/composables/debug-logger'
import {
defineMessages,
type MessageDescriptor,
useVIntl,
type VIntlFormatters,
} from '#ui/composables/i18n'
import { formatLoaderLabel } from '#ui/utils/loaders' import { formatLoaderLabel } from '#ui/utils/loaders'
import { createContext } from '../../../providers' import { createContext, injectModrinthClient } from '../../../providers'
import type { ImportableLauncher } from '../../../providers/instance-import' import type { ImportableLauncher } from '../../../providers/instance-import'
import type { MultiStageModal, StageConfigInput } from '../../base' import type { MultiStageModal, StageConfigInput } from '../../base'
import type { ComboboxOption } from '../../base/Combobox.vue' import type { ComboboxOption } from '../../base/Combobox.vue'
@@ -17,6 +24,74 @@ export type Gamemode = 'survival' | 'creative' | 'hardcore'
export type Difficulty = 'peaceful' | 'easy' | 'normal' | 'hard' export type Difficulty = 'peaceful' | 'easy' | 'normal' | 'hard'
export type LoaderVersionType = 'stable' | 'latest' | 'other' export type LoaderVersionType = 'stable' | 'latest' | 'other'
export type GeneratorSettingsMode = 'default' | 'flat' | 'custom' export type GeneratorSettingsMode = 'default' | 'flat' | 'custom'
export type LoaderManifestResolver = (loader: string) => Promise<LauncherMeta.Manifest.v0.Manifest>
export interface LoaderVersionEntry {
id: string
stable: boolean
}
const loaderManifestQueryKey = (loader: string) =>
['creation-flow', 'loader-manifest', loader] as const
const paperSupportedVersionsQueryKey = ['creation-flow', 'paper', 'supported-versions'] as const
const purpurSupportedVersionsQueryKey = ['creation-flow', 'purpur', 'supported-versions'] as const
export const creationFlowMessages = defineMessages({
createWorldTitle: {
id: 'creation-flow.title.create-world',
defaultMessage: 'Create world',
},
setUpServerTitle: {
id: 'creation-flow.title.set-up-server',
defaultMessage: 'Set up server',
},
resetServerTitle: {
id: 'creation-flow.title.reset-server',
defaultMessage: 'Reset server',
},
createInstanceTitle: {
id: 'creation-flow.title.create-instance',
defaultMessage: 'Create instance',
},
createWorldButton: {
id: 'creation-flow.button.create-world',
defaultMessage: 'Create world',
},
createInstanceButton: {
id: 'creation-flow.button.create-instance',
defaultMessage: 'Create instance',
},
setupServerButton: {
id: 'creation-flow.button.setup-server',
defaultMessage: 'Setup server',
},
finishButton: {
id: 'creation-flow.button.finish',
defaultMessage: 'Finish',
},
importInstanceTitle: {
id: 'creation-flow.title.import-instance',
defaultMessage: 'Import instance',
},
importButton: {
id: 'creation-flow.button.import',
defaultMessage: 'Import',
},
importInstancesButton: {
id: 'creation-flow.button.import-instances',
defaultMessage: 'Import {count, plural, one {# instance} other {# instances}}',
},
chooseModpackTitle: {
id: 'creation-flow.title.choose-modpack',
defaultMessage: 'Choose modpack',
},
})
export const flowTypeHeadingMessages: Record<FlowType, MessageDescriptor> = {
world: creationFlowMessages.createWorldTitle,
'server-onboarding': creationFlowMessages.setUpServerTitle,
'reset-server': creationFlowMessages.resetServerTitle,
instance: creationFlowMessages.createInstanceTitle,
}
export interface ModpackSelection { export interface ModpackSelection {
projectId: string projectId: string
@@ -43,16 +118,10 @@ export interface ModpackSearchResult {
limit: number limit: number
} }
export const flowTypeHeadings: Record<FlowType, string> = {
world: 'Create world',
'server-onboarding': 'Set up server',
'reset-server': 'Reset server',
instance: 'Create instance',
}
export interface CreationFlowContextValue { export interface CreationFlowContextValue {
// Flow // Flow
flowType: FlowType flowType: FlowType
formatMessage: VIntlFormatters['formatMessage']
// Configuration // Configuration
availableLoaders: string[] availableLoaders: string[]
@@ -91,6 +160,9 @@ export interface CreationFlowContextValue {
hideLoaderChips: ComputedRef<boolean> hideLoaderChips: ComputedRef<boolean>
hideLoaderVersion: ComputedRef<boolean> hideLoaderVersion: ComputedRef<boolean>
showSnapshots: Ref<boolean> showSnapshots: Ref<boolean>
loaderVersionsCache: Ref<Record<string, { id: string; loaders: LoaderVersionEntry[] }[]>>
paperSupportedVersions: Ref<Set<string> | null>
purpurSupportedVersions: Ref<Set<string> | null>
// Modpack state // Modpack state
modpackSelection: Ref<ModpackSelection | null> modpackSelection: Ref<ModpackSelection | null>
@@ -133,10 +205,13 @@ export interface CreationFlowContextValue {
browseModpacks: () => void browseModpacks: () => void
finish: () => void finish: () => void
buildProperties: () => Archon.Content.v1.PropertiesFields buildProperties: () => Archon.Content.v1.PropertiesFields
fetchLoaderMetadata: (loader?: string | null) => Promise<void>
prefetchLoaderMetadata: () => Promise<void>
// Platform-provided search // Platform-provided search
searchModpacks: (query: string, limit?: number) => Promise<ModpackSearchResult> searchModpacks: (query: string, limit?: number) => Promise<ModpackSearchResult>
getProjectVersions: (projectId: string) => Promise<{ id: string }[]> getProjectVersions: (projectId: string) => Promise<{ id: string }[]>
getLoaderManifest: LoaderManifestResolver | null
} }
export const [injectCreationFlowContext, provideCreationFlowContext] = export const [injectCreationFlowContext, provideCreationFlowContext] =
@@ -156,6 +231,7 @@ export interface CreationFlowOptions {
onBack?: () => void onBack?: () => void
searchModpacks?: (query: string, limit?: number) => Promise<ModpackSearchResult> searchModpacks?: (query: string, limit?: number) => Promise<ModpackSearchResult>
getProjectVersions?: (projectId: string) => Promise<{ id: string }[]> getProjectVersions?: (projectId: string) => Promise<{ id: string }[]>
getLoaderManifest?: LoaderManifestResolver
} }
export function createCreationFlowContext( export function createCreationFlowContext(
@@ -168,6 +244,9 @@ export function createCreationFlowContext(
options: CreationFlowOptions = {}, options: CreationFlowOptions = {},
): CreationFlowContextValue { ): CreationFlowContextValue {
const debug = useDebugLogger('CreationFlow') const debug = useDebugLogger('CreationFlow')
const client = injectModrinthClient()
const queryClient = useQueryClient()
const { formatMessage } = useVIntl()
const availableLoaders = options.availableLoaders ?? ['fabric', 'neoforge', 'forge', 'quilt'] const availableLoaders = options.availableLoaders ?? ['fabric', 'neoforge', 'forge', 'quilt']
const showSnapshotToggle = options.showSnapshotToggle ?? false const showSnapshotToggle = options.showSnapshotToggle ?? false
const disableClose = options.disableClose ?? false const disableClose = options.disableClose ?? false
@@ -175,6 +254,9 @@ export function createCreationFlowContext(
const initialLoader = options.initialLoader ?? null const initialLoader = options.initialLoader ?? null
const initialGameVersion = options.initialGameVersion ?? null const initialGameVersion = options.initialGameVersion ?? null
const onBack = options.onBack ?? null const onBack = options.onBack ?? null
const searchModpacks = options.searchModpacks!
const getProjectVersions = options.getProjectVersions!
const getLoaderManifest = options.getLoaderManifest ?? null
const setupType = ref<SetupType | null>(null) const setupType = ref<SetupType | null>(null)
const isImportMode = ref(false) const isImportMode = ref(false)
@@ -207,6 +289,11 @@ export function createCreationFlowContext(
const loaderVersionType = ref<LoaderVersionType>('stable') const loaderVersionType = ref<LoaderVersionType>('stable')
const selectedLoaderVersion = ref<string | null>(null) const selectedLoaderVersion = ref<string | null>(null)
const showSnapshots = ref(false) const showSnapshots = ref(false)
const loaderVersionsCache = ref<Record<string, { id: string; loaders: LoaderVersionEntry[] }[]>>(
{},
)
const paperSupportedVersions = ref<Set<string> | null>(null)
const purpurSupportedVersions = ref<Set<string> | null>(null)
const autoInstanceName = computed(() => { const autoInstanceName = computed(() => {
const loader = selectedLoader.value const loader = selectedLoader.value
@@ -255,6 +342,83 @@ export function createCreationFlowContext(
() => setupType.value === 'vanilla' || selectedLoader.value === 'vanilla', () => setupType.value === 'vanilla' || selectedLoader.value === 'vanilla',
) )
function toApiLoaderName(loader: string): string {
return loader === 'neoforge' ? 'neo' : loader
}
async function fetchLoaderManifest(loader: string) {
const apiLoader = toApiLoaderName(loader)
if (loaderVersionsCache.value[apiLoader]) return
try {
const data = await queryClient.fetchQuery({
queryKey: loaderManifestQueryKey(apiLoader),
queryFn: async () =>
(await getLoaderManifest?.(apiLoader)) ??
(await client.launchermeta.manifest_v0.getManifest(apiLoader)),
staleTime: Infinity,
})
loaderVersionsCache.value[apiLoader] = data.gameVersions
debug('fetchLoaderManifest: loaded', apiLoader, 'gameVersions:', data.gameVersions.length)
} catch (error) {
debug('fetchLoaderManifest: failed', apiLoader, error)
loaderVersionsCache.value[apiLoader] = []
}
}
async function fetchPaperSupportedVersions() {
if (paperSupportedVersions.value) return
try {
paperSupportedVersions.value = await queryClient.fetchQuery({
queryKey: paperSupportedVersionsQueryKey,
queryFn: async () => {
const project = await client.paper.versions_v3.getProject()
return new Set(Object.values(project.versions).flat())
},
staleTime: Infinity,
})
} catch {
paperSupportedVersions.value = new Set()
}
}
async function fetchPurpurSupportedVersions() {
if (purpurSupportedVersions.value) return
try {
purpurSupportedVersions.value = await queryClient.fetchQuery({
queryKey: purpurSupportedVersionsQueryKey,
queryFn: async () => {
const project = await client.purpur.versions_v2.getProject()
return new Set(project.versions)
},
staleTime: Infinity,
})
} catch {
purpurSupportedVersions.value = new Set()
}
}
async function fetchLoaderMetadata(loader?: string | null) {
if (!loader || loader === 'vanilla') return
if (loader === 'paper') {
await fetchPaperSupportedVersions()
return
}
if (loader === 'purpur') {
await fetchPurpurSupportedVersions()
return
}
await fetchLoaderManifest(loader)
}
async function prefetchLoaderMetadata() {
await Promise.allSettled(
availableLoaders
.filter((loader) => loader !== 'vanilla')
.map((loader) => fetchLoaderMetadata(loader)),
)
}
async function reset() { async function reset() {
if (fetchExistingInstanceNames) { if (fetchExistingInstanceNames) {
existingInstanceNames.value = await fetchExistingInstanceNames() existingInstanceNames.value = await fetchExistingInstanceNames()
@@ -370,15 +534,13 @@ export function createCreationFlowContext(
return { known } return { known }
} }
const searchModpacks = options.searchModpacks!
const getProjectVersions = options.getProjectVersions!
const resolvedStageConfigs = disableClose const resolvedStageConfigs = disableClose
? stageConfigs.map((stage) => ({ ...stage, disableClose: true })) ? stageConfigs.map((stage) => ({ ...stage, disableClose: true }))
: stageConfigs : stageConfigs
const contextValue: CreationFlowContextValue = { const contextValue: CreationFlowContextValue = {
flowType, flowType,
formatMessage,
availableLoaders, availableLoaders,
showSnapshotToggle, showSnapshotToggle,
disableClose, disableClose,
@@ -407,6 +569,9 @@ export function createCreationFlowContext(
hideLoaderChips, hideLoaderChips,
hideLoaderVersion, hideLoaderVersion,
showSnapshots, showSnapshots,
loaderVersionsCache,
paperSupportedVersions,
purpurSupportedVersions,
modpackSelection, modpackSelection,
modpackFile, modpackFile,
modpackFilePath, modpackFilePath,
@@ -431,8 +596,11 @@ export function createCreationFlowContext(
browseModpacks, browseModpacks,
finish, finish,
buildProperties, buildProperties,
fetchLoaderMetadata,
prefetchLoaderMetadata,
searchModpacks, searchModpacks,
getProjectVersions, getProjectVersions,
getLoaderManifest,
} }
return contextValue return contextValue

View File

@@ -18,6 +18,7 @@ import {
createCreationFlowContext, createCreationFlowContext,
type CreationFlowContextValue, type CreationFlowContextValue,
type FlowType, type FlowType,
type LoaderManifestResolver,
type ModpackSearchResult, type ModpackSearchResult,
provideCreationFlowContext, provideCreationFlowContext,
} from './creation-flow-context' } from './creation-flow-context'
@@ -36,6 +37,7 @@ const props = withDefaults(
fade?: 'standard' | 'warning' | 'danger' fade?: 'standard' | 'warning' | 'danger'
searchModpacks?: (query: string, limit?: number) => Promise<ModpackSearchResult> searchModpacks?: (query: string, limit?: number) => Promise<ModpackSearchResult>
getProjectVersions?: (projectId: string) => Promise<{ id: string }[]> getProjectVersions?: (projectId: string) => Promise<{ id: string }[]>
getLoaderManifest?: LoaderManifestResolver
}>(), }>(),
{ {
type: 'world', type: 'world',
@@ -75,12 +77,14 @@ const ctx = createCreationFlowContext(
onBack: props.onBack ?? undefined, onBack: props.onBack ?? undefined,
searchModpacks: props.searchModpacks, searchModpacks: props.searchModpacks,
getProjectVersions: props.getProjectVersions, getProjectVersions: props.getProjectVersions,
getLoaderManifest: props.getLoaderManifest,
}, },
) )
provideCreationFlowContext(ctx) provideCreationFlowContext(ctx)
async function show() { async function show() {
await ctx.reset() await ctx.reset()
void ctx.prefetchLoaderMetadata()
modal.value?.setStage(0) modal.value?.setStage(0)
modal.value?.show() modal.value?.show()
} }

View File

@@ -1,25 +1,26 @@
import { LeftArrowIcon, PlusIcon, RightArrowIcon } from '@modrinth/assets' import { LeftArrowIcon, PlusIcon, RightArrowIcon } from '@modrinth/assets'
import { markRaw } from 'vue' import { markRaw } from 'vue'
import { commonMessages } from '#ui/utils/common-messages'
import type { StageConfigInput } from '../../../base' import type { StageConfigInput } from '../../../base'
import CustomSetupStage from '../components/CustomSetupStage.vue' import CustomSetupStage from '../components/CustomSetupStage.vue'
import { type CreationFlowContextValue, flowTypeHeadings } from '../creation-flow-context' import {
type CreationFlowContextValue,
creationFlowMessages,
flowTypeHeadingMessages,
} from '../creation-flow-context'
function isForwardBlocked(ctx: CreationFlowContextValue): boolean { function isForwardBlocked(ctx: CreationFlowContextValue): boolean {
if (!ctx.selectedGameVersion.value) return true if (!ctx.selectedGameVersion.value) return true
if (!ctx.hideLoaderChips.value && !ctx.selectedLoader.value) return true if (!ctx.hideLoaderChips.value && !ctx.selectedLoader.value) return true
if ( if (!ctx.hideLoaderVersion.value && !ctx.selectedLoaderVersion.value) return true
!ctx.hideLoaderVersion.value &&
ctx.loaderVersionType.value === 'other' &&
!ctx.selectedLoaderVersion.value
)
return true
return false return false
} }
export const stageConfig: StageConfigInput<CreationFlowContextValue> = { export const stageConfig: StageConfigInput<CreationFlowContextValue> = {
id: 'custom-setup', id: 'custom-setup',
title: (ctx) => flowTypeHeadings[ctx.flowType], title: (ctx) => ctx.formatMessage(flowTypeHeadingMessages[ctx.flowType]),
stageContent: markRaw(CustomSetupStage), stageContent: markRaw(CustomSetupStage),
skip: (ctx) => skip: (ctx) =>
ctx.setupType.value === 'modpack' || ctx.setupType.value === 'modpack' ||
@@ -27,7 +28,7 @@ export const stageConfig: StageConfigInput<CreationFlowContextValue> = {
ctx.isImportMode.value, ctx.isImportMode.value,
cannotNavigateForward: isForwardBlocked, cannotNavigateForward: isForwardBlocked,
leftButtonConfig: (ctx) => ({ leftButtonConfig: (ctx) => ({
label: 'Back', label: ctx.formatMessage(commonMessages.backButton),
icon: LeftArrowIcon, icon: LeftArrowIcon,
onClick: () => ctx.modal.value?.setStage('setup-type'), onClick: () => ctx.modal.value?.setStage('setup-type'),
}), }),
@@ -41,7 +42,7 @@ export const stageConfig: StageConfigInput<CreationFlowContextValue> = {
if (isInstance) { if (isInstance) {
return { return {
label: 'Create instance', label: ctx.formatMessage(creationFlowMessages.createInstanceButton),
icon: PlusIcon, icon: PlusIcon,
iconPosition: 'before' as const, iconPosition: 'before' as const,
color: 'brand' as const, color: 'brand' as const,
@@ -52,7 +53,9 @@ export const stageConfig: StageConfigInput<CreationFlowContextValue> = {
} }
return { return {
label: goesToNextStage ? 'Continue' : 'Finish', label: ctx.formatMessage(
goesToNextStage ? commonMessages.continueButton : creationFlowMessages.finishButton,
),
icon: goesToNextStage ? RightArrowIcon : null, icon: goesToNextStage ? RightArrowIcon : null,
iconPosition: 'after' as const, iconPosition: 'after' as const,
color: goesToNextStage ? undefined : ('brand' as const), color: goesToNextStage ? undefined : ('brand' as const),

View File

@@ -1,9 +1,15 @@
import { LeftArrowIcon, PlusIcon, RightArrowIcon } from '@modrinth/assets' import { LeftArrowIcon, PlusIcon, RightArrowIcon } from '@modrinth/assets'
import { markRaw } from 'vue' import { markRaw } from 'vue'
import { commonMessages } from '#ui/utils/common-messages'
import type { StageConfigInput } from '../../../base' import type { StageConfigInput } from '../../../base'
import FinalConfigStage from '../components/FinalConfigStage.vue' import FinalConfigStage from '../components/FinalConfigStage.vue'
import { type CreationFlowContextValue, flowTypeHeadings } from '../creation-flow-context' import {
type CreationFlowContextValue,
creationFlowMessages,
flowTypeHeadingMessages,
} from '../creation-flow-context'
function isForwardBlocked(ctx: CreationFlowContextValue): boolean { function isForwardBlocked(ctx: CreationFlowContextValue): boolean {
if (ctx.flowType === 'world' && !ctx.worldName.value.trim()) return true if (ctx.flowType === 'world' && !ctx.worldName.value.trim()) return true
@@ -13,12 +19,12 @@ function isForwardBlocked(ctx: CreationFlowContextValue): boolean {
export const stageConfig: StageConfigInput<CreationFlowContextValue> = { export const stageConfig: StageConfigInput<CreationFlowContextValue> = {
id: 'final-config', id: 'final-config',
title: (ctx) => flowTypeHeadings[ctx.flowType], title: (ctx) => ctx.formatMessage(flowTypeHeadingMessages[ctx.flowType]),
stageContent: markRaw(FinalConfigStage), stageContent: markRaw(FinalConfigStage),
skip: (ctx) => ctx.flowType === 'instance' || ctx.isImportMode.value, skip: (ctx) => ctx.flowType === 'instance' || ctx.isImportMode.value,
cannotNavigateForward: isForwardBlocked, cannotNavigateForward: isForwardBlocked,
leftButtonConfig: (ctx) => ({ leftButtonConfig: (ctx) => ({
label: 'Back', label: ctx.formatMessage(commonMessages.backButton),
icon: LeftArrowIcon, icon: LeftArrowIcon,
onClick: () => { onClick: () => {
if (ctx.onBack) { if (ctx.onBack) {
@@ -33,14 +39,15 @@ export const stageConfig: StageConfigInput<CreationFlowContextValue> = {
const isOnboarding = ctx.flowType === 'server-onboarding' const isOnboarding = ctx.flowType === 'server-onboarding'
const isReset = ctx.flowType === 'reset-server' const isReset = ctx.flowType === 'reset-server'
const isFinish = isWorld || isOnboarding || isReset const isFinish = isWorld || isOnboarding || isReset
return { const label = isWorld
label: isWorld ? ctx.formatMessage(creationFlowMessages.createWorldButton)
? 'Create world'
: isReset : isReset
? 'Reset server' ? ctx.formatMessage(commonMessages.resetServerButton)
: isOnboarding : isOnboarding
? 'Setup server' ? ctx.formatMessage(creationFlowMessages.setupServerButton)
: 'Continue', : ctx.formatMessage(commonMessages.continueButton)
return {
label,
icon: isFinish ? PlusIcon : RightArrowIcon, icon: isFinish ? PlusIcon : RightArrowIcon,
iconPosition: isFinish ? ('before' as const) : ('after' as const), iconPosition: isFinish ? ('before' as const) : ('after' as const),
color: isReset ? ('red' as const) : isFinish ? ('brand' as const) : undefined, color: isReset ? ('red' as const) : isFinish ? ('brand' as const) : undefined,

View File

@@ -1,9 +1,11 @@
import { DownloadIcon, LeftArrowIcon } from '@modrinth/assets' import { DownloadIcon, LeftArrowIcon } from '@modrinth/assets'
import { markRaw } from 'vue' import { markRaw } from 'vue'
import { commonMessages } from '#ui/utils/common-messages'
import type { StageConfigInput } from '../../../base' import type { StageConfigInput } from '../../../base'
import ImportInstanceStage from '../components/ImportInstanceStage.vue' import ImportInstanceStage from '../components/ImportInstanceStage.vue'
import type { CreationFlowContextValue } from '../creation-flow-context' import { type CreationFlowContextValue, creationFlowMessages } from '../creation-flow-context'
function getSelectedCount(ctx: CreationFlowContextValue): number { function getSelectedCount(ctx: CreationFlowContextValue): number {
let count = 0 let count = 0
@@ -15,11 +17,11 @@ function getSelectedCount(ctx: CreationFlowContextValue): number {
export const stageConfig: StageConfigInput<CreationFlowContextValue> = { export const stageConfig: StageConfigInput<CreationFlowContextValue> = {
id: 'import-instance', id: 'import-instance',
title: 'Import instance', title: (ctx) => ctx.formatMessage(creationFlowMessages.importInstanceTitle),
stageContent: markRaw(ImportInstanceStage), stageContent: markRaw(ImportInstanceStage),
skip: (ctx) => !ctx.isImportMode.value, skip: (ctx) => !ctx.isImportMode.value,
leftButtonConfig: (ctx) => ({ leftButtonConfig: (ctx) => ({
label: 'Back', label: ctx.formatMessage(commonMessages.backButton),
icon: LeftArrowIcon, icon: LeftArrowIcon,
onClick: () => { onClick: () => {
ctx.isImportMode.value = false ctx.isImportMode.value = false
@@ -29,7 +31,10 @@ export const stageConfig: StageConfigInput<CreationFlowContextValue> = {
rightButtonConfig: (ctx) => { rightButtonConfig: (ctx) => {
const count = getSelectedCount(ctx) const count = getSelectedCount(ctx)
return { return {
label: count > 0 ? `Import ${count} instance${count !== 1 ? 's' : ''}` : 'Import', label:
count > 0
? ctx.formatMessage(creationFlowMessages.importInstancesButton, { count })
: ctx.formatMessage(creationFlowMessages.importButton),
icon: DownloadIcon, icon: DownloadIcon,
iconPosition: 'before' as const, iconPosition: 'before' as const,
color: 'brand' as const, color: 'brand' as const,

View File

@@ -1,17 +1,19 @@
import { LeftArrowIcon } from '@modrinth/assets' import { LeftArrowIcon } from '@modrinth/assets'
import { markRaw } from 'vue' import { markRaw } from 'vue'
import { commonMessages } from '#ui/utils/common-messages'
import type { StageConfigInput } from '../../../base' import type { StageConfigInput } from '../../../base'
import ModpackStage from '../components/ModpackStage.vue' import ModpackStage from '../components/ModpackStage.vue'
import type { CreationFlowContextValue } from '../creation-flow-context' import { type CreationFlowContextValue, creationFlowMessages } from '../creation-flow-context'
export const stageConfig: StageConfigInput<CreationFlowContextValue> = { export const stageConfig: StageConfigInput<CreationFlowContextValue> = {
id: 'modpack', id: 'modpack',
title: 'Choose modpack', title: (ctx) => ctx.formatMessage(creationFlowMessages.chooseModpackTitle),
stageContent: markRaw(ModpackStage), stageContent: markRaw(ModpackStage),
skip: (ctx) => ctx.setupType.value !== 'modpack' || ctx.isImportMode.value, skip: (ctx) => ctx.setupType.value !== 'modpack' || ctx.isImportMode.value,
leftButtonConfig: (ctx) => ({ leftButtonConfig: (ctx) => ({
label: 'Back', label: ctx.formatMessage(commonMessages.backButton),
icon: LeftArrowIcon, icon: LeftArrowIcon,
onClick: () => ctx.modal.value?.setStage('setup-type'), onClick: () => ctx.modal.value?.setStage('setup-type'),
}), }),

View File

@@ -2,11 +2,11 @@ import { markRaw } from 'vue'
import type { StageConfigInput } from '../../../base' import type { StageConfigInput } from '../../../base'
import SetupTypeStage from '../components/SetupTypeStage.vue' import SetupTypeStage from '../components/SetupTypeStage.vue'
import { type CreationFlowContextValue, flowTypeHeadings } from '../creation-flow-context' import { type CreationFlowContextValue, flowTypeHeadingMessages } from '../creation-flow-context'
export const stageConfig: StageConfigInput<CreationFlowContextValue> = { export const stageConfig: StageConfigInput<CreationFlowContextValue> = {
id: 'setup-type', id: 'setup-type',
title: (ctx) => flowTypeHeadings[ctx.flowType], title: (ctx) => ctx.formatMessage(flowTypeHeadingMessages[ctx.flowType]),
stageContent: markRaw(SetupTypeStage), stageContent: markRaw(SetupTypeStage),
leftButtonConfig: null, leftButtonConfig: null,
rightButtonConfig: null, rightButtonConfig: null,

View File

@@ -16,7 +16,7 @@ import { computed } from 'vue'
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const { currentMember, projectV2, projectV3, refreshProject } = injectProjectPageContext() const { currentMember, projectV2, projectV3, invalidate } = injectProjectPageContext()
const { handleError } = injectNotificationManager() const { handleError } = injectNotificationManager()
const client = injectModrinthClient() const client = injectModrinthClient()
@@ -53,7 +53,7 @@ const { saved, current, saving, reset, save } = useSavable(
environment, environment,
side_types_migration_review_status: 'reviewed', side_types_migration_review_status: 'reviewed',
}) })
await refreshProject() await invalidate()
reset() reset()
} catch (err) { } catch (err) {
handleError(err as Error) handleError(err as Error)

View File

@@ -207,6 +207,8 @@ function emitReinstall(args?: { loader: string; lVersion: string; mVersion: stri
} }
function show() { function show() {
void creationFlowRef.value?.ctx?.fetchLoaderMetadata('paper')
void creationFlowRef.value?.ctx?.fetchLoaderMetadata('purpur')
creationFlowRef.value?.show() creationFlowRef.value?.show()
} }

View File

@@ -1,14 +1,18 @@
<template> <template>
<div class="mx-auto flex w-fit flex-col items-start gap-4 mt-16 max-w-[500px]"> <div class="mx-auto flex w-fit flex-col items-start gap-4 mt-16 max-w-[500px]">
<div class="flex flex-col gap-2 w-full"> <div class="flex flex-col gap-2 w-full">
<h2 class="m-0 text-2xl font-semibold text-contrast">Welcome to Modrinth Hosting</h2> <h2 class="m-0 text-2xl font-semibold text-contrast">
{{ formatMessage(messages.welcomeTitle) }}
</h2>
<p class="m-0 text-base text-secondary"> <p class="m-0 text-base text-secondary">
Your server is ready. Here's what you need to do to start playing! {{ formatMessage(messages.welcomeDescription) }}
</p> </p>
</div> </div>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<span class="text-base font-medium text-secondary"> Setup your server (~2mins) </span> <span class="text-base font-medium text-secondary">
{{ formatMessage(messages.setupStepsHeading) }}
</span>
<div class="rounded-[20px] border border-solid border-surface-5 bg-surface-3 p-5"> <div class="rounded-[20px] border border-solid border-surface-5 bg-surface-3 p-5">
<div class="flex flex-col"> <div class="flex flex-col">
@@ -41,11 +45,13 @@
<ButtonStyled v-if="uploading" size="large"> <ButtonStyled v-if="uploading" size="large">
<button class="ml-auto" disabled> <button class="ml-auto" disabled>
<SpinnerIcon class="animate-spin" /> <SpinnerIcon class="animate-spin" />
Uploading ({{ uploadPercent }}%) {{ formatMessage(messages.uploadingProgress, { percent: uploadPercent }) }}
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled v-else color="brand" size="large"> <ButtonStyled v-else color="brand" size="large">
<button class="ml-auto" @click="openModal">Setup server <RightArrowIcon /></button> <button class="ml-auto" @click="openModal">
{{ formatMessage(messages.setupServerButton) }} <RightArrowIcon />
</button>
</ButtonStyled> </ButtonStyled>
</div> </div>
@@ -66,7 +72,13 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Archon } from '@modrinth/api-client' import type { Archon } from '@modrinth/api-client'
import { GlobeIcon, PackageIcon, RightArrowIcon, SpinnerIcon, UsersIcon } from '@modrinth/assets' import { GlobeIcon, PackageIcon, RightArrowIcon, SpinnerIcon, UsersIcon } from '@modrinth/assets'
import { ButtonStyled, injectModrinthClient, injectNotificationManager } from '@modrinth/ui' import {
ButtonStyled,
defineMessages,
injectModrinthClient,
injectNotificationManager,
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query' import { useQueryClient } from '@tanstack/vue-query'
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue' import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
@@ -77,6 +89,73 @@ import { injectModrinthServerContext } from '#ui/providers'
const client = injectModrinthClient() const client = injectModrinthClient()
const { addNotification } = injectNotificationManager() const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
welcomeTitle: {
id: 'servers.setup.onboarding.welcome.title',
defaultMessage: 'Welcome to Modrinth Hosting',
},
welcomeDescription: {
id: 'servers.setup.onboarding.welcome.description',
defaultMessage: "Your server is ready. Here's what you need to do to start playing!",
},
setupStepsHeading: {
id: 'servers.setup.onboarding.steps.heading',
defaultMessage: 'Setup your server (~2mins)',
},
uploadingProgress: {
id: 'servers.setup.onboarding.uploading.progress',
defaultMessage: 'Uploading ({percent, number}%)',
},
setupServerButton: {
id: 'servers.setup.onboarding.setup-server.button',
defaultMessage: 'Setup server',
},
modpackUploadFailedTitle: {
id: 'servers.setup.onboarding.modpack-upload-failed.title',
defaultMessage: 'Modpack upload failed',
},
modpackUploadFailedText: {
id: 'servers.setup.onboarding.modpack-upload-failed.text',
defaultMessage: 'An unexpected error occurred while uploading. Please try again later.',
},
installationFailedTitle: {
id: 'servers.setup.onboarding.installation-failed.title',
defaultMessage: 'Installation failed',
},
installationFailedText: {
id: 'servers.setup.onboarding.installation-failed.text',
defaultMessage: 'An unexpected error occurred while installing. Please try again later.',
},
chooseWhatToPlayTitle: {
id: 'servers.setup.onboarding.step.choose.title',
defaultMessage: 'Choose what to play',
},
chooseWhatToPlayDescription: {
id: 'servers.setup.onboarding.step.choose.description',
defaultMessage:
'Pick your favorite modpack from Modrinth, or choose a loader and add the mods you want.',
},
configureWorldTitle: {
id: 'servers.setup.onboarding.step.configure-world.title',
defaultMessage: 'Configure your world',
},
configureWorldDescription: {
id: 'servers.setup.onboarding.step.configure-world.description',
defaultMessage:
'Set up your world just like singleplayer. Choose your gamemode and world seed.',
},
inviteFriendsTitle: {
id: 'servers.setup.onboarding.step.invite-friends.title',
defaultMessage: 'Invite your friends',
},
inviteFriendsDescription: {
id: 'servers.setup.onboarding.step.invite-friends.description',
defaultMessage:
"Share your server with friends by copying the address and letting them know which mods they'll need to join.",
},
})
async function searchModpacks(query: string, limit: number = 10) { async function searchModpacks(query: string, limit: number = 10) {
return client.labrinth.projects_v2.search({ return client.labrinth.projects_v2.search({
@@ -209,8 +288,8 @@ const onCreate = async (config: CreationFlowContextValue) => {
await finalizeSetup() await finalizeSetup()
} catch { } catch {
addNotification({ addNotification({
title: 'Modpack upload failed', title: formatMessage(messages.modpackUploadFailedTitle),
text: 'An unexpected error occurred while uploading. Please try again later.', text: formatMessage(messages.modpackUploadFailedText),
type: 'error', type: 'error',
}) })
config.loading.value = false config.loading.value = false
@@ -252,31 +331,29 @@ const onCreate = async (config: CreationFlowContextValue) => {
await finalizeSetup() await finalizeSetup()
} catch { } catch {
addNotification({ addNotification({
title: 'Installation failed', title: formatMessage(messages.installationFailedTitle),
text: 'An unexpected error occurred while installing. Please try again later.', text: formatMessage(messages.installationFailedText),
type: 'error', type: 'error',
}) })
config.loading.value = false config.loading.value = false
} }
} }
const steps = [ const steps = computed(() => [
{ {
icon: PackageIcon, icon: PackageIcon,
title: 'Choose what to play', title: formatMessage(messages.chooseWhatToPlayTitle),
description: description: formatMessage(messages.chooseWhatToPlayDescription),
'Pick your favorite modpack from Modrinth, or choose a loader and add the mods you want.',
}, },
{ {
icon: GlobeIcon, icon: GlobeIcon,
title: 'Configure your world', title: formatMessage(messages.configureWorldTitle),
description: 'Set up your world just like singleplayer. Choose your gamemode and world seed.', description: formatMessage(messages.configureWorldDescription),
}, },
{ {
icon: UsersIcon, icon: UsersIcon,
title: 'Invite your friends', title: formatMessage(messages.inviteFriendsTitle),
description: description: formatMessage(messages.inviteFriendsDescription),
"Share your server with friends by copying the address and letting them know which mods they'll need to join.",
}, },
] ])
</script> </script>

View File

@@ -491,6 +491,276 @@
"content.selection-bar.selected-count-simple": { "content.selection-bar.selected-count-simple": {
"defaultMessage": "{count, number} selected" "defaultMessage": "{count, number} selected"
}, },
"creation-flow.button.create-instance": {
"defaultMessage": "Create instance"
},
"creation-flow.button.create-world": {
"defaultMessage": "Create world"
},
"creation-flow.button.finish": {
"defaultMessage": "Finish"
},
"creation-flow.button.import": {
"defaultMessage": "Import"
},
"creation-flow.button.import-instances": {
"defaultMessage": "Import {count, plural, one {# instance} other {# instances}}"
},
"creation-flow.button.setup-server": {
"defaultMessage": "Setup server"
},
"creation-flow.modal.custom-setup.build-number.label": {
"defaultMessage": "Build number"
},
"creation-flow.modal.custom-setup.build-number.placeholder": {
"defaultMessage": "Select build number"
},
"creation-flow.modal.custom-setup.build-number.search-placeholder": {
"defaultMessage": "Search build number..."
},
"creation-flow.modal.custom-setup.content-loader.label": {
"defaultMessage": "Content loader"
},
"creation-flow.modal.custom-setup.game-version.placeholder": {
"defaultMessage": "Select game version"
},
"creation-flow.modal.custom-setup.game-version.search-placeholder": {
"defaultMessage": "Search game version..."
},
"creation-flow.modal.custom-setup.icon.remove": {
"defaultMessage": "Remove icon"
},
"creation-flow.modal.custom-setup.icon.select": {
"defaultMessage": "Select icon"
},
"creation-flow.modal.custom-setup.loader-version-type.latest": {
"defaultMessage": "Latest"
},
"creation-flow.modal.custom-setup.loader-version-type.other": {
"defaultMessage": "Other"
},
"creation-flow.modal.custom-setup.loader-version-type.stable": {
"defaultMessage": "Stable"
},
"creation-flow.modal.custom-setup.loader-version.label": {
"defaultMessage": "Loader version"
},
"creation-flow.modal.custom-setup.loader-version.placeholder": {
"defaultMessage": "Select loader version"
},
"creation-flow.modal.custom-setup.loader-version.search-placeholder": {
"defaultMessage": "Search loader version..."
},
"creation-flow.modal.custom-setup.loader.label": {
"defaultMessage": "Loader"
},
"creation-flow.modal.custom-setup.name.label": {
"defaultMessage": "Name"
},
"creation-flow.modal.custom-setup.name.placeholder": {
"defaultMessage": "Enter instance name"
},
"creation-flow.modal.custom-setup.options.no-versions-available": {
"defaultMessage": "No versions available"
},
"creation-flow.modal.final-config.additional-settings.title": {
"defaultMessage": "Additional settings"
},
"creation-flow.modal.final-config.backup.before-reset-server.name": {
"defaultMessage": "Before reset server"
},
"creation-flow.modal.final-config.difficulty.easy": {
"defaultMessage": "Easy"
},
"creation-flow.modal.final-config.difficulty.hard": {
"defaultMessage": "Hard"
},
"creation-flow.modal.final-config.difficulty.label": {
"defaultMessage": "Difficulty"
},
"creation-flow.modal.final-config.difficulty.normal": {
"defaultMessage": "Normal"
},
"creation-flow.modal.final-config.difficulty.peaceful": {
"defaultMessage": "Peaceful"
},
"creation-flow.modal.final-config.game-version.placeholder": {
"defaultMessage": "Select game version"
},
"creation-flow.modal.final-config.gamemode.creative": {
"defaultMessage": "Creative"
},
"creation-flow.modal.final-config.gamemode.hardcore": {
"defaultMessage": "Hardcore"
},
"creation-flow.modal.final-config.gamemode.label": {
"defaultMessage": "Gamemode"
},
"creation-flow.modal.final-config.gamemode.survival": {
"defaultMessage": "Survival"
},
"creation-flow.modal.final-config.generate-structures.description": {
"defaultMessage": "Controls whether villages, strongholds, and other structures generate in new chunks."
},
"creation-flow.modal.final-config.generate-structures.label": {
"defaultMessage": "Generate structures"
},
"creation-flow.modal.final-config.generator-settings-json.placeholder": {
"defaultMessage": "Enter generator settings JSON"
},
"creation-flow.modal.final-config.generator-settings.custom": {
"defaultMessage": "Custom"
},
"creation-flow.modal.final-config.generator-settings.default": {
"defaultMessage": "Default"
},
"creation-flow.modal.final-config.generator-settings.description": {
"defaultMessage": "Used for advanced world customization such as custom Superflat layers."
},
"creation-flow.modal.final-config.generator-settings.flat": {
"defaultMessage": "Flat"
},
"creation-flow.modal.final-config.generator-settings.label": {
"defaultMessage": "Generator settings"
},
"creation-flow.modal.final-config.generator-settings.placeholder": {
"defaultMessage": "Select generator settings"
},
"creation-flow.modal.final-config.world-name.label": {
"defaultMessage": "World name"
},
"creation-flow.modal.final-config.world-name.placeholder": {
"defaultMessage": "Enter world name"
},
"creation-flow.modal.final-config.world-seed.description": {
"defaultMessage": "Leave blank for a random seed."
},
"creation-flow.modal.final-config.world-seed.label-with-optional": {
"defaultMessage": "World seed <optional>(Optional)</optional>"
},
"creation-flow.modal.final-config.world-seed.placeholder": {
"defaultMessage": "Enter world seed"
},
"creation-flow.modal.final-config.world-type.amplified": {
"defaultMessage": "Amplified"
},
"creation-flow.modal.final-config.world-type.default": {
"defaultMessage": "Default"
},
"creation-flow.modal.final-config.world-type.label": {
"defaultMessage": "World type"
},
"creation-flow.modal.final-config.world-type.large-biomes": {
"defaultMessage": "Large Biomes"
},
"creation-flow.modal.final-config.world-type.placeholder": {
"defaultMessage": "Select world type"
},
"creation-flow.modal.final-config.world-type.single-biome": {
"defaultMessage": "Single Biome"
},
"creation-flow.modal.final-config.world-type.superflat": {
"defaultMessage": "Superflat"
},
"creation-flow.modal.import-instance.action.add": {
"defaultMessage": "Add"
},
"creation-flow.modal.import-instance.custom-launcher.name": {
"defaultMessage": "Custom ({pathName})"
},
"creation-flow.modal.import-instance.detecting-launcher-instances": {
"defaultMessage": "Detecting launcher instances..."
},
"creation-flow.modal.import-instance.launcher-instances.title": {
"defaultMessage": "Launcher instances"
},
"creation-flow.modal.import-instance.launcher-path.add": {
"defaultMessage": "Add launcher path"
},
"creation-flow.modal.import-instance.launcher-path.placeholder": {
"defaultMessage": "Path to launcher..."
},
"creation-flow.modal.import-instance.notification.no-instances-found.text": {
"defaultMessage": "No importable instances were found at the specified path."
},
"creation-flow.modal.import-instance.notification.no-instances-found.title": {
"defaultMessage": "No instances found"
},
"creation-flow.modal.import-instance.search.placeholder": {
"defaultMessage": "Search instance name"
},
"creation-flow.modal.import-instance.selection.clear-all": {
"defaultMessage": "Clear all"
},
"creation-flow.modal.modpack.action.browse": {
"defaultMessage": "Browse modpacks"
},
"creation-flow.modal.modpack.action.import": {
"defaultMessage": "Import modpack"
},
"creation-flow.modal.modpack.known-modpack.prompt": {
"defaultMessage": "Already know the modpack you want to install?"
},
"creation-flow.modal.modpack.search.no-results": {
"defaultMessage": "No results found"
},
"creation-flow.modal.modpack.search.placeholder": {
"defaultMessage": "Search for modpack"
},
"creation-flow.modal.setup-type.instance.description": {
"defaultMessage": "An instance is a Minecraft setup with a specific loader, version, and mods."
},
"creation-flow.modal.setup-type.option.custom-setup.description": {
"defaultMessage": "Start from scratch by picking a loader and game version."
},
"creation-flow.modal.setup-type.option.custom-setup.title": {
"defaultMessage": "Custom setup"
},
"creation-flow.modal.setup-type.option.import-instance.description": {
"defaultMessage": "Import an instance from Prism, CurseForge, or similar."
},
"creation-flow.modal.setup-type.option.import-instance.title": {
"defaultMessage": "Import instance"
},
"creation-flow.modal.setup-type.option.modpack-base.description": {
"defaultMessage": "Use a popular modpack or upload one as your starting point."
},
"creation-flow.modal.setup-type.option.modpack-base.title": {
"defaultMessage": "Modpack base"
},
"creation-flow.modal.setup-type.option.vanilla-minecraft.description": {
"defaultMessage": "Classic Minecraft with no mods or plugins."
},
"creation-flow.modal.setup-type.option.vanilla-minecraft.title": {
"defaultMessage": "Vanilla Minecraft"
},
"creation-flow.modal.setup-type.title.installation": {
"defaultMessage": "Select installation type"
},
"creation-flow.modal.setup-type.title.instance": {
"defaultMessage": "Choose instance type"
},
"creation-flow.modal.setup-type.title.world": {
"defaultMessage": "Select world type"
},
"creation-flow.title.choose-modpack": {
"defaultMessage": "Choose modpack"
},
"creation-flow.title.create-instance": {
"defaultMessage": "Create instance"
},
"creation-flow.title.create-world": {
"defaultMessage": "Create world"
},
"creation-flow.title.import-instance": {
"defaultMessage": "Import instance"
},
"creation-flow.title.reset-server": {
"defaultMessage": "Reset server"
},
"creation-flow.title.set-up-server": {
"defaultMessage": "Set up server"
},
"files.conflict-modal.header": { "files.conflict-modal.header": {
"defaultMessage": "Extract summary" "defaultMessage": "Extract summary"
}, },
@@ -3062,6 +3332,51 @@
"servers.region.western-europe": { "servers.region.western-europe": {
"defaultMessage": "Western Europe" "defaultMessage": "Western Europe"
}, },
"servers.setup.onboarding.installation-failed.text": {
"defaultMessage": "An unexpected error occurred while installing. Please try again later."
},
"servers.setup.onboarding.installation-failed.title": {
"defaultMessage": "Installation failed"
},
"servers.setup.onboarding.modpack-upload-failed.text": {
"defaultMessage": "An unexpected error occurred while uploading. Please try again later."
},
"servers.setup.onboarding.modpack-upload-failed.title": {
"defaultMessage": "Modpack upload failed"
},
"servers.setup.onboarding.setup-server.button": {
"defaultMessage": "Setup server"
},
"servers.setup.onboarding.step.choose.description": {
"defaultMessage": "Pick your favorite modpack from Modrinth, or choose a loader and add the mods you want."
},
"servers.setup.onboarding.step.choose.title": {
"defaultMessage": "Choose what to play"
},
"servers.setup.onboarding.step.configure-world.description": {
"defaultMessage": "Set up your world just like singleplayer. Choose your gamemode and world seed."
},
"servers.setup.onboarding.step.configure-world.title": {
"defaultMessage": "Configure your world"
},
"servers.setup.onboarding.step.invite-friends.description": {
"defaultMessage": "Share your server with friends by copying the address and letting them know which mods they'll need to join."
},
"servers.setup.onboarding.step.invite-friends.title": {
"defaultMessage": "Invite your friends"
},
"servers.setup.onboarding.steps.heading": {
"defaultMessage": "Setup your server (~2mins)"
},
"servers.setup.onboarding.uploading.progress": {
"defaultMessage": "Uploading ({percent, number}%)"
},
"servers.setup.onboarding.welcome.description": {
"defaultMessage": "Your server is ready. Here's what you need to do to start playing!"
},
"servers.setup.onboarding.welcome.title": {
"defaultMessage": "Welcome to Modrinth Hosting"
},
"servers.setup.rate-limit.text": { "servers.setup.rate-limit.text": {
"defaultMessage": "You are being rate limited. Please try again later." "defaultMessage": "You are being rate limited. Please try again later."
}, },