Files
pages/apps/frontend/src/components/ui/servers/PlatformVersionSelectModal.vue
Cal H. bcfa6941e4 fix: Teleport Dropdown/OverflowMenu imports (#4211)
* fix: Teleport Dropdown/OverflowMenu imports

* fix: lint
2025-08-18 13:50:58 +00:00

542 lines
14 KiB
Vue

<template>
<NewModal
ref="versionSelectModal"
:header="
isSecondPhase
? 'Confirming reinstallation'
: `${props.currentLoader === selectedLoader ? 'Reinstalling' : 'Installing'}
${selectedLoader.toLowerCase() === 'vanilla' ? 'Vanilla Minecraft' : selectedLoader}`
"
@hide="onHide"
@show="onShow"
>
<div class="flex flex-col gap-4 md:w-[600px]">
<p
v-if="isSecondPhase"
:style="{
lineHeight: isSecondPhase ? '1.5' : undefined,
marginBottom: isSecondPhase ? '-12px' : '0',
marginTop: isSecondPhase ? '-4px' : '-2px',
}"
>
{{
'This will reinstall your server and erase all data. Are you sure you want to continue?'
}}
</p>
<div v-if="!isSecondPhase" class="flex flex-col gap-4">
<div class="mx-auto flex flex-row items-center gap-4">
<div
class="grid size-16 place-content-center rounded-2xl border-[2px] border-solid border-button-border bg-button-bg shadow-sm"
>
<LoaderIcon class="size-10" :loader="selectedLoader" />
</div>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="size-10"
>
<path d="M5 9v6" />
<path d="M9 9h3V5l7 7-7 7v-4H9V9z" />
</svg>
<div
class="grid size-16 place-content-center rounded-2xl border-[2px] border-solid border-button-border bg-table-alternateRow shadow-sm"
>
<ServerIcon class="size-10" />
</div>
</div>
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
<div class="text-lg font-bold text-contrast">Minecraft version</div>
<TeleportDropdownMenu
v-model="selectedMCVersion"
name="mcVersion"
:options="mcVersions"
class="w-full max-w-[100%]"
placeholder="Select Minecraft version..."
/>
<div class="mt-2 flex items-center justify-between gap-2">
<label for="toggle-snapshots" class="font-semibold"> Show snapshot versions </label>
<div
v-tooltip="
isSnapshotSelected ? 'A snapshot version is currently selected.' : undefined
"
>
<Toggle
id="toggle-snapshots"
v-model="showSnapshots"
:disabled="isSnapshotSelected"
/>
</div>
</div>
</div>
<div
v-if="selectedLoader.toLowerCase() !== 'vanilla'"
class="flex w-full flex-col gap-2 rounded-2xl p-4"
:class="{
'bg-table-alternateRow':
!selectedMCVersion || isLoading || selectedLoaderVersions.length > 0,
'bg-highlight-red':
selectedMCVersion && !isLoading && selectedLoaderVersions.length === 0,
}"
>
<div class="flex flex-col gap-2">
<div class="text-lg font-bold text-contrast">{{ selectedLoader }} version</div>
<template v-if="!selectedMCVersion">
<div
class="relative flex h-9 w-full select-none items-center rounded-xl bg-button-bg px-4 opacity-50"
>
Select a Minecraft version to see available versions
<DropdownIcon class="absolute right-4" />
</div>
</template>
<template v-else-if="isLoading">
<div
class="relative flex h-9 w-full items-center rounded-xl bg-button-bg px-4 opacity-50"
>
<LoadingIcon class="mr-2 animate-spin" />
Loading versions...
<DropdownIcon class="absolute right-4" />
</div>
</template>
<template v-else-if="selectedLoaderVersions.length > 0">
<TeleportDropdownMenu
v-model="selectedLoaderVersion"
name="loaderVersion"
:options="selectedLoaderVersions"
class="w-full max-w-[100%]"
:placeholder="
selectedLoader.toLowerCase() === 'paper' ||
selectedLoader.toLowerCase() === 'purpur'
? `Select build number...`
: `Select loader version...`
"
/>
</template>
<template v-else>
<div>No versions available for Minecraft {{ selectedMCVersion }}.</div>
</template>
</div>
</div>
<div
v-if="!initialSetup"
class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4"
>
<div class="flex w-full flex-row items-center justify-between">
<label class="w-full text-lg font-bold text-contrast" for="hard-reset">
Erase all data
</label>
<input
id="hard-reset"
v-model="hardReset"
class="switch stylized-toggle shrink-0"
type="checkbox"
/>
</div>
<div>
Removes all data on your server, including your worlds, mods, and configuration files,
then reinstalls it with the selected version.
</div>
<div class="font-bold">This does not affect your backups, which are stored off-site.</div>
</div>
<BackupWarning
v-if="!initialSetup"
:backup-link="`/servers/manage/${props.server?.serverId}/backups`"
/>
</div>
<div class="mt-4 flex justify-start gap-4">
<ButtonStyled :color="isDangerous ? 'red' : 'brand'">
<button
v-tooltip="backupInProgress ? formatMessage(backupInProgress.tooltip) : undefined"
:disabled="canInstall || !!backupInProgress"
@click="handleReinstall"
>
<RightArrowIcon />
{{
isLoading
? 'Installing...'
: isSecondPhase
? 'Erase and install'
: hardReset
? 'Continue'
: 'Install'
}}
</button>
</ButtonStyled>
<ButtonStyled>
<button
:disabled="isLoading"
@click="
() => {
if (isSecondPhase) {
isSecondPhase = false
} else {
hide()
}
}
"
>
<XIcon />
{{ isSecondPhase ? 'Go back' : 'Cancel' }}
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
</template>
<script setup lang="ts">
import { DropdownIcon, RightArrowIcon, ServerIcon, XIcon } from '@modrinth/assets'
import {
BackupWarning,
ButtonStyled,
injectNotificationManager,
NewModal,
TeleportDropdownMenu,
Toggle,
} from '@modrinth/ui'
import { type Loaders, ModrinthServersFetchError } from '@modrinth/utils'
import { $fetch } from 'ofetch'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
import type { BackupInProgressReason } from '~/pages/servers/manage/[id].vue'
import LoaderIcon from './icons/LoaderIcon.vue'
import LoadingIcon from './icons/LoadingIcon.vue'
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
interface LoaderVersion {
id: string
stable: boolean
loaders: {
id: string
url: string
stable: boolean
}[]
}
type VersionMap = Record<string, LoaderVersion[]>
type VersionCache = Record<string, any>
const props = defineProps<{
server: ModrinthServer
currentLoader: Loaders | undefined
backupInProgress?: BackupInProgressReason
initialSetup?: boolean
}>()
const emit = defineEmits<{
reinstall: [any?]
}>()
const versionSelectModal = ref()
const isSecondPhase = ref(false)
const hardReset = ref(false)
const isLoading = ref(false)
const loadingServerCheck = ref(false)
const serverCheckError = ref('')
const showSnapshots = ref(false)
const selectedLoader = ref<Loaders>('Vanilla')
const selectedMCVersion = ref('')
const selectedLoaderVersion = ref('')
const paperVersions = ref<Record<string, number[]>>({})
const purpurVersions = ref<Record<string, string[]>>({})
const loaderVersions = ref<VersionMap>({})
const cachedVersions = ref<VersionCache>({})
const versionStrings = ['forge', 'fabric', 'quilt', 'neo'] as const
const isSnapshotSelected = computed(() => {
if (selectedMCVersion.value) {
const selected = tags.value.gameVersions.find((x) => x.version === selectedMCVersion.value)
if (selected?.version_type !== 'release') {
return true
}
}
return false
})
const getLoaderVersions = async (loader: string) => {
return await $fetch(
`https://launcher-meta.modrinth.com/${loader?.toLowerCase()}/v0/manifest.json`,
)
}
const fetchLoaderVersions = async () => {
const versions = await Promise.all(
versionStrings.map(async (loader) => {
const runFetch = async (iterations: number) => {
if (iterations > 5) {
throw new Error('Failed to fetch loader versions')
}
try {
const res = await getLoaderVersions(loader)
return { [loader]: (res as any).gameVersions }
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (_) {
return await runFetch(iterations + 1)
}
}
try {
return await runFetch(0)
} catch (e) {
console.error(e)
return { [loader]: [] }
}
}),
)
loaderVersions.value = versions.reduce((acc, val) => ({ ...acc, ...val }), {})
}
const fetchPaperVersions = async (mcVersion: string) => {
try {
const res = await $fetch(`https://api.papermc.io/v2/projects/paper/versions/${mcVersion}`)
paperVersions.value[mcVersion] = (res as any).builds.sort((a: number, b: number) => b - a)
return res
} catch (e) {
console.error(e)
return null
}
}
const fetchPurpurVersions = async (mcVersion: string) => {
try {
const res = await $fetch(`https://api.purpurmc.org/v2/purpur/${mcVersion}`)
purpurVersions.value[mcVersion] = (res as any).builds.all.sort(
(a: string, b: string) => parseInt(b) - parseInt(a),
)
return res
} catch (e) {
console.error(e)
return null
}
}
const selectedLoaderVersions = computed<string[]>(() => {
const loader = selectedLoader.value.toLowerCase()
if (loader === 'paper') {
return paperVersions.value[selectedMCVersion.value]?.map((x) => `${x}`) || []
}
if (loader === 'purpur') {
return purpurVersions.value[selectedMCVersion.value] || []
}
if (loader === 'vanilla') {
return []
}
let apiLoader = loader
if (loader === 'neoforge') {
apiLoader = 'neo'
}
const backwardsCompatibleVersion = loaderVersions.value[apiLoader]?.find(
(x) => x.id === '${modrinth.gameVersion}',
)
if (backwardsCompatibleVersion) {
return backwardsCompatibleVersion.loaders.map((x) => x.id)
}
return (
loaderVersions.value[apiLoader]
?.find((x) => x.id === selectedMCVersion.value)
?.loaders.map((x) => x.id) || []
)
})
watch(selectedLoader, async () => {
if (selectedMCVersion.value) {
selectedLoaderVersion.value = ''
serverCheckError.value = ''
await checkVersionAvailability(selectedMCVersion.value)
}
})
watch(
selectedLoaderVersions,
(newVersions) => {
if (
newVersions.length > 0 &&
(!selectedLoaderVersion.value || !newVersions.includes(selectedLoaderVersion.value))
) {
selectedLoaderVersion.value = String(newVersions[0])
}
},
{ immediate: true },
)
const getLoaderVersion = async (loader: string, version: string) => {
return await $fetch(
`https://launcher-meta.modrinth.com/${loader?.toLowerCase()}/v0/versions/${version}.json`,
)
}
const checkVersionAvailability = async (version: string) => {
if (!version || version.trim().length < 3) return
isLoading.value = true
loadingServerCheck.value = true
try {
const mcRes = cachedVersions.value[version] || (await getLoaderVersion('minecraft', version))
cachedVersions.value[version] = mcRes
if (!mcRes.downloads?.server) {
serverCheckError.value = "We couldn't find a server.jar for this version."
return
}
const loader = selectedLoader.value.toLowerCase()
if (loader === 'paper' || loader === 'purpur') {
const fetchFn = loader === 'paper' ? fetchPaperVersions : fetchPurpurVersions
const result = await fetchFn(version)
if (!result) {
serverCheckError.value = `This Minecraft version is not supported by ${loader}.`
return
}
}
serverCheckError.value = ''
} catch (error) {
console.error(error)
serverCheckError.value = 'Failed to fetch versions.'
} finally {
loadingServerCheck.value = false
isLoading.value = false
}
}
watch(selectedMCVersion, checkVersionAvailability)
onMounted(() => {
fetchLoaderVersions()
})
const tags = useTags()
const mcVersions = computed(() =>
tags.value.gameVersions
.filter((x) =>
showSnapshots.value
? x.version_type === 'snapshot' || x.version_type === 'release'
: x.version_type === 'release',
)
.map((x) => x.version),
)
const isDangerous = computed(() => hardReset.value)
const canInstall = computed(() => {
const conds =
!selectedMCVersion.value ||
isLoading.value ||
loadingServerCheck.value ||
serverCheckError.value.trim().length > 0
if (selectedLoader.value.toLowerCase() === 'vanilla') {
return conds
}
return conds || !selectedLoaderVersion.value
})
const handleReinstall = async () => {
if (hardReset.value && !isSecondPhase.value) {
isSecondPhase.value = true
return
}
isLoading.value = true
try {
await props.server.general?.reinstall(
true,
selectedLoader.value,
selectedMCVersion.value,
selectedLoader.value === 'Vanilla' ? '' : selectedLoaderVersion.value,
props.initialSetup ? true : hardReset.value,
)
emit('reinstall', {
loader: selectedLoader.value,
lVersion: selectedLoaderVersion.value,
mVersion: selectedMCVersion.value,
})
hide()
} catch (error) {
if (error instanceof ModrinthServersFetchError && (error as any)?.statusCode === 429) {
addNotification({
title: 'Cannot reinstall server',
text: 'You are being rate limited. Please try again later.',
type: 'error',
})
} else {
addNotification({
title: 'Reinstall Failed',
text: 'An unexpected error occurred while reinstalling. Please try again later.',
type: 'error',
})
}
} finally {
isLoading.value = false
}
}
const onShow = () => {
selectedMCVersion.value = props.server.general?.mc_version || ''
if (isSnapshotSelected.value) {
showSnapshots.value = true
}
}
const onHide = () => {
hardReset.value = false
isSecondPhase.value = false
serverCheckError.value = ''
loadingServerCheck.value = false
isLoading.value = false
selectedMCVersion.value = ''
serverCheckError.value = ''
paperVersions.value = {}
purpurVersions.value = {}
}
const show = (loader: Loaders) => {
if (selectedLoader.value !== loader) {
selectedLoaderVersion.value = ''
}
selectedLoader.value = loader
selectedMCVersion.value = props.server.general?.mc_version || ''
versionSelectModal.value?.show()
}
const hide = () => versionSelectModal.value?.hide()
defineExpose({ show, hide })
</script>
<style scoped>
.stylized-toggle:checked::after {
background: var(--color-accent-contrast) !important;
}
</style>