feat: paper channel badges (#5850)
This commit is contained in:
@@ -26,7 +26,9 @@
|
|||||||
{ "url": "https://modrinth.com/*" },
|
{ "url": "https://modrinth.com/*" },
|
||||||
{ "url": "https://*.modrinth.com/*" },
|
{ "url": "https://*.modrinth.com/*" },
|
||||||
{ "url": "https://*.nodes.modrinth.com/*" },
|
{ "url": "https://*.nodes.modrinth.com/*" },
|
||||||
{ "url": "https://api.mclo.gs/*" }
|
{ "url": "https://api.mclo.gs/*" },
|
||||||
|
{ "url": "https://fill.papermc.io/*" },
|
||||||
|
{ "url": "https://api.purpurmc.org/*" }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -22,8 +22,8 @@
|
|||||||
class="w-min"
|
class="w-min"
|
||||||
@update:model-value="$emit('update:selectedCurrency', $event)"
|
@update:model-value="$emit('update:selectedCurrency', $event)"
|
||||||
>
|
>
|
||||||
<template v-for="option in currencyOptions" :key="option.value" #[`option-${option.value}`]>
|
<template #option="{ item }">
|
||||||
<span class="font-semibold leading-tight">{{ option.label }}</span>
|
<span class="font-semibold leading-tight">{{ item.label }}</span>
|
||||||
</template>
|
</template>
|
||||||
</Combobox>
|
</Combobox>
|
||||||
<ButtonStyled>
|
<ButtonStyled>
|
||||||
|
|||||||
@@ -75,16 +75,16 @@
|
|||||||
<span class="font-semibold leading-tight">{{ selectedRewardOption.label }}</span>
|
<span class="font-semibold leading-tight">{{ selectedRewardOption.label }}</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-for="option in rewardOptions" :key="option.value" #[`option-${option.value}`]>
|
<template #option="{ item }">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<img
|
<img
|
||||||
v-if="option.imageUrl"
|
v-if="item.imageUrl"
|
||||||
:src="option.imageUrl"
|
:src="item.imageUrl"
|
||||||
:alt="option.label"
|
:alt="item.label"
|
||||||
class="size-5 rounded-full object-cover"
|
class="size-5 rounded-full object-cover"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
<span class="font-semibold leading-tight">{{ option.label }}</span>
|
<span class="font-semibold leading-tight">{{ item.label }}</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Combobox>
|
</Combobox>
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { $fetch } from 'ofetch'
|
|
||||||
|
|
||||||
import { AbstractModule } from '../../core/abstract-module'
|
import { AbstractModule } from '../../core/abstract-module'
|
||||||
import type { LauncherMeta } from './types'
|
import type { LauncherMeta } from './types'
|
||||||
|
|
||||||
export type { LauncherMeta } from './types'
|
export type { LauncherMeta } from './types'
|
||||||
|
|
||||||
const BASE_URL = 'https://launcher-meta.modrinth.com'
|
const LAUNCHER_META_BASE_URL = 'https://launcher-meta.modrinth.com'
|
||||||
|
|
||||||
export class LauncherMetaManifestV0Module extends AbstractModule {
|
export class LauncherMetaManifestV0Module extends AbstractModule {
|
||||||
public getModuleID(): string {
|
public getModuleID(): string {
|
||||||
@@ -18,6 +16,11 @@ export class LauncherMetaManifestV0Module extends AbstractModule {
|
|||||||
* @param loader - Loader platform (fabric, forge, quilt, neo)
|
* @param loader - Loader platform (fabric, forge, quilt, neo)
|
||||||
*/
|
*/
|
||||||
public async getManifest(loader: string): Promise<LauncherMeta.Manifest.v0.Manifest> {
|
public async getManifest(loader: string): Promise<LauncherMeta.Manifest.v0.Manifest> {
|
||||||
return $fetch<LauncherMeta.Manifest.v0.Manifest>(`${BASE_URL}/${loader}/v0/manifest.json`)
|
return this.client.request<LauncherMeta.Manifest.v0.Manifest>('/manifest.json', {
|
||||||
|
api: LAUNCHER_META_BASE_URL,
|
||||||
|
version: `${loader}/v0`,
|
||||||
|
method: 'GET',
|
||||||
|
skipAuth: true,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,16 @@ export namespace Paper {
|
|||||||
versions: Record<string, string[]>
|
versions: Record<string, string[]>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type BuildChannel = 'STABLE' | 'BETA' | 'ALPHA'
|
||||||
|
|
||||||
|
export type Build = {
|
||||||
|
id: number
|
||||||
|
time: string
|
||||||
|
channel: BuildChannel | string
|
||||||
|
}
|
||||||
|
|
||||||
export type VersionBuilds = {
|
export type VersionBuilds = {
|
||||||
builds: number[]
|
builds: Build[]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { $fetch } from 'ofetch'
|
|
||||||
|
|
||||||
import { AbstractModule } from '../../core/abstract-module'
|
import { AbstractModule } from '../../core/abstract-module'
|
||||||
import type { Paper } from './types'
|
import type { Paper } from './types'
|
||||||
|
|
||||||
export type { Paper } from './types'
|
export type { Paper } from './types'
|
||||||
|
|
||||||
const BASE_URL = 'https://fill.papermc.io/v3'
|
const PAPER_BASE_URL = 'https://fill.papermc.io'
|
||||||
|
|
||||||
export class PaperVersionsV3Module extends AbstractModule {
|
export class PaperVersionsV3Module extends AbstractModule {
|
||||||
public getModuleID(): string {
|
public getModuleID(): string {
|
||||||
@@ -16,17 +14,27 @@ export class PaperVersionsV3Module extends AbstractModule {
|
|||||||
* Get the Paper project info including all supported Minecraft versions.
|
* Get the Paper project info including all supported Minecraft versions.
|
||||||
*/
|
*/
|
||||||
public async getProject(): Promise<Paper.Versions.v3.Project> {
|
public async getProject(): Promise<Paper.Versions.v3.Project> {
|
||||||
return $fetch<Paper.Versions.v3.Project>(`${BASE_URL}/projects/paper`)
|
return this.client.request<Paper.Versions.v3.Project>('/projects/paper', {
|
||||||
|
api: PAPER_BASE_URL,
|
||||||
|
version: 'v3',
|
||||||
|
method: 'GET',
|
||||||
|
skipAuth: true,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get available Paper builds for a Minecraft version.
|
* Get available Paper builds for a Minecraft version (includes channel per build).
|
||||||
|
*
|
||||||
|
* Fill (`fill.papermc.io`) returns a JSON array of builds at this path — not a `{ builds }`
|
||||||
|
* wrapper like some other Paper API shapes — so we normalize to `VersionBuilds`.
|
||||||
*
|
*
|
||||||
* @param mcVersion - Minecraft version (e.g. "1.21.4")
|
* @param mcVersion - Minecraft version (e.g. "1.21.4")
|
||||||
*/
|
*/
|
||||||
public async getBuilds(mcVersion: string): Promise<Paper.Versions.v3.VersionBuilds> {
|
public async getBuilds(mcVersion: string): Promise<Paper.Versions.v3.VersionBuilds> {
|
||||||
return $fetch<Paper.Versions.v3.VersionBuilds>(
|
const builds = await this.client.request<Paper.Versions.v3.Build[]>(
|
||||||
`${BASE_URL}/projects/paper/versions/${mcVersion}`,
|
`/projects/paper/versions/${mcVersion}/builds`,
|
||||||
|
{ api: PAPER_BASE_URL, version: 'v3', method: 'GET', skipAuth: true },
|
||||||
)
|
)
|
||||||
|
return { builds }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { $fetch } from 'ofetch'
|
|
||||||
|
|
||||||
import { AbstractModule } from '../../core/abstract-module'
|
import { AbstractModule } from '../../core/abstract-module'
|
||||||
import type { Purpur } from './types'
|
import type { Purpur } from './types'
|
||||||
|
|
||||||
export type { Purpur } from './types'
|
export type { Purpur } from './types'
|
||||||
|
|
||||||
const BASE_URL = 'https://api.purpurmc.org/v2'
|
const PURPUR_BASE_URL = 'https://api.purpurmc.org'
|
||||||
|
|
||||||
export class PurpurVersionsV2Module extends AbstractModule {
|
export class PurpurVersionsV2Module extends AbstractModule {
|
||||||
public getModuleID(): string {
|
public getModuleID(): string {
|
||||||
@@ -16,7 +14,12 @@ export class PurpurVersionsV2Module extends AbstractModule {
|
|||||||
* Get the Purpur project info including all supported Minecraft versions.
|
* Get the Purpur project info including all supported Minecraft versions.
|
||||||
*/
|
*/
|
||||||
public async getProject(): Promise<Purpur.Versions.v2.Project> {
|
public async getProject(): Promise<Purpur.Versions.v2.Project> {
|
||||||
return $fetch<Purpur.Versions.v2.Project>(`${BASE_URL}/purpur`)
|
return this.client.request<Purpur.Versions.v2.Project>('/purpur', {
|
||||||
|
api: PURPUR_BASE_URL,
|
||||||
|
version: 'v2',
|
||||||
|
method: 'GET',
|
||||||
|
skipAuth: true,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -25,6 +28,11 @@ export class PurpurVersionsV2Module extends AbstractModule {
|
|||||||
* @param mcVersion - Minecraft version (e.g. "1.21.4")
|
* @param mcVersion - Minecraft version (e.g. "1.21.4")
|
||||||
*/
|
*/
|
||||||
public async getBuilds(mcVersion: string): Promise<Purpur.Versions.v2.VersionBuilds> {
|
public async getBuilds(mcVersion: string): Promise<Purpur.Versions.v2.VersionBuilds> {
|
||||||
return $fetch<Purpur.Versions.v2.VersionBuilds>(`${BASE_URL}/purpur/${mcVersion}`)
|
return this.client.request<Purpur.Versions.v2.VersionBuilds>(`/purpur/${mcVersion}`, {
|
||||||
|
api: PURPUR_BASE_URL,
|
||||||
|
version: 'v2',
|
||||||
|
method: 'GET',
|
||||||
|
skipAuth: true,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,44 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="containerRef" class="relative inline-block w-full">
|
<div ref="containerRef" class="relative inline-block w-full">
|
||||||
<!-- Searchable mode: input trigger -->
|
<!-- Searchable mode: input trigger -->
|
||||||
<StyledInput
|
<div v-if="searchable" class="relative w-full rounded-xl bg-surface-4">
|
||||||
v-if="searchable"
|
<!--
|
||||||
ref="searchTriggerRef"
|
Selection mirror: horizontal padding must match StyledInput (filled + left icon uses `pl-10`,
|
||||||
v-model="searchQuery"
|
else `pl-3`) and `searchableInputClass` when the chevron is shown (`!pr-9`), or the overlay
|
||||||
:icon="showSearchIcon ? SearchIcon : undefined"
|
text will not line up with the transparent input text / caret.
|
||||||
type="text"
|
-->
|
||||||
:placeholder="searchPlaceholder || placeholder"
|
<div
|
||||||
:disabled="disabled"
|
v-if="searchSelectionOverlayVisible"
|
||||||
wrapper-class="w-full"
|
class="pointer-events-none absolute inset-y-0 left-0 right-0 z-0 flex min-w-0 items-center gap-2 font-medium text-primary"
|
||||||
:input-class="showChevron ? '!pr-9' : undefined"
|
:class="[showSearchIcon ? 'pl-10' : 'pl-3', showChevron ? 'pr-9' : 'pr-3']"
|
||||||
class="relative"
|
aria-hidden="true"
|
||||||
@input="handleSearchInput"
|
>
|
||||||
@keydown="handleSearchKeydown"
|
<span class="min-w-0 truncate">{{ searchQuery }}</span>
|
||||||
@focus="handleSearchFocus"
|
<slot name="search-selection-affix" :option="selectedOption" />
|
||||||
@click="handleSearchClick"
|
</div>
|
||||||
>
|
<StyledInput
|
||||||
<template v-if="showChevron" #right>
|
ref="searchTriggerRef"
|
||||||
<ChevronLeftIcon
|
v-model="searchQuery"
|
||||||
class="pointer-events-none absolute right-3 top-1/2 size-5 -translate-y-1/2 text-secondary transition-transform duration-150"
|
:icon="showSearchIcon ? SearchIcon : undefined"
|
||||||
:class="isOpen ? (openDirection === 'down' ? 'rotate-90' : '-rotate-90') : '-rotate-90'"
|
type="text"
|
||||||
/>
|
:placeholder="searchPlaceholder || placeholder"
|
||||||
</template>
|
:disabled="disabled"
|
||||||
</StyledInput>
|
wrapper-class="w-full !bg-transparent"
|
||||||
|
:input-class="searchableInputClass"
|
||||||
|
class="relative z-[1]"
|
||||||
|
@input="handleSearchInput"
|
||||||
|
@keydown="handleSearchKeydown"
|
||||||
|
@focus="handleSearchFocus"
|
||||||
|
@click="handleSearchClick"
|
||||||
|
>
|
||||||
|
<template v-if="showChevron" #right>
|
||||||
|
<ChevronLeftIcon
|
||||||
|
class="pointer-events-none absolute right-3 top-1/2 size-5 -translate-y-1/2 text-secondary transition-transform duration-150"
|
||||||
|
:class="isOpen ? (openDirection === 'down' ? 'rotate-90' : '-rotate-90') : '-rotate-90'"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</StyledInput>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Standard mode: button trigger -->
|
<!-- Standard mode: button trigger -->
|
||||||
<span
|
<span
|
||||||
@@ -108,9 +123,14 @@
|
|||||||
:class="getOptionClasses(item, index)"
|
:class="getOptionClasses(item, index)"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
@click="handleOptionClick(item, index)"
|
@click="handleOptionClick(item, index)"
|
||||||
@mouseenter="!item.disabled && (focusedIndex = index)"
|
@mouseenter="handleOptionMouseEnter(item, index)"
|
||||||
>
|
>
|
||||||
<slot :name="`option-${item.value}`" :item="item">
|
<slot
|
||||||
|
name="option"
|
||||||
|
:item="item"
|
||||||
|
:index="index"
|
||||||
|
:is-selected="!!(listbox && item.value === modelValue)"
|
||||||
|
>
|
||||||
<div class="flex w-full items-center justify-between gap-2">
|
<div class="flex w-full items-center justify-between gap-2">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<component :is="item.icon" v-if="item.icon" class="h-5 w-5" />
|
<component :is="item.icon" v-if="item.icon" class="h-5 w-5" />
|
||||||
@@ -151,7 +171,16 @@
|
|||||||
<script setup lang="ts" generic="T">
|
<script setup lang="ts" generic="T">
|
||||||
import { ChevronLeftIcon, SearchIcon } from '@modrinth/assets'
|
import { ChevronLeftIcon, SearchIcon } from '@modrinth/assets'
|
||||||
import { onClickOutside } from '@vueuse/core'
|
import { onClickOutside } from '@vueuse/core'
|
||||||
import { type Component, computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
import {
|
||||||
|
type Component,
|
||||||
|
computed,
|
||||||
|
nextTick,
|
||||||
|
onMounted,
|
||||||
|
onUnmounted,
|
||||||
|
ref,
|
||||||
|
useSlots,
|
||||||
|
watch,
|
||||||
|
} from 'vue'
|
||||||
|
|
||||||
import StyledInput from './StyledInput.vue'
|
import StyledInput from './StyledInput.vue'
|
||||||
|
|
||||||
@@ -223,11 +252,14 @@ const props = withDefaults(
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:modelValue': [value: T]
|
'update:modelValue': [value: T]
|
||||||
select: [option: ComboboxOption<T>]
|
select: [option: ComboboxOption<T>]
|
||||||
|
'option-hover': [option: ComboboxOption<T>]
|
||||||
open: []
|
open: []
|
||||||
close: []
|
close: []
|
||||||
searchInput: [query: string]
|
searchInput: [query: string]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const slots = useSlots()
|
||||||
|
|
||||||
const isOpen = ref(false)
|
const isOpen = ref(false)
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const userHasTyped = ref(false)
|
const userHasTyped = ref(false)
|
||||||
@@ -261,6 +293,23 @@ const selectedOption = computed<ComboboxOption<T> | undefined>(() => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** Extra content (e.g. channel pill) next to the label while the search field is idle */
|
||||||
|
const searchSelectionOverlayVisible = computed(() => {
|
||||||
|
if (!props.searchable || !props.syncWithSelection || !selectedOption.value) return false
|
||||||
|
if (!slots['search-selection-affix']) return false
|
||||||
|
if (isOpen.value || userHasTyped.value) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
const searchableInputClass = computed(() => {
|
||||||
|
const parts = ['!bg-transparent']
|
||||||
|
if (props.showChevron) parts.push('!pr-9')
|
||||||
|
if (searchSelectionOverlayVisible.value) {
|
||||||
|
parts.push('!text-transparent [caret-color:var(--color-text-primary)] selection:bg-transparent')
|
||||||
|
}
|
||||||
|
return parts.join(' ')
|
||||||
|
})
|
||||||
|
|
||||||
const triggerText = computed(() => {
|
const triggerText = computed(() => {
|
||||||
if (props.displayValue !== undefined) return props.displayValue
|
if (props.displayValue !== undefined) return props.displayValue
|
||||||
if (selectedOption.value) return selectedOption.value.label
|
if (selectedOption.value) return selectedOption.value.label
|
||||||
@@ -446,6 +495,12 @@ function handleOptionClick(option: ComboboxOption<T>, index: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleOptionMouseEnter(option: ComboboxOption<T>, index: number) {
|
||||||
|
if (option.disabled) return
|
||||||
|
focusedIndex.value = index
|
||||||
|
emit('option-hover', option)
|
||||||
|
}
|
||||||
|
|
||||||
function findNextFocusableOption(currentIndex: number, direction: 'next' | 'previous'): number {
|
function findNextFocusableOption(currentIndex: number, direction: 'next' | 'previous'): number {
|
||||||
const length = filteredOptions.value.length
|
const length = filteredOptions.value.length
|
||||||
let index = currentIndex
|
let index = currentIndex
|
||||||
|
|||||||
29
packages/ui/src/components/base/PaperChannelBadge.vue
Normal file
29
packages/ui/src/components/base/PaperChannelBadge.vue
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<template>
|
||||||
|
<span
|
||||||
|
v-if="channel === 'ALPHA'"
|
||||||
|
class="rounded-full bg-bg-red px-2 text-sm font-bold text-red"
|
||||||
|
:class="{ 'shrink-0': affix }"
|
||||||
|
>
|
||||||
|
{{ formatMessage(commonMessages.alpha) }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else-if="channel === 'BETA'"
|
||||||
|
class="rounded-full bg-bg-orange px-2 text-sm font-bold text-orange"
|
||||||
|
:class="{ 'shrink-0': affix }"
|
||||||
|
>
|
||||||
|
{{ formatMessage(commonMessages.beta) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useVIntl } from '#ui/composables/i18n'
|
||||||
|
import { commonMessages } from '#ui/utils/common-messages'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
channel: 'ALPHA' | 'BETA' | null | undefined
|
||||||
|
/** When true, prevents the badge from shrinking in flex rows (e.g. search field affix). */
|
||||||
|
affix?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { formatMessage } = useVIntl()
|
||||||
|
</script>
|
||||||
@@ -51,6 +51,7 @@
|
|||||||
sync-with-selection
|
sync-with-selection
|
||||||
placeholder="Select game version"
|
placeholder="Select game version"
|
||||||
search-placeholder="Search game version..."
|
search-placeholder="Search game version..."
|
||||||
|
@option-hover="handleGameVersionHover"
|
||||||
>
|
>
|
||||||
<template v-if="ctx.showSnapshotToggle" #dropdown-footer>
|
<template v-if="ctx.showSnapshotToggle" #dropdown-footer>
|
||||||
<button
|
<button
|
||||||
@@ -90,7 +91,28 @@
|
|||||||
:search-placeholder="
|
:search-placeholder="
|
||||||
isPaperLike ? 'Search build number...' : 'Search loader version...'
|
isPaperLike ? 'Search build number...' : 'Search loader version...'
|
||||||
"
|
"
|
||||||
/>
|
>
|
||||||
|
<!-- When not Paper, this scoped slot is omitted and Combobox uses default option markup. -->
|
||||||
|
<template v-if="selectedLoader === 'paper'" #option="{ item, isSelected }">
|
||||||
|
<div class="flex w-full items-center justify-between gap-2">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="font-semibold leading-tight"
|
||||||
|
:class="isSelected ? 'text-contrast' : 'text-primary'"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</span>
|
||||||
|
<PaperChannelBadge :channel="paperBuildChannelTag(String(item.value))" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-if="selectedLoader === 'paper'" #search-selection-affix="{ option }">
|
||||||
|
<PaperChannelBadge
|
||||||
|
affix
|
||||||
|
:channel="option ? paperBuildChannelTag(String(option.value)) : null"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Combobox>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
@@ -99,6 +121,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { Paper } from '@modrinth/api-client'
|
||||||
import { EyeIcon, EyeOffIcon, UploadIcon, XIcon } from '@modrinth/assets'
|
import { EyeIcon, EyeOffIcon, UploadIcon, XIcon } from '@modrinth/assets'
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
@@ -110,6 +133,7 @@ import ButtonStyled from '../../../base/ButtonStyled.vue'
|
|||||||
import Chips from '../../../base/Chips.vue'
|
import Chips from '../../../base/Chips.vue'
|
||||||
import Collapsible from '../../../base/Collapsible.vue'
|
import Collapsible from '../../../base/Collapsible.vue'
|
||||||
import Combobox, { type ComboboxOption } from '../../../base/Combobox.vue'
|
import Combobox, { type ComboboxOption } from '../../../base/Combobox.vue'
|
||||||
|
import PaperChannelBadge from '../../../base/PaperChannelBadge.vue'
|
||||||
import StyledInput from '../../../base/StyledInput.vue'
|
import StyledInput from '../../../base/StyledInput.vue'
|
||||||
import type { LoaderVersionType } from '../creation-flow-context'
|
import type { LoaderVersionType } from '../creation-flow-context'
|
||||||
import { injectCreationFlowContext } from '../creation-flow-context'
|
import { injectCreationFlowContext } from '../creation-flow-context'
|
||||||
@@ -192,7 +216,7 @@ const loaderVersionsData = ref<LoaderVersionEntry[]>([])
|
|||||||
const loaderVersionsCache = ref<Record<string, { id: string; loaders: LoaderVersionEntry[] }[]>>({})
|
const loaderVersionsCache = ref<Record<string, { id: string; loaders: LoaderVersionEntry[] }[]>>({})
|
||||||
|
|
||||||
// Paper/Purpur build caches
|
// Paper/Purpur build caches
|
||||||
const paperVersions = ref<Record<string, number[]>>({})
|
const paperVersions = ref<Record<string, Paper.Versions.v3.Build[]>>({})
|
||||||
const purpurVersions = ref<Record<string, string[]>>({})
|
const purpurVersions = ref<Record<string, string[]>>({})
|
||||||
|
|
||||||
// Paper/Purpur supported game version sets (for filtering the game version combobox)
|
// Paper/Purpur supported game version sets (for filtering the game version combobox)
|
||||||
@@ -298,16 +322,33 @@ async function fetchPurpurSupportedVersions() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function paperBuildChannelTag(buildId: string): 'ALPHA' | 'BETA' | null {
|
||||||
|
const gv = selectedGameVersion.value
|
||||||
|
if (!gv || selectedLoader.value !== 'paper') return null
|
||||||
|
const b = paperVersions.value[gv]?.find((x) => String(x.id) === buildId)
|
||||||
|
if (!b) return null
|
||||||
|
const u = String(b.channel).toUpperCase()
|
||||||
|
if (u === 'ALPHA' || u === 'BETA') return u
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchPaperVersions(mcVersion: string) {
|
async function fetchPaperVersions(mcVersion: string) {
|
||||||
if (paperVersions.value[mcVersion]) return
|
if (paperVersions.value[mcVersion]) return
|
||||||
try {
|
try {
|
||||||
const data = await client.paper.versions_v3.getBuilds(mcVersion)
|
const data = await client.paper.versions_v3.getBuilds(mcVersion)
|
||||||
paperVersions.value[mcVersion] = data.builds.sort((a, b) => b - a)
|
paperVersions.value[mcVersion] = data.builds.toSorted((a, b) => b.id - a.id)
|
||||||
} catch {
|
} catch {
|
||||||
paperVersions.value[mcVersion] = []
|
paperVersions.value[mcVersion] = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleGameVersionHover(option: ComboboxOption<string | null>) {
|
||||||
|
const v = option.value
|
||||||
|
if (v == null || v === '') return
|
||||||
|
if (selectedLoader.value === 'paper') void fetchPaperVersions(v)
|
||||||
|
else if (selectedLoader.value === 'purpur') void fetchPurpurVersions(v)
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchPurpurVersions(mcVersion: string) {
|
async function fetchPurpurVersions(mcVersion: string) {
|
||||||
if (purpurVersions.value[mcVersion]) return
|
if (purpurVersions.value[mcVersion]) return
|
||||||
try {
|
try {
|
||||||
@@ -393,7 +434,7 @@ watch(
|
|||||||
loaderVersionsLoading.value = false
|
loaderVersionsLoading.value = false
|
||||||
const builds = paperVersions.value[gameVersion]
|
const builds = paperVersions.value[gameVersion]
|
||||||
if (builds?.length) {
|
if (builds?.length) {
|
||||||
selectedLoaderVersion.value = `${builds[0]}`
|
selectedLoaderVersion.value = `${builds[0].id}`
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -459,7 +500,10 @@ function autoSelectLoaderVersion() {
|
|||||||
const loaderVersionOptions = computed<ComboboxOption<string>[]>(() => {
|
const loaderVersionOptions = computed<ComboboxOption<string>[]>(() => {
|
||||||
if (selectedLoader.value === 'paper' && selectedGameVersion.value) {
|
if (selectedLoader.value === 'paper' && selectedGameVersion.value) {
|
||||||
const builds = paperVersions.value[selectedGameVersion.value] ?? []
|
const builds = paperVersions.value[selectedGameVersion.value] ?? []
|
||||||
return builds.map((b) => ({ value: `${b}`, label: `Build ${b}` }))
|
return builds.map((b) => ({
|
||||||
|
value: `${b.id}`,
|
||||||
|
label: `Build ${b.id}`,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedLoader.value === 'purpur' && selectedGameVersion.value) {
|
if (selectedLoader.value === 'purpur' && selectedGameVersion.value) {
|
||||||
|
|||||||
@@ -40,12 +40,16 @@ export function useInstallationForm(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const loaderVersionOptions = computed(() =>
|
const loaderVersionOptions = computed(() =>
|
||||||
loaderVersionEntries.value.map((v, index) => ({ value: index, label: v.id })),
|
loaderVersionEntries.value.map((v, index) => ({
|
||||||
|
value: index,
|
||||||
|
label: v.label ?? v.id,
|
||||||
|
})),
|
||||||
)
|
)
|
||||||
|
|
||||||
const loaderVersionDisplayValue = computed(() => {
|
const loaderVersionDisplayValue = computed(() => {
|
||||||
const idx = selectedLoaderVersion.value
|
const idx = selectedLoaderVersion.value
|
||||||
return idx >= 0 && loaderVersionEntries.value[idx] ? loaderVersionEntries.value[idx].id : ''
|
const e = loaderVersionEntries.value[idx]
|
||||||
|
return idx >= 0 && e ? (e.label ?? e.id) : ''
|
||||||
})
|
})
|
||||||
|
|
||||||
const hasSnapshots = computed(() => ctx.resolveHasSnapshots(selectedPlatform.value))
|
const hasSnapshots = computed(() => ctx.resolveHasSnapshots(selectedPlatform.value))
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import Avatar from '#ui/components/base/Avatar.vue'
|
|||||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||||
import Chips from '#ui/components/base/Chips.vue'
|
import Chips from '#ui/components/base/Chips.vue'
|
||||||
import Combobox from '#ui/components/base/Combobox.vue'
|
import Combobox from '#ui/components/base/Combobox.vue'
|
||||||
|
import PaperChannelBadge from '#ui/components/base/PaperChannelBadge.vue'
|
||||||
import ConfirmLeaveModal from '#ui/components/modal/ConfirmLeaveModal.vue'
|
import ConfirmLeaveModal from '#ui/components/modal/ConfirmLeaveModal.vue'
|
||||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||||
import { commonMessages } from '#ui/utils/common-messages'
|
import { commonMessages } from '#ui/utils/common-messages'
|
||||||
@@ -35,6 +36,7 @@ import ContentDiffModal from './components/ContentDiffModal.vue'
|
|||||||
import IncompatibleContentModal from './components/IncompatibleContentModal.vue'
|
import IncompatibleContentModal from './components/IncompatibleContentModal.vue'
|
||||||
import { useInstallationForm } from './composables'
|
import { useInstallationForm } from './composables'
|
||||||
import { injectInstallationSettings } from './providers/installation-settings'
|
import { injectInstallationSettings } from './providers/installation-settings'
|
||||||
|
import type { LoaderVersionEntry } from './types'
|
||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
const ctx = injectInstallationSettings()
|
const ctx = injectInstallationSettings()
|
||||||
@@ -58,6 +60,16 @@ const form = useInstallationForm(
|
|||||||
incompatibleContentModal,
|
incompatibleContentModal,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
function paperLoaderChannelTag(index: number): LoaderVersionEntry['channelTag'] | null {
|
||||||
|
if (form.selectedPlatform.value !== 'paper') return null
|
||||||
|
const entries = ctx.resolveLoaderVersions(
|
||||||
|
form.selectedPlatform.value,
|
||||||
|
form.selectedGameVersion.value,
|
||||||
|
)
|
||||||
|
const tag = entries[index]?.channelTag
|
||||||
|
return tag === 'ALPHA' || tag === 'BETA' ? tag : null
|
||||||
|
}
|
||||||
|
|
||||||
function handleBeforeUnload(e: BeforeUnloadEvent) {
|
function handleBeforeUnload(e: BeforeUnloadEvent) {
|
||||||
if (form.isSaving.value) {
|
if (form.isSaving.value) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -525,6 +537,7 @@ const messages = defineMessages({
|
|||||||
formatMessage(commonMessages.selectVersionPlaceholder)
|
formatMessage(commonMessages.selectVersionPlaceholder)
|
||||||
"
|
"
|
||||||
:aria-label="formatMessage(messages.selectGameVersionAriaLabel)"
|
:aria-label="formatMessage(messages.selectGameVersionAriaLabel)"
|
||||||
|
@option-hover="ctx.onGameVersionHover?.($event)"
|
||||||
>
|
>
|
||||||
<template v-if="form.hasSnapshots.value" #dropdown-footer>
|
<template v-if="form.hasSnapshots.value" #dropdown-footer>
|
||||||
<button
|
<button
|
||||||
@@ -574,7 +587,33 @@ const messages = defineMessages({
|
|||||||
loader: form.formattedLoaderName.value,
|
loader: form.formattedLoaderName.value,
|
||||||
})
|
})
|
||||||
"
|
"
|
||||||
/>
|
>
|
||||||
|
<template
|
||||||
|
v-if="form.selectedPlatform.value === 'paper'"
|
||||||
|
#option="{ item, isSelected }"
|
||||||
|
>
|
||||||
|
<div class="flex w-full items-center justify-between gap-2">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="font-semibold leading-tight"
|
||||||
|
:class="isSelected ? 'text-contrast' : 'text-primary'"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</span>
|
||||||
|
<PaperChannelBadge :channel="paperLoaderChannelTag(item.value)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template
|
||||||
|
v-if="form.selectedPlatform.value === 'paper'"
|
||||||
|
#search-selection-affix="{ option }"
|
||||||
|
>
|
||||||
|
<PaperChannelBadge
|
||||||
|
affix
|
||||||
|
:channel="option ? paperLoaderChannelTag(option.value) : null"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Combobox>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ export interface InstallationSettingsContext {
|
|||||||
resolveLoaderVersions: (loader: string, gameVersion: string) => LoaderVersionEntry[]
|
resolveLoaderVersions: (loader: string, gameVersion: string) => LoaderVersionEntry[]
|
||||||
resolveHasSnapshots: (loader: string) => boolean
|
resolveHasSnapshots: (loader: string) => boolean
|
||||||
|
|
||||||
|
/** Prefetch loader build lists when the user hovers a game version (e.g. Paper/Purpur). */
|
||||||
|
onGameVersionHover?: (option: GameVersionOption) => void
|
||||||
|
|
||||||
save: (platform: string, gameVersion: string, loaderVersionId: string | null) => Promise<void>
|
save: (platform: string, gameVersion: string, loaderVersionId: string | null) => Promise<void>
|
||||||
repair: () => Promise<void>
|
repair: () => Promise<void>
|
||||||
reinstallModpack: () => Promise<void>
|
reinstallModpack: () => Promise<void>
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ export interface GameVersionOption {
|
|||||||
export interface LoaderVersionEntry {
|
export interface LoaderVersionEntry {
|
||||||
id: string
|
id: string
|
||||||
stable?: boolean
|
stable?: boolean
|
||||||
|
/** Shown in the loader-version combobox when set; defaults to `id` */
|
||||||
|
label?: string
|
||||||
|
/** Paper build channel for optional UI (e.g. combobox pill); not used by Combobox itself */
|
||||||
|
channelTag?: 'ALPHA' | 'BETA'
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContentDiffItem {
|
export interface ContentDiffItem {
|
||||||
|
|||||||
@@ -67,7 +67,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Archon, LauncherMeta } from '@modrinth/api-client'
|
import type { Archon } from '@modrinth/api-client'
|
||||||
import { RotateCounterClockwiseIcon } from '@modrinth/assets'
|
import { RotateCounterClockwiseIcon } from '@modrinth/assets'
|
||||||
import {
|
import {
|
||||||
ButtonStyled,
|
ButtonStyled,
|
||||||
@@ -75,12 +75,14 @@ import {
|
|||||||
ConfirmModal,
|
ConfirmModal,
|
||||||
defineMessages,
|
defineMessages,
|
||||||
formatLoaderLabel,
|
formatLoaderLabel,
|
||||||
|
type GameVersionOption,
|
||||||
injectModrinthClient,
|
injectModrinthClient,
|
||||||
injectModrinthServerContext,
|
injectModrinthServerContext,
|
||||||
injectNotificationManager,
|
injectNotificationManager,
|
||||||
injectServerSettings,
|
injectServerSettings,
|
||||||
injectTags,
|
injectTags,
|
||||||
InstallationSettingsLayout,
|
InstallationSettingsLayout,
|
||||||
|
type LoaderVersionEntry,
|
||||||
provideInstallationSettings,
|
provideInstallationSettings,
|
||||||
ServerSetupModal,
|
ServerSetupModal,
|
||||||
UploadProgressModal,
|
UploadProgressModal,
|
||||||
@@ -295,7 +297,21 @@ const purpurSupportedVersionsQuery = useQuery({
|
|||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
})
|
})
|
||||||
|
|
||||||
type LoaderVersionEntry = LauncherMeta.Manifest.v0.LoaderVersion
|
function handleGameVersionHover(option: GameVersionOption) {
|
||||||
|
if (editingPlatform.value === 'paper') {
|
||||||
|
void queryClient.prefetchQuery({
|
||||||
|
queryKey: ['paper-builds', option.value] as const,
|
||||||
|
queryFn: () => client.paper.versions_v3.getBuilds(option.value),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
})
|
||||||
|
} else if (editingPlatform.value === 'purpur') {
|
||||||
|
void queryClient.prefetchQuery({
|
||||||
|
queryKey: ['purpur-builds', option.value] as const,
|
||||||
|
queryFn: () => client.purpur.versions_v2.getBuilds(option.value),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getLoaderVersionsForGameVersion(
|
function getLoaderVersionsForGameVersion(
|
||||||
loader: string,
|
loader: string,
|
||||||
@@ -303,8 +319,18 @@ function getLoaderVersionsForGameVersion(
|
|||||||
): LoaderVersionEntry[] {
|
): LoaderVersionEntry[] {
|
||||||
if (loader === 'paper') {
|
if (loader === 'paper') {
|
||||||
return (paperBuildsQuery.data.value?.builds ?? [])
|
return (paperBuildsQuery.data.value?.builds ?? [])
|
||||||
.toSorted((a, b) => b - a)
|
.toSorted((a, b) => b.id - a.id)
|
||||||
.map((b) => ({ id: String(b), stable: true }))
|
.map((b): LoaderVersionEntry => {
|
||||||
|
const u = String(b.channel).toUpperCase()
|
||||||
|
let channelTag: LoaderVersionEntry['channelTag'] | undefined
|
||||||
|
if (u === 'ALPHA' || u === 'BETA') channelTag = u
|
||||||
|
return {
|
||||||
|
id: String(b.id),
|
||||||
|
stable: b.channel === 'STABLE',
|
||||||
|
label: `Build ${b.id}`,
|
||||||
|
channelTag,
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
if (loader === 'purpur') {
|
if (loader === 'purpur') {
|
||||||
return (purpurBuildsQuery.data.value?.builds.all ?? [])
|
return (purpurBuildsQuery.data.value?.builds.all ?? [])
|
||||||
@@ -328,6 +354,7 @@ function toApiLoader(loader: string): Archon.Content.v1.Modloader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
provideInstallationSettings({
|
provideInstallationSettings({
|
||||||
|
onGameVersionHover: handleGameVersionHover,
|
||||||
loading: computed(() => !server.value || addonsQuery.isLoading.value),
|
loading: computed(() => !server.value || addonsQuery.isLoading.value),
|
||||||
installationInfo: computed(() => {
|
installationInfo: computed(() => {
|
||||||
const addons = addonsQuery.data.value
|
const addons = addonsQuery.data.value
|
||||||
|
|||||||
@@ -35,6 +35,9 @@
|
|||||||
"app.server-settings.failed-to-load-server": {
|
"app.server-settings.failed-to-load-server": {
|
||||||
"defaultMessage": "Failed to load server settings"
|
"defaultMessage": "Failed to load server settings"
|
||||||
},
|
},
|
||||||
|
"badge.alpha": {
|
||||||
|
"defaultMessage": "Alpha"
|
||||||
|
},
|
||||||
"badge.beta": {
|
"badge.beta": {
|
||||||
"defaultMessage": "Beta"
|
"defaultMessage": "Beta"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -187,3 +187,56 @@ export const MixedSubLabels: Story = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Custom `#option` + `#search-selection-affix` (idle search field overlay), with `sync-with-selection`. */
|
||||||
|
export const SearchableWithOptionAndSelectionAffix: StoryObj = {
|
||||||
|
render: () => ({
|
||||||
|
components: { Combobox },
|
||||||
|
data: () => ({
|
||||||
|
selected: '100',
|
||||||
|
}),
|
||||||
|
computed: {
|
||||||
|
options(): { value: string; label: string; tag?: 'BETA' }[] {
|
||||||
|
return [
|
||||||
|
{ value: '100', label: 'Build 100' },
|
||||||
|
{ value: '99', label: 'Build 99', tag: 'BETA' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
template: /* html */ `
|
||||||
|
<Combobox
|
||||||
|
v-model="selected"
|
||||||
|
:options="options"
|
||||||
|
searchable
|
||||||
|
sync-with-selection
|
||||||
|
placeholder="Select build"
|
||||||
|
search-placeholder="Search builds..."
|
||||||
|
>
|
||||||
|
<template #option="{ item, isSelected }">
|
||||||
|
<div class="flex w-full items-center justify-between gap-2">
|
||||||
|
<span
|
||||||
|
class="font-semibold leading-tight"
|
||||||
|
:class="isSelected ? 'text-contrast' : 'text-primary'"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="item.tag === 'BETA'"
|
||||||
|
class="shrink-0 rounded-full bg-bg-orange px-2 text-sm font-bold text-orange"
|
||||||
|
>
|
||||||
|
Beta
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #search-selection-affix="{ option }">
|
||||||
|
<span
|
||||||
|
v-if="option && option.tag === 'BETA'"
|
||||||
|
class="shrink-0 rounded-full bg-bg-orange px-2 text-sm font-bold text-orange"
|
||||||
|
>
|
||||||
|
Beta
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</Combobox>
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ export const commonMessages = defineMessages({
|
|||||||
id: 'button.analytics',
|
id: 'button.analytics',
|
||||||
defaultMessage: 'Analytics',
|
defaultMessage: 'Analytics',
|
||||||
},
|
},
|
||||||
|
alpha: {
|
||||||
|
id: 'badge.alpha',
|
||||||
|
defaultMessage: 'Alpha',
|
||||||
|
},
|
||||||
betaRelease: {
|
betaRelease: {
|
||||||
id: 'badge.beta-release',
|
id: 'badge.beta-release',
|
||||||
defaultMessage: 'Beta Release',
|
defaultMessage: 'Beta Release',
|
||||||
|
|||||||
Reference in New Issue
Block a user