feat: paper channel badges (#5850)

This commit is contained in:
Calum H.
2026-04-18 19:13:08 +01:00
committed by GitHub
parent ab623dc325
commit 3e32901737
18 changed files with 357 additions and 63 deletions

View File

@@ -1,29 +1,44 @@
<template>
<div ref="containerRef" class="relative inline-block w-full">
<!-- Searchable mode: input trigger -->
<StyledInput
v-if="searchable"
ref="searchTriggerRef"
v-model="searchQuery"
:icon="showSearchIcon ? SearchIcon : undefined"
type="text"
:placeholder="searchPlaceholder || placeholder"
:disabled="disabled"
wrapper-class="w-full"
:input-class="showChevron ? '!pr-9' : undefined"
class="relative"
@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 v-if="searchable" class="relative w-full rounded-xl bg-surface-4">
<!--
Selection mirror: horizontal padding must match StyledInput (filled + left icon uses `pl-10`,
else `pl-3`) and `searchableInputClass` when the chevron is shown (`!pr-9`), or the overlay
text will not line up with the transparent input text / caret.
-->
<div
v-if="searchSelectionOverlayVisible"
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"
:class="[showSearchIcon ? 'pl-10' : 'pl-3', showChevron ? 'pr-9' : 'pr-3']"
aria-hidden="true"
>
<span class="min-w-0 truncate">{{ searchQuery }}</span>
<slot name="search-selection-affix" :option="selectedOption" />
</div>
<StyledInput
ref="searchTriggerRef"
v-model="searchQuery"
:icon="showSearchIcon ? SearchIcon : undefined"
type="text"
:placeholder="searchPlaceholder || placeholder"
:disabled="disabled"
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 -->
<span
@@ -108,9 +123,14 @@
:class="getOptionClasses(item, index)"
tabindex="-1"
@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 items-center gap-2">
<component :is="item.icon" v-if="item.icon" class="h-5 w-5" />
@@ -151,7 +171,16 @@
<script setup lang="ts" generic="T">
import { ChevronLeftIcon, SearchIcon } from '@modrinth/assets'
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'
@@ -223,11 +252,14 @@ const props = withDefaults(
const emit = defineEmits<{
'update:modelValue': [value: T]
select: [option: ComboboxOption<T>]
'option-hover': [option: ComboboxOption<T>]
open: []
close: []
searchInput: [query: string]
}>()
const slots = useSlots()
const isOpen = ref(false)
const searchQuery = ref('')
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(() => {
if (props.displayValue !== undefined) return props.displayValue
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 {
const length = filteredOptions.value.length
let index = currentIndex

View 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>

View File

@@ -51,6 +51,7 @@
sync-with-selection
placeholder="Select game version"
search-placeholder="Search game version..."
@option-hover="handleGameVersionHover"
>
<template v-if="ctx.showSnapshotToggle" #dropdown-footer>
<button
@@ -90,7 +91,28 @@
:search-placeholder="
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>
</Collapsible>
@@ -99,6 +121,7 @@
</template>
<script setup lang="ts">
import type { Paper } from '@modrinth/api-client'
import { EyeIcon, EyeOffIcon, UploadIcon, XIcon } from '@modrinth/assets'
import { computed, onMounted, ref, watch } from 'vue'
@@ -110,6 +133,7 @@ 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 PaperChannelBadge from '../../../base/PaperChannelBadge.vue'
import StyledInput from '../../../base/StyledInput.vue'
import type { LoaderVersionType } 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[] }[]>>({})
// 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[]>>({})
// 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) {
if (paperVersions.value[mcVersion]) return
try {
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 {
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) {
if (purpurVersions.value[mcVersion]) return
try {
@@ -393,7 +434,7 @@ watch(
loaderVersionsLoading.value = false
const builds = paperVersions.value[gameVersion]
if (builds?.length) {
selectedLoaderVersion.value = `${builds[0]}`
selectedLoaderVersion.value = `${builds[0].id}`
}
return
}
@@ -459,7 +500,10 @@ function autoSelectLoaderVersion() {
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}` }))
return builds.map((b) => ({
value: `${b.id}`,
label: `Build ${b.id}`,
}))
}
if (selectedLoader.value === 'purpur' && selectedGameVersion.value) {

View File

@@ -40,12 +40,16 @@ export function useInstallationForm(
)
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 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))

View File

@@ -21,6 +21,7 @@ import Avatar from '#ui/components/base/Avatar.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import Chips from '#ui/components/base/Chips.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 { defineMessages, useVIntl } from '#ui/composables/i18n'
import { commonMessages } from '#ui/utils/common-messages'
@@ -35,6 +36,7 @@ import ContentDiffModal from './components/ContentDiffModal.vue'
import IncompatibleContentModal from './components/IncompatibleContentModal.vue'
import { useInstallationForm } from './composables'
import { injectInstallationSettings } from './providers/installation-settings'
import type { LoaderVersionEntry } from './types'
const { formatMessage } = useVIntl()
const ctx = injectInstallationSettings()
@@ -58,6 +60,16 @@ const form = useInstallationForm(
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) {
if (form.isSaving.value) {
e.preventDefault()
@@ -525,6 +537,7 @@ const messages = defineMessages({
formatMessage(commonMessages.selectVersionPlaceholder)
"
:aria-label="formatMessage(messages.selectGameVersionAriaLabel)"
@option-hover="ctx.onGameVersionHover?.($event)"
>
<template v-if="form.hasSnapshots.value" #dropdown-footer>
<button
@@ -574,7 +587,33 @@ const messages = defineMessages({
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 class="flex flex-wrap gap-2">

View File

@@ -29,6 +29,9 @@ export interface InstallationSettingsContext {
resolveLoaderVersions: (loader: string, gameVersion: string) => LoaderVersionEntry[]
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>
repair: () => Promise<void>
reinstallModpack: () => Promise<void>

View File

@@ -29,6 +29,10 @@ export interface GameVersionOption {
export interface LoaderVersionEntry {
id: string
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 {

View File

@@ -67,7 +67,7 @@
</template>
<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 {
ButtonStyled,
@@ -75,12 +75,14 @@ import {
ConfirmModal,
defineMessages,
formatLoaderLabel,
type GameVersionOption,
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
injectServerSettings,
injectTags,
InstallationSettingsLayout,
type LoaderVersionEntry,
provideInstallationSettings,
ServerSetupModal,
UploadProgressModal,
@@ -295,7 +297,21 @@ const purpurSupportedVersionsQuery = useQuery({
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(
loader: string,
@@ -303,8 +319,18 @@ function getLoaderVersionsForGameVersion(
): LoaderVersionEntry[] {
if (loader === 'paper') {
return (paperBuildsQuery.data.value?.builds ?? [])
.toSorted((a, b) => b - a)
.map((b) => ({ id: String(b), stable: true }))
.toSorted((a, b) => b.id - a.id)
.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') {
return (purpurBuildsQuery.data.value?.builds.all ?? [])
@@ -328,6 +354,7 @@ function toApiLoader(loader: string): Archon.Content.v1.Modloader {
}
provideInstallationSettings({
onGameVersionHover: handleGameVersionHover,
loading: computed(() => !server.value || addonsQuery.isLoading.value),
installationInfo: computed(() => {
const addons = addonsQuery.data.value

View File

@@ -35,6 +35,9 @@
"app.server-settings.failed-to-load-server": {
"defaultMessage": "Failed to load server settings"
},
"badge.alpha": {
"defaultMessage": "Alpha"
},
"badge.beta": {
"defaultMessage": "Beta"
},

View File

@@ -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>
`,
}),
}

View File

@@ -13,6 +13,10 @@ export const commonMessages = defineMessages({
id: 'button.analytics',
defaultMessage: 'Analytics',
},
alpha: {
id: 'badge.alpha',
defaultMessage: 'Alpha',
},
betaRelease: {
id: 'badge.beta-release',
defaultMessage: 'Beta Release',