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

View File

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

View File

@@ -11,6 +11,7 @@ import type ModpackAlreadyInstalledModal from '@/components/ui/modal/ModpackAlre
import { trackEvent } from '@/helpers/analytics'
import { get_project_versions, get_search_results } from '@/helpers/cache.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, list } from '@/helpers/profile.js'
import type { InstanceLoader } from '@/helpers/types'
@@ -165,6 +166,7 @@ export function setupCreationModal(notificationManager: AbstractWebNotificationM
handleBrowseModpacks,
searchModpacks,
getProjectVersions,
getLoaderManifest,
setModpackAlreadyInstalledModal,
handleModpackDuplicateCreateAnyway,
handleModpackDuplicateGoToInstance,

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
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 emails from '~/templates/emails'
@@ -14,7 +14,7 @@ const filtered = computed(() =>
function openAll() {
let offset = 0
for (const id of filtered.value) {
openPreview(id, offset)
openPopupPreview(id, offset)
offset++
}
}
@@ -23,7 +23,81 @@ function copy(id: string) {
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 height = 850
const left = window.screenX + (window.outerWidth - width) / 2 + ((offset * 28) % 320)
@@ -48,6 +122,69 @@ onMounted(() => {
<template>
<div class="normal-page no-sidebar">
<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">
<Card class="mb-6 flex flex-col gap-4">
<div class="flex flex-wrap items-center gap-3">
@@ -97,7 +234,7 @@ onMounted(() => {
<div class="mt-auto flex gap-2">
<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" />
Preview
</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>
<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 {authprovider.name} account has been connected and you can now use it to 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"> 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">
Your <b>{authprovider.name}</b> account has been disconnected and you can no longer use it to
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>
<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">
At your request, we've successfully updated your Modrinth account's email to
{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>
<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">
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.

View File

@@ -13,7 +13,7 @@ import StyledEmail from '../shared/StyledEmail.vue'
A new personal access token has been created
</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">
A new personal access token, <b>{newpat.token_name}</b>, has been added to your account.
</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>
<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">
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>
<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">
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

View File

@@ -14,7 +14,7 @@ import StyledEmail from '../shared/StyledEmail.vue'
Payment failed for {paymentfailed.service}
</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">
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.

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>
<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">
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>
<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">
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.

View File

@@ -8,7 +8,7 @@ import StyledEmail from '../shared/StyledEmail.vue'
<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>
<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">

View File

@@ -14,7 +14,7 @@ import StyledEmail from '../shared/StyledEmail.vue'
Price change for {taxnotification.service}
</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">
We're writing to let you know about an update to your {taxnotification.service} subscription.
</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>
<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">
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.

View File

@@ -13,7 +13,7 @@ import StyledEmail from '../shared/StyledEmail.vue'
You've disabled two-factor authentication security on your account.
</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">
At your request, we've removed two-factor authentication from your Modrinth account.
</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>
<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">
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.

View File

@@ -29,7 +29,7 @@ import StyledEmail from '../shared/StyledEmail.vue'
>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">
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
>
<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"
>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
>
<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">
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
>
<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"
>Modrinth user

View File

@@ -24,7 +24,7 @@ import StyledEmail from '../shared/StyledEmail.vue'
</Section>
<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">
Modrinth user

View File

@@ -26,7 +26,7 @@ import StyledEmail from '../shared/StyledEmail.vue'
>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">
Your project

View File

@@ -29,7 +29,7 @@ import StyledEmail from '../shared/StyledEmail.vue'
>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">
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>
<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">
The ownership of

View File

@@ -67,6 +67,7 @@ const tailwindConfig = {
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<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
href="https://fonts.googleapis.com/css?family=Inter:700,400"
rel="stylesheet"
@@ -78,6 +79,9 @@ const tailwindConfig = {
line-height:100%; } table { border-collapse:separate; } a, a:link, a:visited {
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; }
.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>
</Head>

View File

@@ -7,13 +7,13 @@
<ButtonStyled type="outlined">
<button class="!border-surface-5" @click="triggerIconInput">
<UploadIcon />
Select icon
{{ formatMessage(messages.selectIcon) }}
</button>
</ButtonStyled>
<ButtonStyled type="outlined">
<button class="!border-surface-5" :disabled="!ctx.instanceIcon.value" @click="removeIcon">
<XIcon />
Remove icon
{{ formatMessage(messages.removeIcon) }}
</button>
</ButtonStyled>
</div>
@@ -21,17 +21,19 @@
<!-- Instance-specific: Name field -->
<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
v-model="ctx.instanceName.value"
:placeholder="ctx.autoInstanceName.value || 'Enter instance name'"
:placeholder="ctx.autoInstanceName.value || formatMessage(messages.instanceNamePlaceholder)"
/>
</div>
<!-- Loader chips -->
<div v-if="!hideLoaderChips" class="flex flex-col gap-2">
<span class="font-semibold text-contrast">{{
ctx.flowType === 'instance' ? 'Loader' : 'Content loader'
ctx.flowType === 'instance'
? formatMessage(messages.loaderLabel)
: formatMessage(messages.contentLoaderLabel)
}}</span>
<Chips
v-model="selectedLoader"
@@ -43,14 +45,21 @@
<!-- Game version -->
<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
v-model="selectedGameVersion"
:options="gameVersionOptions"
:no-options-message="
gameVersionsLoading
? formatMessage(commonMessages.loadingLabel)
: formatMessage(messages.noVersionsAvailable)
"
searchable
sync-with-selection
placeholder="Select game version"
search-placeholder="Search game version..."
:placeholder="formatMessage(messages.selectGameVersion)"
:search-placeholder="formatMessage(messages.searchGameVersion)"
@option-hover="handleGameVersionHover"
>
<template v-if="ctx.showSnapshotToggle" #dropdown-footer>
@@ -61,7 +70,11 @@
>
<EyeOffIcon v-if="ctx.showSnapshots.value" 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>
</template>
</Combobox>
@@ -72,24 +85,36 @@
<Collapsible :collapsed="!selectedLoader || !selectedGameVersion" overflow-visible>
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">{{
isPaperLike ? 'Build number' : 'Loader version'
isPaperLike
? formatMessage(messages.buildNumberLabel)
: formatMessage(messages.loaderVersionLabel)
}}</span>
<Chips
v-if="!isPaperLike"
v-model="loaderVersionType"
:items="loaderVersionTypeItems"
:format-label="capitalize"
:format-label="formatLoaderVersionTypeLabel"
/>
<div v-if="isPaperLike || loaderVersionType === 'other'">
<Combobox
v-model="selectedLoaderVersion"
:options="loaderVersionOptions"
:no-options-message="loaderVersionsLoading ? 'Loading...' : 'No versions available'"
:no-options-message="
loaderVersionsLoading
? formatMessage(commonMessages.loadingLabel)
: formatMessage(messages.noVersionsAvailable)
"
searchable
sync-with-selection
:placeholder="isPaperLike ? 'Select build number' : 'Select loader version'"
:placeholder="
isPaperLike
? formatMessage(messages.selectBuildNumber)
: formatMessage(messages.selectLoaderVersion)
"
: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. -->
@@ -123,6 +148,7 @@
<script setup lang="ts">
import type { Paper } from '@modrinth/api-client'
import { EyeIcon, EyeOffIcon, UploadIcon, XIcon } from '@modrinth/assets'
import { commonMessages, defineMessages, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref, watch } from 'vue'
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 PaperChannelBadge from '../../../base/PaperChannelBadge.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 { capitalize, formatLoaderLabel } from '../shared'
import { formatLoaderLabel } from '../shared'
const debug = useDebugLogger('CustomSetupStage')
const client = injectModrinthClient()
const ctx = injectCreationFlowContext()
const { formatMessage } = useVIntl()
const {
selectedLoader,
selectedGameVersion,
@@ -151,6 +178,92 @@ const {
hideLoaderVersion,
} = 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 server flows, vanilla is a separate option in the setup type stage, so exclude it here.
const effectiveLoaders = computed(() => {
@@ -205,23 +318,24 @@ function removeIcon() {
ctx.instanceIconPath.value = null
}
// Loader versions fetched from launcher-meta
interface LoaderVersionEntry {
id: string
stable: boolean
}
const loaderVersionsLoading = ref(false)
const loaderVersionsData = ref<LoaderVersionEntry[]>([])
const loaderVersionsCache = ref<Record<string, { id: string; loaders: LoaderVersionEntry[] }[]>>({})
// Paper/Purpur build caches
const paperVersions = ref<Record<string, Paper.Versions.v3.Build[]>>({})
const purpurVersions = ref<Record<string, string[]>>({})
// Paper/Purpur supported game version sets (for filtering the game version combobox)
const paperSupportedVersions = ref<Set<string> | null>(null)
const purpurSupportedVersions = ref<Set<string> | null>(null)
function toApiLoaderName(loader: string): string {
return loader === 'neoforge' ? 'neo' : loader
}
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
const gameVersionOptions = computed<ComboboxOption<string>[]>(() => {
@@ -231,33 +345,35 @@ const gameVersionOptions = computed<ComboboxOption<string>[]>(() => {
// For loaders with per-version data, only show game versions that have builds
if (selectedLoader.value && selectedLoader.value !== 'vanilla') {
if (selectedLoader.value === 'paper' && paperSupportedVersions.value) {
if (selectedLoader.value === 'paper') {
if (!ctx.paperSupportedVersions.value) return []
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 }))
}
if (selectedLoader.value === 'purpur' && purpurSupportedVersions.value) {
if (selectedLoader.value === 'purpur') {
if (!ctx.purpurSupportedVersions.value) return []
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 }))
}
let apiLoader = selectedLoader.value
if (apiLoader === 'neoforge') apiLoader = 'neo'
const apiLoader = toApiLoaderName(selectedLoader.value)
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}')
if (!hasPlaceholder) {
const supportedVersions = new Set(
manifest.filter((x) => x.loaders.length > 0).map((x) => x.id),
const hasPlaceholder = manifest.some((x) => x.id === '${modrinth.gameVersion}')
const supportedVersions = new Set(
manifest
.filter(
(x) => x.id !== '${modrinth.gameVersion}' && (hasPlaceholder || x.loaders.length > 0),
)
return versions
.filter((v) => supportedVersions.has(v.version))
.map((v) => ({ value: v.version, label: v.version }))
}
}
.map((x) => x.id),
)
return versions
.filter((v) => supportedVersions.has(v.version))
.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(
gameVersionOptions,
(options) => {
if (options.length === 0) return
if (options.length === 0) {
selectedGameVersion.value = null
return
}
if (!selectedGameVersion.value || !options.some((o) => o.value === selectedGameVersion.value)) {
selectedGameVersion.value = options[0].value
}
@@ -276,50 +395,20 @@ watch(
)
async function fetchLoaderManifest(loader: string) {
let apiLoader = loader
if (apiLoader === 'neoforge') apiLoader = 'neo'
const apiLoader = toApiLoaderName(loader)
debug(
'fetchLoaderManifest:',
loader,
'apiLoader:',
apiLoader,
'cached:',
!!loaderVersionsCache.value[apiLoader],
!!ctx.loaderVersionsCache.value[apiLoader],
)
if (loaderVersionsCache.value[apiLoader]) return
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] = []
}
await ctx.fetchLoaderMetadata(loader)
}
async function fetchPaperSupportedVersions() {
if (paperSupportedVersions.value) return
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()
}
async function fetchLoaderMetadata(loader?: string | null) {
await ctx.fetchLoaderMetadata(loader)
}
function paperBuildChannelTag(buildId: string): 'ALPHA' | 'BETA' | null {
@@ -363,10 +452,8 @@ function getLoaderVersionsForGameVersion(
loader: string,
gameVersion: string,
): LoaderVersionEntry[] {
let apiLoader = loader
if (apiLoader === 'neoforge') apiLoader = 'neo'
const manifest = loaderVersionsCache.value[apiLoader]
const apiLoader = toApiLoaderName(loader)
const manifest = ctx.loaderVersionsCache.value[apiLoader]
debug('getLoaderVersionsForGameVersion:', {
loader,
apiLoader,
@@ -379,6 +466,7 @@ function getLoaderVersionsForGameVersion(
// Some loaders (e.g. Fabric) list all versions under a placeholder entry
const placeholder = manifest.find((x) => x.id === '${modrinth.gameVersion}')
if (placeholder) {
if (!manifest.some((x) => x.id === gameVersion)) return []
debug(
'getLoaderVersionsForGameVersion: using placeholder, loaders:',
placeholder.loaders.length,
@@ -400,16 +488,7 @@ function getLoaderVersionsForGameVersion(
watch(
() => selectedLoader.value,
async (loader) => {
if (!loader || loader === 'vanilla') return
if (loader === 'paper') {
await fetchPaperSupportedVersions()
return
}
if (loader === 'purpur') {
await fetchPurpurSupportedVersions()
return
}
await fetchLoaderManifest(loader)
await fetchLoaderMetadata(loader)
},
{ immediate: true },
)

View File

@@ -4,18 +4,23 @@
v-if="ctx.flowType !== 'server-onboarding' && ctx.flowType !== 'reset-server'"
class="flex flex-col gap-2"
>
<span class="font-semibold text-contrast">World name</span>
<StyledInput v-model="worldName" placeholder="Enter world name" />
<span class="font-semibold text-contrast">{{ formatMessage(messages.worldNameLabel) }}</span>
<StyledInput
v-model="worldName"
:placeholder="formatMessage(messages.worldNamePlaceholder)"
/>
</div>
<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
v-model="selectedGameVersion"
:options="gameVersionOptions"
searchable
sync-with-selection
placeholder="Select game version"
:placeholder="formatMessage(messages.gameVersionPlaceholder)"
>
<template v-if="ctx.showSnapshotToggle" #dropdown-footer>
<button
@@ -25,37 +30,50 @@
>
<EyeOffIcon v-if="ctx.showSnapshots.value" 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>
</template>
</Combobox>
</div>
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">Gamemode</span>
<Chips v-model="gamemode" :items="gamemodeItems" :format-label="capitalize" />
<span class="font-semibold text-contrast">{{ formatMessage(messages.gamemodeLabel) }}</span>
<Chips v-model="gamemode" :items="gamemodeItems" :format-label="formatGamemodeLabel" />
</div>
<div v-if="gamemode !== 'hardcore'" class="flex flex-col gap-2">
<span class="font-semibold text-contrast">Difficulty</span>
<Chips v-model="difficulty" :items="difficultyItems" :format-label="capitalize" />
<span class="font-semibold text-contrast">{{ formatMessage(messages.difficultyLabel) }}</span>
<Chips v-model="difficulty" :items="difficultyItems" :format-label="formatDifficultyLabel" />
</div>
<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
v-model="worldTypeOption"
:options="worldTypeOptions"
placeholder="Select world type"
:placeholder="formatMessage(messages.worldTypePlaceholder)"
/>
</div>
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast"
>World seed <span class="text-secondary font-normal">(Optional)</span></span
>
<StyledInput v-model="worldSeed" placeholder="Enter world seed" />
<span class="text-sm text-secondary">Leave blank for a random seed.</span>
<span class="font-semibold text-contrast">
<IntlFormatted :message-id="messages.worldSeedLabelWithOptional">
<template #optional="{ children }">
<span class="text-secondary font-normal">
<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 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">
<template #title>
<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>
<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 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">
Controls whether villages, strongholds, and other structures generate in new chunks.
{{ formatMessage(messages.generateStructuresDescription) }}
</span>
</div>
<Toggle v-model="generateStructures" small class="shrink-0" />
</div>
<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
v-model="generatorSettingsMode"
:options="generatorSettingsOptions"
placeholder="Select generator settings"
:placeholder="formatMessage(messages.generatorSettingsPlaceholder)"
/>
<StyledInput
v-if="generatorSettingsMode === 'custom'"
v-model="generatorSettingsCustom"
multiline
:rows="4"
placeholder="Enter generator settings JSON"
:placeholder="formatMessage(messages.generatorSettingsJsonPlaceholder)"
input-class="font-mono"
/>
<span class="text-sm text-secondary">
Used for advanced world customization such as custom Superflat layers.
{{ formatMessage(messages.generatorSettingsDescription) }}
</span>
</div>
</div>
@@ -101,7 +125,7 @@
<InlineBackupCreator
v-if="ctx.flowType === 'reset-server'"
ref="backupCreator"
backup-name="Before reset server"
:backup-name="formatMessage(messages.beforeResetServerBackupName)"
hide-shift-click-hint
@update:buttons-disabled="ctx.isBackingUp.value = $event"
/>
@@ -110,6 +134,7 @@
<script setup lang="ts">
import { EyeIcon, EyeOffIcon, SettingsIcon } from '@modrinth/assets'
import { commonMessages, defineMessages, IntlFormatted, useVIntl } from '@modrinth/ui'
import { computed, ref, watch } from 'vue'
import { useDebugLogger } from '#ui/composables/debug-logger'
@@ -123,10 +148,146 @@ import StyledInput from '../../../base/StyledInput.vue'
import Toggle from '../../../base/Toggle.vue'
import type { Difficulty, Gamemode, GeneratorSettingsMode } from '../creation-flow-context'
import { injectCreationFlowContext } from '../creation-flow-context'
import { capitalize } from '../shared'
const debug = useDebugLogger('FinalConfigStage')
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)
watch(backupCreator, (creator) => {
@@ -189,17 +350,41 @@ watch(gamemode, (mode) => {
const gamemodeItems: Gamemode[] = ['survival', 'creative', 'hardcore']
const difficultyItems: Difficulty[] = ['peaceful', 'easy', 'normal', 'hard']
const worldTypeOptions: ComboboxOption<string>[] = [
{ value: 'minecraft:normal', label: 'Default' },
{ value: 'minecraft:flat', label: 'Superflat' },
{ value: 'minecraft:large_biomes', label: 'Large Biomes' },
{ value: 'minecraft:amplified', label: 'Amplified' },
{ value: 'minecraft:single_biome_surface', label: 'Single Biome' },
]
function formatGamemodeLabel(mode: Gamemode): string {
switch (mode) {
case 'survival':
return formatMessage(messages.gamemodeSurvival)
case 'creative':
return formatMessage(messages.gamemodeCreative)
case 'hardcore':
return formatMessage(messages.gamemodeHardcore)
}
}
const generatorSettingsOptions: ComboboxOption<GeneratorSettingsMode>[] = [
{ value: 'default', label: 'Default' },
{ value: 'flat', label: 'Flat' },
{ value: 'custom', label: 'Custom' },
]
function formatDifficultyLabel(value: Difficulty): string {
switch (value) {
case 'peaceful':
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>

View File

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

View File

@@ -1,12 +1,18 @@
<template>
<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
v-model="ctx.modpackSearchProjectId.value"
:options="ctx.modpackSearchOptions.value"
searchable
search-placeholder="Search for modpack"
:no-options-message="searchLoading ? 'Loading...' : 'No results found'"
:search-placeholder="formatMessage(messages.searchModpackPlaceholder)"
:no-options-message="
searchLoading
? formatMessage(commonMessages.loadingLabel)
: formatMessage(messages.noResultsFound)
"
:disable-search-filter="true"
@search-input="(query) => handleSearch(query)"
>
@@ -18,20 +24,20 @@
</Combobox>
<div class="flex items-center gap-3">
<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>
<div class="flex gap-3">
<ButtonStyled type="outlined">
<button class="flex-1 !border-surface-4" @click="triggerFileInput">
<ImportIcon />
Import modpack
{{ formatMessage(messages.importModpack) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button class="flex-1" @click="ctx.browseModpacks()">
<CompassIcon />
Browse modpacks
{{ formatMessage(messages.browseModpacks) }}
</button>
</ButtonStyled>
</div>
@@ -40,6 +46,7 @@
<script setup lang="ts">
import { CompassIcon, ImportIcon, RightArrowIcon } from '@modrinth/assets'
import { commonMessages, defineMessages, useVIntl } from '@modrinth/ui'
import { defineAsyncComponent, h, onMounted, ref, watch } from 'vue'
import { useDebugLogger } from '#ui/composables/debug-logger'
@@ -52,9 +59,33 @@ import { injectCreationFlowContext } from '../creation-flow-context'
const debug = useDebugLogger('ModpackStage')
const ctx = injectCreationFlowContext()
const filePicker = injectFilePicker()
const { formatMessage } = useVIntl()
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() {
debug('proceedWithModpack:', {
flowType: ctx.flowType,

View File

@@ -1,13 +1,7 @@
<template>
<div class="flex flex-col gap-4">
<span class="font-semibold text-contrast">
{{
ctx.flowType === 'instance'
? 'Choose instance type'
: ctx.flowType === 'server-onboarding' || ctx.flowType === 'reset-server'
? 'Select installation type'
: 'Select world type'
}}
{{ setupTypeTitle }}
</span>
<!-- Instance flow options -->
@@ -15,25 +9,25 @@
<div class="flex flex-col gap-3">
<BigOptionButton
:icon="BoxesIcon"
title="Custom setup"
description="Start from scratch by picking a loader and game version."
:title="formatMessage(messages.customSetupTitle)"
:description="formatMessage(messages.customSetupDescription)"
@click="setSetupType('custom')"
/>
<BigOptionButton
:icon="PackageIcon"
title="Modpack base"
description="Use a popular modpack as your starting point."
:title="formatMessage(messages.modpackBaseTitle)"
:description="formatMessage(messages.modpackBaseDescription)"
@click="setSetupType('modpack')"
/>
<BigOptionButton
:icon="BoxImportIcon"
title="Import instance"
description="Import an instance from Prism, CurseForge, or similar."
:title="formatMessage(messages.importInstanceTitle)"
:description="formatMessage(messages.importInstanceDescription)"
@click="ctx.setImportMode()"
/>
</div>
<span class="text-sm text-secondary">
An instance is a Minecraft setup with a specific loader, version, and mods.
{{ formatMessage(messages.instanceDescription) }}
</span>
</template>
@@ -42,20 +36,20 @@
<div class="flex flex-col gap-3">
<BigOptionButton
:icon="PackageIcon"
title="Modpack base"
description="Use a popular modpack as your starting point."
:title="formatMessage(messages.modpackBaseTitle)"
:description="formatMessage(messages.modpackBaseDescription)"
@click="setSetupType('modpack')"
/>
<BigOptionButton
:icon="BoxesIcon"
title="Custom setup"
description="Start from scratch by picking a loader and game version."
:title="formatMessage(messages.customSetupTitle)"
:description="formatMessage(messages.customSetupDescription)"
@click="setSetupType('custom')"
/>
<BigOptionButton
:icon="BoxIcon"
title="Vanilla Minecraft"
description="Classic Minecraft with no mods or plugins."
:title="formatMessage(messages.vanillaMinecraftTitle)"
:description="formatMessage(messages.vanillaMinecraftDescription)"
@click="setSetupType('vanilla')"
/>
</div>
@@ -65,6 +59,8 @@
<script setup lang="ts">
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'
@@ -74,6 +70,68 @@ import { injectCreationFlowContext } from '../creation-flow-context'
const debug = useDebugLogger('SetupTypeStage')
const ctx = injectCreationFlowContext()
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') {
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 type { ComponentExposed } from 'vue-component-type-helpers'
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 { createContext } from '../../../providers'
import { createContext, injectModrinthClient } from '../../../providers'
import type { ImportableLauncher } from '../../../providers/instance-import'
import type { MultiStageModal, StageConfigInput } from '../../base'
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 LoaderVersionType = 'stable' | 'latest' | 'other'
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 {
projectId: string
@@ -43,16 +118,10 @@ export interface ModpackSearchResult {
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 {
// Flow
flowType: FlowType
formatMessage: VIntlFormatters['formatMessage']
// Configuration
availableLoaders: string[]
@@ -91,6 +160,9 @@ export interface CreationFlowContextValue {
hideLoaderChips: ComputedRef<boolean>
hideLoaderVersion: ComputedRef<boolean>
showSnapshots: Ref<boolean>
loaderVersionsCache: Ref<Record<string, { id: string; loaders: LoaderVersionEntry[] }[]>>
paperSupportedVersions: Ref<Set<string> | null>
purpurSupportedVersions: Ref<Set<string> | null>
// Modpack state
modpackSelection: Ref<ModpackSelection | null>
@@ -133,10 +205,13 @@ export interface CreationFlowContextValue {
browseModpacks: () => void
finish: () => void
buildProperties: () => Archon.Content.v1.PropertiesFields
fetchLoaderMetadata: (loader?: string | null) => Promise<void>
prefetchLoaderMetadata: () => Promise<void>
// Platform-provided search
searchModpacks: (query: string, limit?: number) => Promise<ModpackSearchResult>
getProjectVersions: (projectId: string) => Promise<{ id: string }[]>
getLoaderManifest: LoaderManifestResolver | null
}
export const [injectCreationFlowContext, provideCreationFlowContext] =
@@ -156,6 +231,7 @@ export interface CreationFlowOptions {
onBack?: () => void
searchModpacks?: (query: string, limit?: number) => Promise<ModpackSearchResult>
getProjectVersions?: (projectId: string) => Promise<{ id: string }[]>
getLoaderManifest?: LoaderManifestResolver
}
export function createCreationFlowContext(
@@ -168,6 +244,9 @@ export function createCreationFlowContext(
options: CreationFlowOptions = {},
): CreationFlowContextValue {
const debug = useDebugLogger('CreationFlow')
const client = injectModrinthClient()
const queryClient = useQueryClient()
const { formatMessage } = useVIntl()
const availableLoaders = options.availableLoaders ?? ['fabric', 'neoforge', 'forge', 'quilt']
const showSnapshotToggle = options.showSnapshotToggle ?? false
const disableClose = options.disableClose ?? false
@@ -175,6 +254,9 @@ export function createCreationFlowContext(
const initialLoader = options.initialLoader ?? null
const initialGameVersion = options.initialGameVersion ?? 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 isImportMode = ref(false)
@@ -207,6 +289,11 @@ export function createCreationFlowContext(
const loaderVersionType = ref<LoaderVersionType>('stable')
const selectedLoaderVersion = ref<string | null>(null)
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 loader = selectedLoader.value
@@ -255,6 +342,83 @@ export function createCreationFlowContext(
() => 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() {
if (fetchExistingInstanceNames) {
existingInstanceNames.value = await fetchExistingInstanceNames()
@@ -370,15 +534,13 @@ export function createCreationFlowContext(
return { known }
}
const searchModpacks = options.searchModpacks!
const getProjectVersions = options.getProjectVersions!
const resolvedStageConfigs = disableClose
? stageConfigs.map((stage) => ({ ...stage, disableClose: true }))
: stageConfigs
const contextValue: CreationFlowContextValue = {
flowType,
formatMessage,
availableLoaders,
showSnapshotToggle,
disableClose,
@@ -407,6 +569,9 @@ export function createCreationFlowContext(
hideLoaderChips,
hideLoaderVersion,
showSnapshots,
loaderVersionsCache,
paperSupportedVersions,
purpurSupportedVersions,
modpackSelection,
modpackFile,
modpackFilePath,
@@ -431,8 +596,11 @@ export function createCreationFlowContext(
browseModpacks,
finish,
buildProperties,
fetchLoaderMetadata,
prefetchLoaderMetadata,
searchModpacks,
getProjectVersions,
getLoaderManifest,
}
return contextValue

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,11 +2,11 @@ import { markRaw } from 'vue'
import type { StageConfigInput } from '../../../base'
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> = {
id: 'setup-type',
title: (ctx) => flowTypeHeadings[ctx.flowType],
title: (ctx) => ctx.formatMessage(flowTypeHeadingMessages[ctx.flowType]),
stageContent: markRaw(SetupTypeStage),
leftButtonConfig: null,
rightButtonConfig: null,

View File

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

View File

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

View File

@@ -1,14 +1,18 @@
<template>
<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">
<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">
Your server is ready. Here's what you need to do to start playing!
{{ formatMessage(messages.welcomeDescription) }}
</p>
</div>
<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="flex flex-col">
@@ -41,11 +45,13 @@
<ButtonStyled v-if="uploading" size="large">
<button class="ml-auto" disabled>
<SpinnerIcon class="animate-spin" />
Uploading ({{ uploadPercent }}%)
{{ formatMessage(messages.uploadingProgress, { percent: uploadPercent }) }}
</button>
</ButtonStyled>
<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>
</div>
@@ -66,7 +72,13 @@
<script setup lang="ts">
import type { Archon } from '@modrinth/api-client'
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 { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
@@ -77,6 +89,73 @@ import { injectModrinthServerContext } from '#ui/providers'
const client = injectModrinthClient()
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) {
return client.labrinth.projects_v2.search({
@@ -209,8 +288,8 @@ const onCreate = async (config: CreationFlowContextValue) => {
await finalizeSetup()
} catch {
addNotification({
title: 'Modpack upload failed',
text: 'An unexpected error occurred while uploading. Please try again later.',
title: formatMessage(messages.modpackUploadFailedTitle),
text: formatMessage(messages.modpackUploadFailedText),
type: 'error',
})
config.loading.value = false
@@ -252,31 +331,29 @@ const onCreate = async (config: CreationFlowContextValue) => {
await finalizeSetup()
} catch {
addNotification({
title: 'Installation failed',
text: 'An unexpected error occurred while installing. Please try again later.',
title: formatMessage(messages.installationFailedTitle),
text: formatMessage(messages.installationFailedText),
type: 'error',
})
config.loading.value = false
}
}
const steps = [
const steps = computed(() => [
{
icon: PackageIcon,
title: 'Choose what to play',
description:
'Pick your favorite modpack from Modrinth, or choose a loader and add the mods you want.',
title: formatMessage(messages.chooseWhatToPlayTitle),
description: formatMessage(messages.chooseWhatToPlayDescription),
},
{
icon: GlobeIcon,
title: 'Configure your world',
description: 'Set up your world just like singleplayer. Choose your gamemode and world seed.',
title: formatMessage(messages.configureWorldTitle),
description: formatMessage(messages.configureWorldDescription),
},
{
icon: UsersIcon,
title: 'Invite your friends',
description:
"Share your server with friends by copying the address and letting them know which mods they'll need to join.",
title: formatMessage(messages.inviteFriendsTitle),
description: formatMessage(messages.inviteFriendsDescription),
},
]
])
</script>

View File

@@ -491,6 +491,276 @@
"content.selection-bar.selected-count-simple": {
"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": {
"defaultMessage": "Extract summary"
},
@@ -3062,6 +3332,51 @@
"servers.region.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": {
"defaultMessage": "You are being rate limited. Please try again later."
},