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

@@ -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."
},