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:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
export { formatLoaderLabel, loaderDisplayNames } from '#ui/utils/loaders'
|
||||
|
||||
export const capitalize = (item: string) => item.charAt(0).toUpperCase() + item.slice(1)
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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,
|
||||
]
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
Reference in New Issue
Block a user