feat: content tab rewrite for worlds (#5136)

* feat: base content card component

* fix: tooltips + colors

* feat: fix orgs

* feat: base content tab internals rewrite

* feat: fix invalidmodal

* feat: add ContentModpackCard

* fix: extract types

* draft: layout

* feat: unlink modal

* feat: impl content tab

* fix: lint

* fix: toggling

* temp: disable updating stuff

* feat: selection v-model

* feat: bulk selection

* feat: mods tab rough draft

* feat: use fuse.js

* feat: add project combobox

* clean up project combobox

* feat: start install to play modal

* fix: events

* feat: use v-on

* feat: bulk actions + fix floating action bar width

* feat: figma alignments

* feat: migrate toggle to tailwind

* fix: row borders

* feat: disabled state

* feat: virtual list impl for card table based on window scroll

* fix: lint

* feat: virtualization + smaller contentcard items

* feat: use ContentCardTable + ContentCardItems

* feat: fix gap + border issues on last elm

* feat: cleanup + use proper searching

* fix: use TeleportOverflowMenu

* fix: fallback to svg if src is invalid on avatar component

* fix: storybook

* feat: start on updater modal

* feat: finish content updater modal

* feat: i18n pass

* feat: impl modal

* feat(app): backend changes for content tab refactor (#5237)

* feat: include_changelog=false for updater modal

* fix: hash overrides

* feat: update checking for modpack

* feat: qa

* feat: modpack content modal

* fix: padding in table to match modals + tightness

* fix: lint

* feat: delete modal

* feat: fix toggle bugs

* fix: prepr

* fix: duplicate messages

* qa: full width search

* qa: use bg-surface-1.5

* qa: animation for filter pills

* qa: standardize hover colors

* fix: border-[1px] is border

* qa: mass de-select actually mass selecting

* qa: match figma designs for floating action bar

* qa: modal fixes

* q: modal fixes x2

* fix: table border

* qa: confirm modals

* qa: modal alignment

* qa: re-add stuck heading + dedupe logic

* qa: dedupe virtual scrolling + remove dead components

* qa: responsiveness for content table + link fixes

* qa: version column link, tooltips + lint fixes

* qa: instance busy protections

* fix: installation freeze bug

* chore: remove old mods page

* refactor: deduplicate layout

* chore: delete old content page(s)

* qa

* qa

* qa

* feat: sort btn - to iterate

* fix: ml

* feat: date added

* fix: lint

* fix: formatting.ts removal

* feat: get_dependencies_as_content_items

* qa: final QA changes

* refactor: deduplicate + polish content.rs

* feat: hook up content.vue with v1

* feat: hide v1 content api behind frontend feature flag

* fix: query keys + copy on empty state

* chore: i18n pass

* feat: reimpl unlink + upload endpoint

* feat: use bulk endpoints v1

* fix: lint

* fix: flags

* fix: responsiveness via container queries

* fix: lint

* qa: 1

* qa: fixes

* qa: fix ssr issues with browse content

* qa: header page divider

* qa: modals

* fix: prepr

* fix: issues

* fix: lint

* fix: toggle v1 ff

* qa: 5

* qa: delete modal copy

* feat: creation flow modals (#5383)

* refactor: delete content v0 usages + impl

* feat: qa + fixes

* feat: installing banner using state event

* feat: fix modpack card bugs + filtering issues

* refactor: delete backups v0 api module

* feat: v1 servers GET endpoint

* fix: backups

* feat: swap to kyros upload v1 addon

* fix: use tanstack for loader.vue

* feat: finish install from discovery modal

* qa: bug fixes

* feat: set up installation settings

* fix: lint

* fix: typos

* fix: bugs

* fix: disable inline content

* feat: content tab improvements — upload UX, installation settings, and client-only indicators

   Upload cancellation and navigation guard:
   - Add ConfirmLeaveModal that prompts when navigating away during upload
   - Cancel in-flight XHR uploads when user confirms leaving the page
   - Add beforeunload handler to warn on browser/tab close during upload
   - Track uploadedBytes/totalBytes in UploadState for progress display
   - Replace Collapsible with Transition for upload progress admonition
   - Show byte progress and percentage in upload banner
   - Clamp upload progress to prevent exceeding 100%

   Installation settings (server.properties):
   - Add KnownPropertiesFields and PropertiesFields types to Archon types
   - Add buildProperties() to creation flow context to collect gamemode,
     difficulty, seed, world type, structures, and generator settings
   - Pass properties through installContent on onboarding, discovery, and
     ServerSetupModal flows

   Server setup and discovery flow improvements:
   - Migrate ServerSetupModal from servers_v0.reinstall to content_v1.installContent
   - Replace loaderApiNames lookup with toApiLoader() helper
   - Remove eraseDataOnInstall toggle — always use soft_override: false
   - Simplify modpack install on discovery page to use first available version
     and route through creation flow modal for both onboarding and non-onboarding
   - Differentiate post-install navigation: content page for onboarding,
     loader options for existing servers

   Modpack update flow:
   - Replace updateModpack() call with installContent() using soft_override: true
     to support version selection in the content updater modal

   Client-only mod indicators:
   - Add environment field to AddonVersion (reuses Labrinth.Projects.v3.Environment)
   - Add environment to ContentItem and isClientOnly to ContentCardTableItem
   - Show orange TriangleAlertIcon with tooltip on client-only mods in content table
   - Add "Client-only" filter pill to content filtering (controlled via
     showClientOnlyFilter on ContentManagerContext)
   - Apply client-only indicators in both ContentPageLayout and ModpackContentModal

   Misc:
   - Add CLAUDE.md note about using prepr commands for lint checks
   - Export ConfirmLeaveModal from instances barrel

* fix: piping

* fix: switch content disable for linked server instances

* feat: client only filter

* fix: prepr

* feat: hasUpdate shape update

* feat: bulk update endpoint impl for content in panel

* feat: websocket state impl again with new phases

* fix: ws

* fix: use timeout fn for sync admon + fix content card layout scroll for browsers with overflow anchor bug

* fix: qa bugs

* fix: lint, a11y and i18n

* refactor: set up layouts folder properly

* fix: linked data cache stuff + lint

* feat: move installationsettings to shared layout

* fix: lint

* fix: issues

* feat: temp fuck staging up

* fix: lockfile

* fix: data sync issues on loader.vue

* fix: lint

* Hide shader configuration files from content list (#5499)

* feat: workaround search problem + split out reset

* fix: qa

* fix: changelog not showing on first open

* fix: qa + optimistic updating improvements

* fix: prepr+lint

* fix: qa

* feat: qa

* fix: lint

* fix: lint

* fix: build

* fix: build

* fix: type errors

* fix: fade and JAVA_HOME passthrough

* feat: qa

* feat: impl diff shit

* fix: qa

* fix: app qa

* feat: update diff modal

* fix: endpoint

* fix: qa

* fix: qa

* fix: use bulk in modpack modal

* feat: abort signal impl + fix issues

* fix: diff modal trunc

* feat: qa

* fix: qa

* feat: tooltip content tab

* fix: prepr

* fix: dismiss on settings btn

* feat: qa

* feat: dont clear handlers on disconnect

* fix: lint

* fix: wrangler + introduce staging-archon env file

---------

Signed-off-by: Calum H. <calum@modrinth.com>
Co-authored-by: tdgao <mr.trumgao@gmail.com>
Co-authored-by: Artyom Ezri <61311568+Artezon@users.noreply.github.com>
This commit is contained in:
Calum H.
2026-03-12 20:24:32 +00:00
committed by GitHub
parent f0224dfff7
commit 7d92e4ec7f
302 changed files with 20016 additions and 12142 deletions

View File

@@ -0,0 +1,422 @@
<template>
<div class="space-y-6">
<!-- Instance-specific: Icon upload -->
<div v-if="ctx.flowType === 'instance'" class="flex items-center gap-4">
<Avatar :src="ctx.instanceIconUrl.value ?? undefined" size="5rem" />
<div class="flex flex-col gap-2">
<ButtonStyled type="outlined">
<button class="!border-surface-5" @click="triggerIconInput">
<UploadIcon />
Select icon
</button>
</ButtonStyled>
<ButtonStyled type="outlined">
<button class="!border-surface-5" :disabled="!ctx.instanceIcon.value" @click="removeIcon">
<XIcon />
Remove icon
</button>
</ButtonStyled>
</div>
</div>
<!-- Instance-specific: Name field -->
<div v-if="ctx.flowType === 'instance'" class="flex flex-col gap-2">
<span class="font-semibold text-contrast">Name</span>
<StyledInput v-model="ctx.instanceName.value" placeholder="Enter instance name" />
</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'
}}</span>
<Chips
v-model="selectedLoader"
:items="effectiveLoaders"
:format-label="formatLoaderLabel"
:never-empty="false"
/>
</div>
<!-- Game version -->
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">Game version</span>
<Combobox
v-model="selectedGameVersion"
:options="gameVersionOptions"
searchable
sync-with-selection
placeholder="Select game version"
search-placeholder="Search game version..."
>
<template v-if="ctx.showSnapshotToggle" #dropdown-footer>
<button
class="flex w-full cursor-pointer items-center justify-center gap-1.5 border-0 border-t border-solid border-surface-5 bg-transparent py-3 text-center text-sm font-semibold text-secondary transition-colors hover:text-contrast"
@mousedown.prevent
@click="ctx.showSnapshots.value = !ctx.showSnapshots.value"
>
<EyeOffIcon v-if="ctx.showSnapshots.value" class="size-4" />
<EyeIcon v-else class="size-4" />
{{ ctx.showSnapshots.value ? 'Hide snapshots' : 'Show all versions' }}
</button>
</template>
</Combobox>
</div>
<!-- Loader version -->
<template v-if="!hideLoaderVersion">
<Collapsible :collapsed="!selectedLoader || !selectedGameVersion" overflow-visible>
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">{{
isPaperLike ? 'Build number' : 'Loader version'
}}</span>
<Chips
v-if="!isPaperLike"
v-model="loaderVersionType"
:items="loaderVersionTypeItems"
:format-label="capitalize"
/>
<div v-if="isPaperLike || loaderVersionType === 'other'">
<Combobox
v-model="selectedLoaderVersion"
:options="loaderVersionOptions"
:no-options-message="loaderVersionsLoading ? 'Loading...' : 'No versions available'"
searchable
sync-with-selection
:placeholder="isPaperLike ? 'Select build number' : 'Select loader version'"
:search-placeholder="
isPaperLike ? 'Search build number...' : 'Search loader version...'
"
/>
</div>
</div>
</Collapsible>
</template>
</div>
</template>
<script setup lang="ts">
import { EyeIcon, EyeOffIcon, UploadIcon, XIcon } from '@modrinth/assets'
import { computed, onMounted, ref, watch } from 'vue'
import { useDebugLogger } from '#ui/composables/debug-logger'
import { injectFilePicker, injectTags } from '../../../../providers'
import Avatar from '../../../base/Avatar.vue'
import ButtonStyled from '../../../base/ButtonStyled.vue'
import Chips from '../../../base/Chips.vue'
import Collapsible from '../../../base/Collapsible.vue'
import Combobox, { type ComboboxOption } from '../../../base/Combobox.vue'
import StyledInput from '../../../base/StyledInput.vue'
import type { LoaderVersionType } from '../creation-flow-context'
import { injectCreationFlowContext } from '../creation-flow-context'
import { capitalize, formatLoaderLabel } from '../shared'
const debug = useDebugLogger('CustomSetupStage')
const ctx = injectCreationFlowContext()
const {
selectedLoader,
selectedGameVersion,
loaderVersionType,
selectedLoaderVersion,
hideLoaderChips,
hideLoaderVersion,
} = ctx
// 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(() => {
if (ctx.flowType === 'instance') {
return ['vanilla', ...ctx.availableLoaders.filter((l) => l !== 'vanilla')]
}
if (ctx.flowType === 'server-onboarding' || ctx.flowType === 'reset-server') {
return ctx.availableLoaders.filter((l) => l !== 'vanilla')
}
return ctx.availableLoaders
})
// Pre-select loader and game version from initial values
onMounted(() => {
debug('mounted, initialLoader:', ctx.initialLoader, 'initialGameVersion:', ctx.initialGameVersion)
if (!selectedLoader.value) {
if (ctx.initialLoader) {
selectedLoader.value = ctx.initialLoader
} else {
selectedLoader.value = 'fabric'
}
}
if (ctx.initialGameVersion && !selectedGameVersion.value) {
selectedGameVersion.value = ctx.initialGameVersion
}
debug('after init:', { loader: selectedLoader.value, gameVersion: selectedGameVersion.value })
})
const tags = injectTags()
const loaderVersionTypeItems: LoaderVersionType[] = ['stable', 'latest', 'other']
const isPaperLike = computed(
() => selectedLoader.value === 'paper' || selectedLoader.value === 'purpur',
)
// Icon upload handling
const filePicker = injectFilePicker()
async function triggerIconInput() {
const picked = await filePicker.pickImage()
if (picked) {
ctx.instanceIcon.value = picked.file
ctx.instanceIconUrl.value = picked.previewUrl
ctx.instanceIconPath.value = picked.path ?? null
}
}
function removeIcon() {
ctx.instanceIcon.value = null
ctx.instanceIconUrl.value = null
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, number[]>>({})
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)
// Game versions from tags provider, filtered by loader support
const gameVersionOptions = computed<ComboboxOption<string>[]>(() => {
const versions = ctx.showSnapshots.value
? tags.gameVersions.value
: tags.gameVersions.value.filter((v) => v.version_type === 'release')
// 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) {
return versions
.filter((v) => paperSupportedVersions.value!.has(v.version))
.map((v) => ({ value: v.version, label: v.version }))
}
if (selectedLoader.value === 'purpur' && purpurSupportedVersions.value) {
return versions
.filter((v) => purpurSupportedVersions.value!.has(v.version))
.map((v) => ({ value: v.version, label: v.version }))
}
let apiLoader = selectedLoader.value
if (apiLoader === 'neoforge') apiLoader = 'neo'
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),
)
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 }))
})
// Auto-select latest game version when options change and current selection is missing or invalid
watch(
gameVersionOptions,
(options) => {
if (options.length === 0) return
if (!selectedGameVersion.value || !options.some((o) => o.value === selectedGameVersion.value)) {
selectedGameVersion.value = options[0].value
}
},
{ immediate: true },
)
async function fetchLoaderManifest(loader: string) {
let apiLoader = loader
if (apiLoader === 'neoforge') apiLoader = 'neo'
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
} catch {
loaderVersionsCache.value[apiLoader] = []
}
}
async function fetchPaperSupportedVersions() {
if (paperSupportedVersions.value) return
try {
const res = await fetch('https://api.papermc.io/v2/projects/paper')
const data = (await res.json()) as { versions: string[] }
paperSupportedVersions.value = new Set(data.versions)
} catch {
paperSupportedVersions.value = new Set()
}
}
async function fetchPurpurSupportedVersions() {
if (purpurSupportedVersions.value) return
try {
const res = await fetch('https://api.purpurmc.org/v2/purpur')
const data = (await res.json()) as { versions: string[] }
purpurSupportedVersions.value = new Set(data.versions)
} catch {
purpurSupportedVersions.value = new Set()
}
}
async function fetchPaperVersions(mcVersion: string) {
if (paperVersions.value[mcVersion]) return
try {
const res = await fetch(`https://fill.papermc.io/v3/projects/paper/versions/${mcVersion}`)
const data = (await res.json()) as { builds: number[] }
paperVersions.value[mcVersion] = data.builds.sort((a, b) => b - a)
} catch {
paperVersions.value[mcVersion] = []
}
}
async function fetchPurpurVersions(mcVersion: string) {
if (purpurVersions.value[mcVersion]) return
try {
const res = await fetch(`https://api.purpurmc.org/v2/purpur/${mcVersion}`)
const data = (await res.json()) as { builds: { all: string[] } }
purpurVersions.value[mcVersion] = data.builds.all.sort((a, b) => parseInt(b) - parseInt(a))
} catch {
purpurVersions.value[mcVersion] = []
}
}
function getLoaderVersionsForGameVersion(
loader: string,
gameVersion: string,
): LoaderVersionEntry[] {
let apiLoader = loader
if (apiLoader === 'neoforge') apiLoader = 'neo'
const manifest = loaderVersionsCache.value[apiLoader]
if (!manifest) return []
// Some loaders (e.g. Fabric) list all versions under a placeholder entry
const placeholder = manifest.find((x) => x.id === '${modrinth.gameVersion}')
if (placeholder) return placeholder.loaders
const entry = manifest.find((x) => x.id === gameVersion)
return entry?.loaders ?? []
}
// Fetch version data when loader changes so game versions can be filtered
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)
},
{ immediate: true },
)
// Watch loader + game version to resolve loader versions
watch(
[() => selectedLoader.value, () => selectedGameVersion.value],
async ([loader, gameVersion]) => {
loaderVersionsData.value = []
selectedLoaderVersion.value = null
if (!loader || !gameVersion || loader === 'vanilla') return
loaderVersionsLoading.value = true
if (loader === 'paper') {
await fetchPaperVersions(gameVersion)
loaderVersionsLoading.value = false
// Auto-select latest build
const builds = paperVersions.value[gameVersion]
if (builds?.length) {
selectedLoaderVersion.value = `${builds[0]}`
}
return
}
if (loader === 'purpur') {
await fetchPurpurVersions(gameVersion)
loaderVersionsLoading.value = false
// Auto-select latest build
const builds = purpurVersions.value[gameVersion]
if (builds?.length) {
selectedLoaderVersion.value = builds[0]
}
return
}
await fetchLoaderManifest(loader)
loaderVersionsData.value = getLoaderVersionsForGameVersion(loader, gameVersion)
loaderVersionsLoading.value = false
// Auto-select based on loaderVersionType
autoSelectLoaderVersion()
},
)
watch(
() => loaderVersionType.value,
() => autoSelectLoaderVersion(),
)
function autoSelectLoaderVersion() {
if (loaderVersionType.value === 'stable') {
const stable = loaderVersionsData.value.find((v) => v.stable)
selectedLoaderVersion.value = stable?.id ?? loaderVersionsData.value[0]?.id ?? null
} else if (loaderVersionType.value === 'latest') {
selectedLoaderVersion.value = loaderVersionsData.value[0]?.id ?? null
} else if (loaderVersionType.value === 'other' && !selectedLoaderVersion.value) {
selectedLoaderVersion.value = loaderVersionsData.value[0]?.id ?? null
}
debug('autoSelectLoaderVersion:', selectedLoaderVersion.value, 'type:', loaderVersionType.value)
}
const loaderVersionOptions = computed<ComboboxOption<string>[]>(() => {
if (selectedLoader.value === 'paper' && selectedGameVersion.value) {
const builds = paperVersions.value[selectedGameVersion.value] ?? []
return builds.map((b) => ({ value: `${b}`, label: `Build ${b}` }))
}
if (selectedLoader.value === 'purpur' && selectedGameVersion.value) {
const builds = purpurVersions.value[selectedGameVersion.value] ?? []
return builds.map((b) => ({ value: b, label: `Build ${b}` }))
}
return loaderVersionsData.value.map((v) => ({
value: v.id,
label: v.stable ? `${v.id} (stable)` : v.id,
}))
})
</script>

View File

@@ -0,0 +1,191 @@
<template>
<div class="space-y-6">
<div
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" />
</div>
<div v-if="ctx.setupType.value === 'vanilla'" class="flex flex-col gap-2">
<span class="font-semibold text-contrast">Game version</span>
<Combobox
v-model="selectedGameVersion"
:options="gameVersionOptions"
searchable
sync-with-selection
placeholder="Select game version"
>
<template v-if="ctx.showSnapshotToggle" #dropdown-footer>
<button
class="flex w-full cursor-pointer items-center justify-center gap-1.5 border-0 border-t border-solid border-surface-5 bg-transparent py-3 text-center text-sm font-semibold text-secondary transition-colors hover:text-contrast"
@mousedown.prevent
@click="ctx.showSnapshots.value = !ctx.showSnapshots.value"
>
<EyeOffIcon v-if="ctx.showSnapshots.value" class="size-4" />
<EyeIcon v-else class="size-4" />
{{ ctx.showSnapshots.value ? 'Hide snapshots' : 'Show all versions' }}
</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" />
</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" />
</div>
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">World type</span>
<Combobox
v-model="worldTypeOption"
:options="worldTypeOptions"
placeholder="Select world type"
/>
</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>
</div>
<div class="h-px w-full bg-surface-5" />
<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>
</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="text-sm text-secondary">
Controls whether villages, strongholds, and other structures generate in new chunks.
</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>
<Combobox
v-model="generatorSettingsMode"
:options="generatorSettingsOptions"
placeholder="Select generator settings"
/>
<StyledInput
v-if="generatorSettingsMode === 'custom'"
v-model="generatorSettingsCustom"
multiline
:rows="4"
placeholder="Enter generator settings JSON"
input-class="font-mono"
/>
<span class="text-sm text-secondary">
Used for advanced world customization such as custom Superflat layers.
</span>
</div>
</div>
</Accordion>
</div>
</template>
<script setup lang="ts">
import { EyeIcon, EyeOffIcon, SettingsIcon } from '@modrinth/assets'
import { computed, watch } from 'vue'
import { useDebugLogger } from '#ui/composables/debug-logger'
import { injectTags } from '../../../../providers'
import Accordion from '../../../base/Accordion.vue'
import Chips from '../../../base/Chips.vue'
import Combobox, { type ComboboxOption } from '../../../base/Combobox.vue'
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 {
worldName,
gamemode,
difficulty,
worldTypeOption,
worldSeed,
generateStructures,
generatorSettingsMode,
generatorSettingsCustom,
selectedGameVersion,
} = ctx
debug(
'mounted, setupType:',
ctx.setupType.value,
'loader:',
ctx.selectedLoader.value,
'gameVersion:',
ctx.selectedGameVersion.value,
'loaderVersion:',
ctx.selectedLoaderVersion.value,
)
// Game version options for vanilla flow
const tags = injectTags()
const gameVersionOptions = computed<ComboboxOption<string>[]>(() => {
const versions = ctx.showSnapshots.value
? tags.gameVersions.value
: tags.gameVersions.value.filter((v) => v.version_type === 'release')
return versions.map((v) => ({ value: v.version, label: v.version }))
})
// Auto-select latest game version for vanilla
watch(
gameVersionOptions,
(options) => {
if (!selectedGameVersion.value && options.length > 0) {
selectedGameVersion.value = options[0].value
}
},
{ immediate: true },
)
// Hardcore locks difficulty to hard
let previousDifficulty: Difficulty = difficulty.value
watch(gamemode, (mode) => {
if (mode === 'hardcore') {
previousDifficulty = difficulty.value
difficulty.value = 'hard'
} else {
difficulty.value = previousDifficulty
}
})
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' },
]
const generatorSettingsOptions: ComboboxOption<GeneratorSettingsMode>[] = [
{ value: 'default', label: 'Default' },
{ value: 'flat', label: 'Flat' },
{ value: 'custom', label: 'Custom' },
]
</script>

View File

@@ -0,0 +1,281 @@
<template>
<div class="flex flex-col gap-2">
<!-- Header -->
<div class="flex items-center justify-between">
<span class="font-semibold text-contrast">Launcher instances</span>
<ButtonStyled
type="transparent"
size="small"
:class="{ invisible: totalSelectedCount === 0 }"
>
<button @click="clearAll">Clear all</button>
</ButtonStyled>
</div>
<template v-if="loading">
<div class="flex items-center justify-center py-8 text-secondary text-sm">
Detecting launcher instances...
</div>
</template>
<template v-else>
<!-- Search -->
<StyledInput
v-if="ctx.importLaunchers.value.length > 0"
v-model="ctx.importSearchQuery.value"
:icon="SearchIcon"
placeholder="Search instance name"
/>
<!-- Launcher sections -->
<div v-if="ctx.importLaunchers.value.length > 0" class="flex flex-col gap-2">
<div
v-for="launcher in visibleLaunchers"
:key="launcher.name"
class="flex flex-col rounded-[20px] border border-solid border-surface-4 shadow-sm overflow-clip"
>
<!-- Launcher header -->
<button
class="flex w-full cursor-pointer items-center gap-3 border-none bg-surface-3 p-3 text-left transition-colors"
@click="toggleLauncherExpanded(launcher.name)"
>
<ChevronRightIcon
class="size-5 shrink-0 text-secondary transition-transform"
:class="{ 'rotate-90': expandedLaunchers.has(launcher.name) }"
/>
<Checkbox
:model-value="getLauncherCheckState(launcher)"
:indeterminate="getLauncherIndeterminate(launcher)"
@update:model-value="toggleLauncherAll(launcher, $event)"
@click.stop
/>
<span class="font-semibold text-contrast">{{ launcher.name }}</span>
</button>
<!-- Instance list (expanded) -->
<Collapsible :collapsed="!expandedLaunchers.has(launcher.name)">
<div class="flex flex-col">
<template v-for="(instance, i) in filteredInstances(launcher)" :key="instance">
<div
class="flex items-center gap-3 border-0 border-t border-solid border-surface-4 py-3 pr-3"
:class="i % 2 === 0 ? 'bg-surface-2' : 'bg-surface-1.5'"
style="padding-left: 2.75rem"
>
<Checkbox
:model-value="isInstanceSelected(launcher.name, instance)"
@update:model-value="toggleInstance(launcher.name, instance, $event)"
/>
<span class="text-sm">{{ instance }}</span>
</div>
</template>
</div>
</Collapsible>
</div>
</div>
<!-- Add launcher path -->
<div v-if="!showAddPath">
<ButtonStyled>
<button class="w-full !shadow-none" @click="showAddPath = true">Add launcher path</button>
</ButtonStyled>
</div>
<div v-else class="flex items-center gap-2">
<ButtonStyled icon-only
><button class="!shadow-none" @click="browseForLauncherPath">
<FolderSearchIcon class="size-5" /></button
></ButtonStyled>
<StyledInput v-model="newLauncherPath" placeholder="Path to launcher..." class="flex-1" />
<ButtonStyled>
<button class="!shadow-none" :disabled="!newLauncherPath.trim()" @click="addLauncherPath">
Add
</button>
</ButtonStyled>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ChevronRightIcon, FolderSearchIcon, SearchIcon } from '@modrinth/assets'
import { computed, onMounted, ref, watch } from 'vue'
import { injectInstanceImport } from '../../../../providers'
import type { ImportableLauncher } from '../../../../providers/instance-import'
import ButtonStyled from '../../../base/ButtonStyled.vue'
import Checkbox from '../../../base/Checkbox.vue'
import Collapsible from '../../../base/Collapsible.vue'
import StyledInput from '../../../base/StyledInput.vue'
import { injectCreationFlowContext } from '../creation-flow-context'
const ctx = injectCreationFlowContext()
const importProvider = injectInstanceImport()
const loading = ref(false)
const expandedLaunchers = ref(new Set<string>())
const expandedBeforeSearch = ref<Set<string> | null>(null)
const showAddPath = ref(false)
const newLauncherPath = ref('')
// Load detected launchers on mount
onMounted(async () => {
if (ctx.importLaunchers.value.length > 0) return // Already loaded
loading.value = true
try {
ctx.importLaunchers.value = await importProvider.getDetectedLaunchers()
// Auto-expand launchers that have instances
for (const launcher of ctx.importLaunchers.value) {
if (launcher.instances.length > 0) {
expandedLaunchers.value.add(launcher.name)
}
}
} catch {
ctx.importLaunchers.value = []
}
loading.value = false
})
// Filter instances by search query
function filteredInstances(launcher: ImportableLauncher): string[] {
const query = ctx.importSearchQuery.value.toLowerCase().trim()
if (!query) return launcher.instances
return launcher.instances.filter((name) => name.toLowerCase().includes(query))
}
// Hide launchers with no matching instances when searching
const visibleLaunchers = computed(() => {
const query = ctx.importSearchQuery.value.toLowerCase().trim()
if (!query) return ctx.importLaunchers.value
return ctx.importLaunchers.value.filter((launcher) => filteredInstances(launcher).length > 0)
})
// Auto-expand launchers with matching results when searching
watch(
() => ctx.importSearchQuery.value,
(query) => {
const trimmed = query.trim()
if (trimmed) {
// Save current state before search overrides it
if (!expandedBeforeSearch.value) {
expandedBeforeSearch.value = new Set(expandedLaunchers.value)
}
// Expand all launchers that have matching instances
const newExpanded = new Set(expandedLaunchers.value)
for (const launcher of ctx.importLaunchers.value) {
if (filteredInstances(launcher).length > 0) {
newExpanded.add(launcher.name)
}
}
expandedLaunchers.value = newExpanded
} else if (expandedBeforeSearch.value) {
// Restore pre-search state
expandedLaunchers.value = expandedBeforeSearch.value
expandedBeforeSearch.value = null
}
},
)
// Selection helpers
function isInstanceSelected(launcherName: string, instance: string): boolean {
return ctx.importSelectedInstances.value[launcherName]?.has(instance) ?? false
}
function toggleInstance(launcherName: string, instance: string, selected: boolean) {
if (!ctx.importSelectedInstances.value[launcherName]) {
ctx.importSelectedInstances.value[launcherName] = new Set()
}
if (selected) {
ctx.importSelectedInstances.value[launcherName].add(instance)
} else {
ctx.importSelectedInstances.value[launcherName].delete(instance)
}
// Trigger reactivity
ctx.importSelectedInstances.value = { ...ctx.importSelectedInstances.value }
}
function getLauncherCheckState(launcher: ImportableLauncher): boolean {
const set = ctx.importSelectedInstances.value[launcher.name]
if (!set || set.size === 0) return false
const visible = filteredInstances(launcher)
return visible.length > 0 && visible.every((i) => set.has(i))
}
function getLauncherIndeterminate(launcher: ImportableLauncher): boolean {
const set = ctx.importSelectedInstances.value[launcher.name]
if (!set || set.size === 0) return false
const visible = filteredInstances(launcher)
const selectedVisible = visible.filter((i) => set.has(i))
return selectedVisible.length > 0 && selectedVisible.length < visible.length
}
function toggleLauncherAll(launcher: ImportableLauncher, selected: boolean) {
if (!ctx.importSelectedInstances.value[launcher.name]) {
ctx.importSelectedInstances.value[launcher.name] = new Set()
}
const visible = filteredInstances(launcher)
for (const instance of visible) {
if (selected) {
ctx.importSelectedInstances.value[launcher.name].add(instance)
} else {
ctx.importSelectedInstances.value[launcher.name].delete(instance)
}
}
// Trigger reactivity
ctx.importSelectedInstances.value = { ...ctx.importSelectedInstances.value }
}
function toggleLauncherExpanded(name: string) {
if (expandedLaunchers.value.has(name)) {
expandedLaunchers.value.delete(name)
} else {
expandedLaunchers.value.add(name)
}
expandedLaunchers.value = new Set(expandedLaunchers.value)
}
const totalSelectedCount = computed(() => {
let count = 0
for (const set of Object.values(ctx.importSelectedInstances.value)) {
count += set.size
}
return count
})
function clearAll() {
ctx.importSelectedInstances.value = {}
}
async function browseForLauncherPath() {
const path = await importProvider.selectDirectory()
if (path) {
newLauncherPath.value = path
}
}
async function addLauncherPath() {
const path = newLauncherPath.value.trim()
if (!path) return
try {
const instances = await importProvider.getImportableInstances('Custom', path)
const launcher: ImportableLauncher = {
name: `Custom (${path.split(/[\\/]/).pop() || path})`,
path,
instances,
}
ctx.importLaunchers.value = [...ctx.importLaunchers.value, launcher]
expandedLaunchers.value.add(launcher.name)
expandedLaunchers.value = new Set(expandedLaunchers.value)
} catch {
// Failed to load — still add with empty instances
const launcher: ImportableLauncher = {
name: `Custom (${path.split(/[\\/]/).pop() || path})`,
path,
instances: [],
}
ctx.importLaunchers.value = [...ctx.importLaunchers.value, launcher]
}
newLauncherPath.value = ''
showAddPath.value = false
}
</script>

View File

@@ -0,0 +1,174 @@
<template>
<div class="flex flex-col gap-4">
<span class="font-semibold text-contrast">Already know the modpack you want to install?</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'"
:disable-search-filter="true"
@search-input="(query) => handleSearch(query)"
>
<template #option-suffix>
<RightArrowIcon
class="size-5 shrink-0 text-secondary opacity-0 transition-opacity group-hover/option:opacity-100 group-data-[focused=true]/option:opacity-100"
/>
</template>
</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>
<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
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button class="flex-1" @click="ctx.browseModpacks()">
<CompassIcon />
Browse modpacks
</button>
</ButtonStyled>
</div>
</div>
</template>
<script setup lang="ts">
import { CompassIcon, ImportIcon, RightArrowIcon } from '@modrinth/assets'
import { defineAsyncComponent, h, onMounted, ref, watch } from 'vue'
import { useDebugLogger } from '#ui/composables/debug-logger'
import { injectFilePicker } from '../../../../providers'
import ButtonStyled from '../../../base/ButtonStyled.vue'
import Combobox from '../../../base/Combobox.vue'
import { injectCreationFlowContext } from '../creation-flow-context'
const debug = useDebugLogger('ModpackStage')
const ctx = injectCreationFlowContext()
const filePicker = injectFilePicker()
const searchLoading = ref(false)
function proceedWithModpack() {
debug('proceedWithModpack:', {
flowType: ctx.flowType,
modpackSelection: ctx.modpackSelection.value,
})
if (ctx.flowType === 'instance') {
ctx.finish()
} else {
ctx.modal.value?.setStage('final-config')
}
}
const search = async (query: string) => {
query = query.trim()
debug('search() called:', { query, trimmed: query })
try {
debug('search() calling API...', {
query: query || undefined,
facets: [['project_type:modpack']],
limit: 10,
})
const results = await ctx.searchModpacks(query, 10)
debug('search() API returned:', {
totalHits: results.total_hits,
hitCount: results.hits.length,
firstHit: results.hits[0]?.title,
})
ctx.modpackSearchHits.value = {}
for (const hit of results.hits) {
ctx.modpackSearchHits.value[hit.project_id] = {
title: hit.title,
iconUrl: hit.icon_url,
latestVersion: hit.latest_version,
}
}
ctx.modpackSearchOptions.value = results.hits.map((hit) => ({
label: hit.title,
value: hit.project_id,
icon: defineAsyncComponent(() =>
Promise.resolve({
setup: () => () =>
h('img', {
src: hit.icon_url,
alt: hit.title,
class: 'h-5 w-5 rounded',
}),
}),
),
}))
debug('search() options set:', {
optionCount: ctx.modpackSearchOptions.value.length,
labels: ctx.modpackSearchOptions.value.map((o) => o.label),
})
} catch (err) {
debug('search() ERROR:', err)
ctx.modpackSearchOptions.value = []
}
searchLoading.value = false
debug('search() done, searchLoading:', searchLoading.value)
}
const handleSearch = async (query: string) => {
debug('handleSearch() called:', { query })
searchLoading.value = true
await search(query)
}
onMounted(() => {
debug('onMounted() firing, resetting and calling search("")')
ctx.modpackSearchProjectId.value = undefined
search('')
})
// When a project is selected via search, fetch its latest version and auto-proceed
watch(
() => ctx.modpackSearchProjectId.value,
async (projectId, oldProjectId) => {
if (projectId === oldProjectId) return
ctx.modpackSearchVersionId.value = undefined
ctx.modpackVersionOptions.value = []
if (!projectId) return
const hit = ctx.modpackSearchHits.value[projectId]
// Always fetch the actual latest version from the API since search index can be stale
try {
const versions = await ctx.getProjectVersions(projectId)
if (versions.length > 0) {
const version = versions[0]
ctx.modpackSelection.value = {
projectId,
versionId: version.id,
name: hit?.title ?? '',
iconUrl: hit?.iconUrl,
}
proceedWithModpack()
}
} catch {
// Failed to fetch versions — do nothing
}
},
)
async function triggerFileInput() {
const picked = await filePicker.pickModpackFile()
if (picked) {
ctx.modpackFile.value = picked.file
ctx.modpackFilePath.value = picked.path ?? null
proceedWithModpack()
}
}
</script>

View File

@@ -0,0 +1,84 @@
<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'
}}
</span>
<!-- Instance flow options -->
<template v-if="ctx.flowType === 'instance'">
<div class="flex flex-col gap-3">
<BigOptionButton
:icon="BoxesIcon"
title="Custom setup"
description="Start from scratch by picking a loader and game version."
@click="setSetupType('custom')"
/>
<BigOptionButton
:icon="PackageIcon"
title="Modpack base"
description="Use a popular modpack as your starting point."
@click="setSetupType('modpack')"
/>
<BigOptionButton
:icon="BoxImportIcon"
title="Import instance"
description="Import an instance from Prism, CurseForge, or similar."
@click="ctx.setImportMode()"
/>
</div>
<span class="text-sm text-secondary">
An instance is a Minecraft setup with a specific loader, version, and mods.
</span>
</template>
<!-- World / Server onboarding flow options -->
<template v-else>
<div class="flex flex-col gap-3">
<BigOptionButton
:icon="PackageIcon"
title="Modpack base"
description="Use a popular modpack as your starting point."
@click="setSetupType('modpack')"
/>
<BigOptionButton
:icon="BoxesIcon"
title="Custom setup"
description="Start from scratch by picking a loader and game version."
@click="setSetupType('custom')"
/>
<BigOptionButton
:icon="BoxIcon"
title="Vanilla Minecraft"
description="Classic Minecraft with no mods or plugins."
@click="setSetupType('vanilla')"
/>
</div>
<InlineBackupCreator v-if="ctx.flowType === 'reset-server'" backup-name="Before reinstall" />
</template>
</div>
</template>
<script setup lang="ts">
import { BoxesIcon, BoxIcon, BoxImportIcon, PackageIcon } from '@modrinth/assets'
import { useDebugLogger } from '#ui/composables/debug-logger'
import InlineBackupCreator from '../../../../layouts/shared/content-tab/components/modals/InlineBackupCreator.vue'
import BigOptionButton from '../../../base/BigOptionButton.vue'
import { injectCreationFlowContext } from '../creation-flow-context'
const debug = useDebugLogger('SetupTypeStage')
const ctx = injectCreationFlowContext()
const { setSetupType: _setSetupType } = ctx
function setSetupType(type: 'modpack' | 'custom' | 'vanilla') {
debug('selected:', type)
_setSetupType(type)
}
</script>

View File

@@ -0,0 +1,396 @@
import type { Archon } from '@modrinth/api-client'
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 { createContext } from '../../../providers'
import type { ImportableLauncher } from '../../../providers/instance-import'
import type { MultiStageModal, StageConfigInput } from '../../base'
import type { ComboboxOption } from '../../base/Combobox.vue'
import { stageConfigs } from './stages'
export type FlowType = 'world' | 'server-onboarding' | 'reset-server' | 'instance'
export type SetupType = 'modpack' | 'custom' | 'vanilla'
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 interface ModpackSelection {
projectId: string
versionId: string
name: string
iconUrl?: string
}
export interface ModpackSearchHit {
title: string
iconUrl?: string
latestVersion?: string
}
export interface ModpackSearchResult {
hits: {
project_id: string
title: string
icon_url: string
latest_version?: string
}[]
total_hits: number
offset: number
limit: number
}
export const flowTypeHeadings: Record<FlowType, string> = {
world: 'Create world',
'server-onboarding': 'Set up server',
'reset-server': 'Reset server',
instance: 'Create instance',
}
export interface CreationFlowContextValue {
// Flow
flowType: FlowType
// Configuration
availableLoaders: string[]
showSnapshotToggle: boolean
disableClose: boolean
isInitialSetup: boolean
// Initial values
initialLoader: string | null
initialGameVersion: string | null
// State
setupType: Ref<SetupType | null>
isImportMode: Ref<boolean>
worldName: Ref<string>
gamemode: Ref<Gamemode>
difficulty: Ref<Difficulty>
worldSeed: Ref<string>
worldTypeOption: Ref<string>
generateStructures: Ref<boolean>
generatorSettingsMode: Ref<GeneratorSettingsMode>
generatorSettingsCustom: Ref<string>
// Instance-specific state
instanceName: Ref<string>
instanceIcon: Ref<File | null>
instanceIconUrl: Ref<string | null>
instanceIconPath: Ref<string | null>
// Loader/version state (custom setup)
selectedLoader: Ref<string | null>
selectedGameVersion: Ref<string | null>
loaderVersionType: Ref<LoaderVersionType>
selectedLoaderVersion: Ref<string | null>
hideLoaderChips: ComputedRef<boolean>
hideLoaderVersion: ComputedRef<boolean>
showSnapshots: Ref<boolean>
// Modpack state
modpackSelection: Ref<ModpackSelection | null>
modpackFile: Ref<File | null>
modpackFilePath: Ref<string | null>
// Modpack search state (persisted across stage navigation)
modpackSearchProjectId: Ref<string | undefined>
modpackSearchVersionId: Ref<string | undefined>
modpackSearchOptions: Ref<ComboboxOption<string>[]>
modpackVersionOptions: Ref<ComboboxOption<string>[]>
modpackSearchHits: Ref<Record<string, ModpackSearchHit>>
// Import state (instance flow only)
importLaunchers: Ref<ImportableLauncher[]>
importSelectedInstances: Ref<Record<string, Set<string>>>
importSearchQuery: Ref<string>
// Confirm stage
hardReset: Ref<boolean>
// Loading state (set when finish() is called, cleared on reset)
loading: Ref<boolean>
// Modal
modal: ShallowRef<ComponentExposed<typeof MultiStageModal> | null>
stageConfigs: StageConfigInput<CreationFlowContextValue>[]
// Callbacks
onBack: (() => void) | null
// Methods
reset: (instanceCount?: number) => void
setSetupType: (type: SetupType) => void
setImportMode: () => void
browseModpacks: () => void
finish: () => void
buildProperties: () => Archon.Content.v1.PropertiesFields
// Platform-provided search
searchModpacks: (query: string, limit?: number) => Promise<ModpackSearchResult>
getProjectVersions: (projectId: string) => Promise<{ id: string }[]>
}
export const [injectCreationFlowContext, provideCreationFlowContext] =
createContext<CreationFlowContextValue>('CreationFlowModal')
// TODO: replace with actual world count from the world list once available
let worldCounter = 0
let instanceCounter = 0
export interface CreationFlowOptions {
availableLoaders?: string[]
showSnapshotToggle?: boolean
disableClose?: boolean
isInitialSetup?: boolean
initialLoader?: string
initialGameVersion?: string
onBack?: () => void
searchModpacks?: (query: string, limit?: number) => Promise<ModpackSearchResult>
getProjectVersions?: (projectId: string) => Promise<{ id: string }[]>
}
export function createCreationFlowContext(
modal: ShallowRef<ComponentExposed<typeof MultiStageModal> | null>,
flowType: FlowType,
emit: {
browseModpacks: () => void
create: (config: CreationFlowContextValue) => void
},
options: CreationFlowOptions = {},
): CreationFlowContextValue {
const debug = useDebugLogger('CreationFlow')
const availableLoaders = options.availableLoaders ?? ['fabric', 'neoforge', 'forge', 'quilt']
const showSnapshotToggle = options.showSnapshotToggle ?? false
const disableClose = options.disableClose ?? false
const isInitialSetup = options.isInitialSetup ?? false
const initialLoader = options.initialLoader ?? null
const initialGameVersion = options.initialGameVersion ?? null
const onBack = options.onBack ?? null
const setupType = ref<SetupType | null>(null)
const isImportMode = ref(false)
const worldName = ref('')
const gamemode = ref<Gamemode>('survival')
const difficulty = ref<Difficulty>('normal')
const worldSeed = ref('')
const worldTypeOption = ref('minecraft:normal')
const generateStructures = ref(true)
const generatorSettingsMode = ref<GeneratorSettingsMode>('default')
const generatorSettingsCustom = ref('')
// Instance-specific state
const instanceName = ref('')
const instanceIcon = ref<File | null>(null)
const instanceIconUrl = ref<string | null>(null)
const instanceIconPath = ref<string | null>(null)
// Revoke old object URL when icon is cleared to avoid memory leaks
watch(instanceIconUrl, (_newUrl, oldUrl) => {
if (oldUrl && oldUrl.startsWith('blob:')) {
URL.revokeObjectURL(oldUrl)
}
})
const selectedLoader = ref<string | null>(null)
const selectedGameVersion = ref<string | null>(null)
const loaderVersionType = ref<LoaderVersionType>('stable')
const selectedLoaderVersion = ref<string | null>(null)
const showSnapshots = ref(false)
const modpackSelection = ref<ModpackSelection | null>(null)
const modpackFile = ref<File | null>(null)
const modpackFilePath = ref<string | null>(null)
// Modpack search state (persisted across stage navigation)
const modpackSearchProjectId = ref<string | undefined>()
const modpackSearchVersionId = ref<string | undefined>()
const modpackSearchOptions = ref<ComboboxOption<string>[]>([])
const modpackVersionOptions = ref<ComboboxOption<string>[]>([])
const modpackSearchHits = ref<Record<string, ModpackSearchHit>>({})
// Import state (instance flow only)
const importLaunchers = ref<ImportableLauncher[]>([])
const importSelectedInstances = ref<Record<string, Set<string>>>({})
const importSearchQuery = ref('')
const hardReset = ref(isInitialSetup)
const loading = ref(false)
// hideLoaderChips: hides the entire loader chips section (only for vanilla world type in world/server flows)
const hideLoaderChips = computed(() => setupType.value === 'vanilla')
// hideLoaderVersion: hides the loader version section (vanilla world type OR vanilla selected as loader chip)
const hideLoaderVersion = computed(
() => setupType.value === 'vanilla' || selectedLoader.value === 'vanilla',
)
function reset(instanceCount?: number) {
setupType.value = null
isImportMode.value = false
worldCounter++
worldName.value = flowType === 'world' ? `World ${worldCounter}` : ''
if (instanceCount != null) {
instanceCounter = instanceCount
}
instanceCounter++
gamemode.value = 'survival'
difficulty.value = 'normal'
worldSeed.value = ''
worldTypeOption.value = 'minecraft:normal'
generateStructures.value = true
generatorSettingsMode.value = 'default'
generatorSettingsCustom.value = ''
// Instance-specific
instanceName.value = flowType === 'instance' ? `New instance (${instanceCounter})` : ''
instanceIconUrl.value = null
instanceIcon.value = null
instanceIconPath.value = null
selectedLoader.value = null
selectedGameVersion.value = null
loaderVersionType.value = 'stable'
selectedLoaderVersion.value = null
showSnapshots.value = false
modpackSelection.value = null
modpackFile.value = null
modpackFilePath.value = null
modpackSearchProjectId.value = undefined
modpackSearchVersionId.value = undefined
modpackSearchOptions.value = []
modpackVersionOptions.value = []
modpackSearchHits.value = {}
// Import state
importLaunchers.value = []
importSelectedInstances.value = {}
importSearchQuery.value = ''
hardReset.value = isInitialSetup
loading.value = false
}
function setSetupType(type: SetupType) {
debug('setSetupType:', type)
isImportMode.value = false
setupType.value = type
if (type === 'modpack') {
modal.value?.setStage('modpack')
} else {
// both custom and vanilla go to custom-setup
// vanilla just hides loader chips via hideLoaderChips computed
modal.value?.setStage('custom-setup')
}
}
function setImportMode() {
isImportMode.value = true
setupType.value = null
modal.value?.setStage('import-instance')
}
function browseModpacks() {
modal.value?.hide()
emit.browseModpacks()
}
function finish() {
debug('finish() called, state:', {
setupType: setupType.value,
selectedLoader: selectedLoader.value,
selectedGameVersion: selectedGameVersion.value,
selectedLoaderVersion: selectedLoaderVersion.value,
modpackSelection: modpackSelection.value,
hasModpackFile: !!modpackFile.value,
})
loading.value = true
emit.create(contextValue)
}
function buildProperties(): Archon.Content.v1.PropertiesFields {
const isHardcore = gamemode.value === 'hardcore'
const known: Archon.Content.v1.KnownPropertiesFields = {
gamemode: isHardcore ? 'survival' : gamemode.value,
hardcore: isHardcore ? 'true' : 'false',
difficulty: difficulty.value,
level_seed: worldSeed.value || null,
level_type: worldTypeOption.value,
generate_structures: String(generateStructures.value),
}
if (generatorSettingsMode.value === 'flat') {
known.generator_settings = ''
} else if (generatorSettingsMode.value === 'custom' && generatorSettingsCustom.value) {
known.generator_settings = generatorSettingsCustom.value
}
return { known }
}
const searchModpacks = options.searchModpacks!
const getProjectVersions = options.getProjectVersions!
const resolvedStageConfigs = disableClose
? stageConfigs.map((stage) => ({ ...stage, disableClose: true }))
: stageConfigs
const contextValue: CreationFlowContextValue = {
flowType,
availableLoaders,
showSnapshotToggle,
disableClose,
isInitialSetup,
initialLoader,
initialGameVersion,
setupType,
isImportMode,
worldName,
gamemode,
difficulty,
worldSeed,
worldTypeOption,
generateStructures,
generatorSettingsMode,
generatorSettingsCustom,
instanceName,
instanceIcon,
instanceIconUrl,
instanceIconPath,
selectedLoader,
selectedGameVersion,
loaderVersionType,
selectedLoaderVersion,
hideLoaderChips,
hideLoaderVersion,
showSnapshots,
modpackSelection,
modpackFile,
modpackFilePath,
modpackSearchProjectId,
modpackSearchVersionId,
modpackSearchOptions,
modpackVersionOptions,
modpackSearchHits,
importLaunchers,
importSelectedInstances,
importSearchQuery,
hardReset,
loading,
modal,
stageConfigs: resolvedStageConfigs,
onBack,
reset,
setSetupType,
setImportMode,
browseModpacks,
finish,
buildProperties,
searchModpacks,
getProjectVersions,
}
return contextValue
}

View File

@@ -0,0 +1,90 @@
<template>
<MultiStageModal
ref="modal"
:stages="ctx.stageConfigs"
:context="ctx"
:fade="fade"
disable-progress
@hide="$emit('hide')"
/>
</template>
<script setup lang="ts">
import { useTemplateRef } from 'vue'
import type { ComponentExposed } from 'vue-component-type-helpers'
import MultiStageModal from '../../base/MultiStageModal.vue'
import {
createCreationFlowContext,
type CreationFlowContextValue,
type FlowType,
type ModpackSearchResult,
provideCreationFlowContext,
} from './creation-flow-context'
const props = withDefaults(
defineProps<{
type?: FlowType
availableLoaders?: string[]
showSnapshotToggle?: boolean
disableClose?: boolean
isInitialSetup?: boolean
initialLoader?: string
initialGameVersion?: string
onBack?: (() => void) | null
fade?: 'standard' | 'warning' | 'danger'
searchModpacks?: (query: string, limit?: number) => Promise<ModpackSearchResult>
getProjectVersions?: (projectId: string) => Promise<{ id: string }[]>
}>(),
{
type: 'world',
availableLoaders: () => ['fabric', 'neoforge', 'forge', 'quilt'],
showSnapshotToggle: false,
disableClose: false,
isInitialSetup: false,
initialLoader: undefined,
initialGameVersion: undefined,
onBack: null,
},
)
const emit = defineEmits<{
(e: 'hide' | 'browse-modpacks'): void
(e: 'create', config: CreationFlowContextValue): void
}>()
const modal = useTemplateRef<ComponentExposed<typeof MultiStageModal>>('modal')
const ctx = createCreationFlowContext(
modal,
props.type,
{
browseModpacks: () => emit('browse-modpacks'),
create: (config) => emit('create', config),
},
{
availableLoaders: props.availableLoaders,
showSnapshotToggle: props.showSnapshotToggle,
disableClose: props.disableClose,
isInitialSetup: props.isInitialSetup,
initialLoader: props.initialLoader,
initialGameVersion: props.initialGameVersion,
onBack: props.onBack ?? undefined,
searchModpacks: props.searchModpacks,
getProjectVersions: props.getProjectVersions,
},
)
provideCreationFlowContext(ctx)
function show(instanceCount?: number) {
ctx.reset(instanceCount)
modal.value?.setStage(0)
modal.value?.show()
}
function hide() {
modal.value?.hide()
}
defineExpose({ show, hide, ctx })
</script>

View File

@@ -0,0 +1,3 @@
export { formatLoaderLabel, loaderDisplayNames } from '#ui/utils/loaders'
export const capitalize = (item: string) => item.charAt(0).toUpperCase() + item.slice(1)

View File

@@ -0,0 +1,71 @@
import { LeftArrowIcon, PlusIcon, RightArrowIcon } from '@modrinth/assets'
import { markRaw } from 'vue'
import type { StageConfigInput } from '../../../base'
import CustomSetupStage from '../components/CustomSetupStage.vue'
import { type CreationFlowContextValue, flowTypeHeadings } from '../creation-flow-context'
function isForwardBlocked(ctx: CreationFlowContextValue): boolean {
if (ctx.flowType === 'instance' && !ctx.instanceName.value?.trim()) return true
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
return false
}
export const stageConfig: StageConfigInput<CreationFlowContextValue> = {
id: 'custom-setup',
title: (ctx) => flowTypeHeadings[ctx.flowType],
stageContent: markRaw(CustomSetupStage),
skip: (ctx) =>
ctx.setupType.value === 'modpack' ||
ctx.setupType.value === 'vanilla' ||
ctx.isImportMode.value,
cannotNavigateForward: isForwardBlocked,
leftButtonConfig: (ctx) => ({
label: 'Back',
icon: LeftArrowIcon,
onClick: () => ctx.modal.value?.setStage('setup-type'),
}),
rightButtonConfig: (ctx) => {
const isInstance = ctx.flowType === 'instance'
const goesToNextStage =
ctx.flowType === 'world' ||
ctx.flowType === 'server-onboarding' ||
ctx.flowType === 'reset-server'
const disabled = isForwardBlocked(ctx)
if (isInstance) {
return {
label: 'Create instance',
icon: PlusIcon,
iconPosition: 'before' as const,
color: 'brand' as const,
disabled,
loading: ctx.loading.value,
onClick: () => ctx.finish(),
}
}
return {
label: goesToNextStage ? 'Continue' : 'Finish',
icon: goesToNextStage ? RightArrowIcon : null,
iconPosition: 'after' as const,
color: goesToNextStage ? undefined : ('brand' as const),
disabled,
onClick: () => {
if (goesToNextStage) {
ctx.modal.value?.nextStage()
} else {
ctx.finish()
}
},
}
},
maxWidth: '520px',
}

View File

@@ -0,0 +1,59 @@
import { LeftArrowIcon, PlusIcon, RightArrowIcon } from '@modrinth/assets'
import { markRaw } from 'vue'
import type { StageConfigInput } from '../../../base'
import FinalConfigStage from '../components/FinalConfigStage.vue'
import { type CreationFlowContextValue, flowTypeHeadings } from '../creation-flow-context'
function isForwardBlocked(ctx: CreationFlowContextValue): boolean {
if (ctx.flowType === 'world' && !ctx.worldName.value.trim()) return true
if (ctx.setupType.value === 'vanilla' && !ctx.selectedGameVersion.value) return true
return false
}
export const stageConfig: StageConfigInput<CreationFlowContextValue> = {
id: 'final-config',
title: (ctx) => flowTypeHeadings[ctx.flowType],
stageContent: markRaw(FinalConfigStage),
skip: (ctx) => ctx.flowType === 'instance' || ctx.isImportMode.value,
cannotNavigateForward: isForwardBlocked,
leftButtonConfig: (ctx) => ({
label: 'Back',
icon: LeftArrowIcon,
onClick: () => {
if (ctx.onBack) {
ctx.onBack()
} else {
ctx.modal.value?.prevStage()
}
},
}),
rightButtonConfig: (ctx) => {
const isWorld = ctx.flowType === 'world'
const isOnboarding = ctx.flowType === 'server-onboarding'
const isReset = ctx.flowType === 'reset-server'
const isFinish = isWorld || isOnboarding || isReset
return {
label: isWorld
? 'Create world'
: isReset
? 'Reset server'
: isOnboarding
? 'Setup server'
: 'Continue',
icon: isFinish ? PlusIcon : RightArrowIcon,
iconPosition: isFinish ? ('before' as const) : ('after' as const),
color: isReset ? ('red' as const) : isFinish ? ('brand' as const) : undefined,
disabled: isForwardBlocked(ctx),
loading: isFinish && ctx.loading.value,
onClick: () => {
if (isFinish) {
ctx.finish()
} else {
ctx.modal.value?.nextStage()
}
},
}
},
maxWidth: '520px',
}

View File

@@ -0,0 +1,41 @@
import { DownloadIcon, LeftArrowIcon } from '@modrinth/assets'
import { markRaw } from 'vue'
import type { StageConfigInput } from '../../../base'
import ImportInstanceStage from '../components/ImportInstanceStage.vue'
import type { CreationFlowContextValue } from '../creation-flow-context'
function getSelectedCount(ctx: CreationFlowContextValue): number {
let count = 0
for (const set of Object.values(ctx.importSelectedInstances.value)) {
count += set.size
}
return count
}
export const stageConfig: StageConfigInput<CreationFlowContextValue> = {
id: 'import-instance',
title: 'Import instance',
stageContent: markRaw(ImportInstanceStage),
skip: (ctx) => !ctx.isImportMode.value,
leftButtonConfig: (ctx) => ({
label: 'Back',
icon: LeftArrowIcon,
onClick: () => {
ctx.isImportMode.value = false
ctx.modal.value?.setStage('setup-type')
},
}),
rightButtonConfig: (ctx) => {
const count = getSelectedCount(ctx)
return {
label: count > 0 ? `Import ${count} instance${count !== 1 ? 's' : ''}` : 'Import',
icon: DownloadIcon,
iconPosition: 'before' as const,
color: 'brand' as const,
disabled: count === 0,
onClick: () => ctx.finish(),
}
},
maxWidth: '520px',
}

View File

@@ -0,0 +1,15 @@
import type { StageConfigInput } from '../../../base'
import type { CreationFlowContextValue } from '../creation-flow-context'
import { stageConfig as customSetupStageConfig } from './custom-setup-stage'
import { stageConfig as finalConfigStageConfig } from './final-config-stage'
import { stageConfig as importInstanceStageConfig } from './import-instance-stage'
import { stageConfig as modpackStageConfig } from './modpack-stage'
import { stageConfig as setupTypeStageConfig } from './setup-type-stage'
export const stageConfigs: StageConfigInput<CreationFlowContextValue>[] = [
setupTypeStageConfig,
modpackStageConfig,
importInstanceStageConfig,
customSetupStageConfig,
finalConfigStageConfig,
]

View File

@@ -0,0 +1,20 @@
import { LeftArrowIcon } from '@modrinth/assets'
import { markRaw } from 'vue'
import type { StageConfigInput } from '../../../base'
import ModpackStage from '../components/ModpackStage.vue'
import type { CreationFlowContextValue } from '../creation-flow-context'
export const stageConfig: StageConfigInput<CreationFlowContextValue> = {
id: 'modpack',
title: 'Choose modpack',
stageContent: markRaw(ModpackStage),
skip: (ctx) => ctx.setupType.value !== 'modpack' || ctx.isImportMode.value,
leftButtonConfig: (ctx) => ({
label: 'Back',
icon: LeftArrowIcon,
onClick: () => ctx.modal.value?.setStage('setup-type'),
}),
rightButtonConfig: null,
maxWidth: '520px',
}

View File

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