Skip to content

Commit 3e32901

Browse files
authored
feat: paper channel badges (#5850)
1 parent ab623dc commit 3e32901

18 files changed

Lines changed: 357 additions & 63 deletions

File tree

apps/app/capabilities/plugins.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@
2626
{ "url": "https://modrinth.com/*" },
2727
{ "url": "https://*.modrinth.com/*" },
2828
{ "url": "https://*.nodes.modrinth.com/*" },
29-
{ "url": "https://api.mclo.gs/*" }
29+
{ "url": "https://api.mclo.gs/*" },
30+
{ "url": "https://fill.papermc.io/*" },
31+
{ "url": "https://api.purpurmc.org/*" }
3032
]
3133
},
3234

apps/frontend/src/components/ui/dashboard/RevenueInputField.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@
2222
class="w-min"
2323
@update:model-value="$emit('update:selectedCurrency', $event)"
2424
>
25-
<template v-for="option in currencyOptions" :key="option.value" #[`option-${option.value}`]>
26-
<span class="font-semibold leading-tight">{{ option.label }}</span>
25+
<template #option="{ item }">
26+
<span class="font-semibold leading-tight">{{ item.label }}</span>
2727
</template>
2828
</Combobox>
2929
<ButtonStyled>

apps/frontend/src/components/ui/dashboard/withdraw-stages/TremendousDetailsStage.vue

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -75,16 +75,16 @@
7575
<span class="font-semibold leading-tight">{{ selectedRewardOption.label }}</span>
7676
</div>
7777
</template>
78-
<template v-for="option in rewardOptions" :key="option.value" #[`option-${option.value}`]>
78+
<template #option="{ item }">
7979
<div class="flex items-center gap-2">
8080
<img
81-
v-if="option.imageUrl"
82-
:src="option.imageUrl"
83-
:alt="option.label"
81+
v-if="item.imageUrl"
82+
:src="item.imageUrl"
83+
:alt="item.label"
8484
class="size-5 rounded-full object-cover"
8585
loading="lazy"
8686
/>
87-
<span class="font-semibold leading-tight">{{ option.label }}</span>
87+
<span class="font-semibold leading-tight">{{ item.label }}</span>
8888
</div>
8989
</template>
9090
</Combobox>

packages/api-client/src/modules/launcher-meta/v0.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
1-
import { $fetch } from 'ofetch'
2-
31
import { AbstractModule } from '../../core/abstract-module'
42
import type { LauncherMeta } from './types'
53

64
export type { LauncherMeta } from './types'
75

8-
const BASE_URL = 'https://launcher-meta.modrinth.com'
6+
const LAUNCHER_META_BASE_URL = 'https://launcher-meta.modrinth.com'
97

108
export class LauncherMetaManifestV0Module extends AbstractModule {
119
public getModuleID(): string {
@@ -18,6 +16,11 @@ export class LauncherMetaManifestV0Module extends AbstractModule {
1816
* @param loader - Loader platform (fabric, forge, quilt, neo)
1917
*/
2018
public async getManifest(loader: string): Promise<LauncherMeta.Manifest.v0.Manifest> {
21-
return $fetch<LauncherMeta.Manifest.v0.Manifest>(`${BASE_URL}/${loader}/v0/manifest.json`)
19+
return this.client.request<LauncherMeta.Manifest.v0.Manifest>('/manifest.json', {
20+
api: LAUNCHER_META_BASE_URL,
21+
version: `${loader}/v0`,
22+
method: 'GET',
23+
skipAuth: true,
24+
})
2225
}
2326
}

packages/api-client/src/modules/paper/types.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,16 @@ export namespace Paper {
66
versions: Record<string, string[]>
77
}
88

9+
export type BuildChannel = 'STABLE' | 'BETA' | 'ALPHA'
10+
11+
export type Build = {
12+
id: number
13+
time: string
14+
channel: BuildChannel | string
15+
}
16+
917
export type VersionBuilds = {
10-
builds: number[]
18+
builds: Build[]
1119
}
1220
}
1321
}
Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
1-
import { $fetch } from 'ofetch'
2-
31
import { AbstractModule } from '../../core/abstract-module'
42
import type { Paper } from './types'
53

64
export type { Paper } from './types'
75

8-
const BASE_URL = 'https://fill.papermc.io/v3'
6+
const PAPER_BASE_URL = 'https://fill.papermc.io'
97

108
export class PaperVersionsV3Module extends AbstractModule {
119
public getModuleID(): string {
@@ -16,17 +14,27 @@ export class PaperVersionsV3Module extends AbstractModule {
1614
* Get the Paper project info including all supported Minecraft versions.
1715
*/
1816
public async getProject(): Promise<Paper.Versions.v3.Project> {
19-
return $fetch<Paper.Versions.v3.Project>(`${BASE_URL}/projects/paper`)
17+
return this.client.request<Paper.Versions.v3.Project>('/projects/paper', {
18+
api: PAPER_BASE_URL,
19+
version: 'v3',
20+
method: 'GET',
21+
skipAuth: true,
22+
})
2023
}
2124

2225
/**
23-
* Get available Paper builds for a Minecraft version.
26+
* Get available Paper builds for a Minecraft version (includes channel per build).
27+
*
28+
* Fill (`fill.papermc.io`) returns a JSON array of builds at this path — not a `{ builds }`
29+
* wrapper like some other Paper API shapes — so we normalize to `VersionBuilds`.
2430
*
2531
* @param mcVersion - Minecraft version (e.g. "1.21.4")
2632
*/
2733
public async getBuilds(mcVersion: string): Promise<Paper.Versions.v3.VersionBuilds> {
28-
return $fetch<Paper.Versions.v3.VersionBuilds>(
29-
`${BASE_URL}/projects/paper/versions/${mcVersion}`,
34+
const builds = await this.client.request<Paper.Versions.v3.Build[]>(
35+
`/projects/paper/versions/${mcVersion}/builds`,
36+
{ api: PAPER_BASE_URL, version: 'v3', method: 'GET', skipAuth: true },
3037
)
38+
return { builds }
3139
}
3240
}
Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
1-
import { $fetch } from 'ofetch'
2-
31
import { AbstractModule } from '../../core/abstract-module'
42
import type { Purpur } from './types'
53

64
export type { Purpur } from './types'
75

8-
const BASE_URL = 'https://api.purpurmc.org/v2'
6+
const PURPUR_BASE_URL = 'https://api.purpurmc.org'
97

108
export class PurpurVersionsV2Module extends AbstractModule {
119
public getModuleID(): string {
@@ -16,7 +14,12 @@ export class PurpurVersionsV2Module extends AbstractModule {
1614
* Get the Purpur project info including all supported Minecraft versions.
1715
*/
1816
public async getProject(): Promise<Purpur.Versions.v2.Project> {
19-
return $fetch<Purpur.Versions.v2.Project>(`${BASE_URL}/purpur`)
17+
return this.client.request<Purpur.Versions.v2.Project>('/purpur', {
18+
api: PURPUR_BASE_URL,
19+
version: 'v2',
20+
method: 'GET',
21+
skipAuth: true,
22+
})
2023
}
2124

2225
/**
@@ -25,6 +28,11 @@ export class PurpurVersionsV2Module extends AbstractModule {
2528
* @param mcVersion - Minecraft version (e.g. "1.21.4")
2629
*/
2730
public async getBuilds(mcVersion: string): Promise<Purpur.Versions.v2.VersionBuilds> {
28-
return $fetch<Purpur.Versions.v2.VersionBuilds>(`${BASE_URL}/purpur/${mcVersion}`)
31+
return this.client.request<Purpur.Versions.v2.VersionBuilds>(`/purpur/${mcVersion}`, {
32+
api: PURPUR_BASE_URL,
33+
version: 'v2',
34+
method: 'GET',
35+
skipAuth: true,
36+
})
2937
}
3038
}

packages/ui/src/components/base/Combobox.vue

Lines changed: 81 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,44 @@
11
<template>
22
<div ref="containerRef" class="relative inline-block w-full">
33
<!-- Searchable mode: input trigger -->
4-
<StyledInput
5-
v-if="searchable"
6-
ref="searchTriggerRef"
7-
v-model="searchQuery"
8-
:icon="showSearchIcon ? SearchIcon : undefined"
9-
type="text"
10-
:placeholder="searchPlaceholder || placeholder"
11-
:disabled="disabled"
12-
wrapper-class="w-full"
13-
:input-class="showChevron ? '!pr-9' : undefined"
14-
class="relative"
15-
@input="handleSearchInput"
16-
@keydown="handleSearchKeydown"
17-
@focus="handleSearchFocus"
18-
@click="handleSearchClick"
19-
>
20-
<template v-if="showChevron" #right>
21-
<ChevronLeftIcon
22-
class="pointer-events-none absolute right-3 top-1/2 size-5 -translate-y-1/2 text-secondary transition-transform duration-150"
23-
:class="isOpen ? (openDirection === 'down' ? 'rotate-90' : '-rotate-90') : '-rotate-90'"
24-
/>
25-
</template>
26-
</StyledInput>
4+
<div v-if="searchable" class="relative w-full rounded-xl bg-surface-4">
5+
<!--
6+
Selection mirror: horizontal padding must match StyledInput (filled + left icon uses `pl-10`,
7+
else `pl-3`) and `searchableInputClass` when the chevron is shown (`!pr-9`), or the overlay
8+
text will not line up with the transparent input text / caret.
9+
-->
10+
<div
11+
v-if="searchSelectionOverlayVisible"
12+
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"
13+
:class="[showSearchIcon ? 'pl-10' : 'pl-3', showChevron ? 'pr-9' : 'pr-3']"
14+
aria-hidden="true"
15+
>
16+
<span class="min-w-0 truncate">{{ searchQuery }}</span>
17+
<slot name="search-selection-affix" :option="selectedOption" />
18+
</div>
19+
<StyledInput
20+
ref="searchTriggerRef"
21+
v-model="searchQuery"
22+
:icon="showSearchIcon ? SearchIcon : undefined"
23+
type="text"
24+
:placeholder="searchPlaceholder || placeholder"
25+
:disabled="disabled"
26+
wrapper-class="w-full !bg-transparent"
27+
:input-class="searchableInputClass"
28+
class="relative z-[1]"
29+
@input="handleSearchInput"
30+
@keydown="handleSearchKeydown"
31+
@focus="handleSearchFocus"
32+
@click="handleSearchClick"
33+
>
34+
<template v-if="showChevron" #right>
35+
<ChevronLeftIcon
36+
class="pointer-events-none absolute right-3 top-1/2 size-5 -translate-y-1/2 text-secondary transition-transform duration-150"
37+
:class="isOpen ? (openDirection === 'down' ? 'rotate-90' : '-rotate-90') : '-rotate-90'"
38+
/>
39+
</template>
40+
</StyledInput>
41+
</div>
2742

2843
<!-- Standard mode: button trigger -->
2944
<span
@@ -108,9 +123,14 @@
108123
:class="getOptionClasses(item, index)"
109124
tabindex="-1"
110125
@click="handleOptionClick(item, index)"
111-
@mouseenter="!item.disabled && (focusedIndex = index)"
126+
@mouseenter="handleOptionMouseEnter(item, index)"
112127
>
113-
<slot :name="`option-${item.value}`" :item="item">
128+
<slot
129+
name="option"
130+
:item="item"
131+
:index="index"
132+
:is-selected="!!(listbox && item.value === modelValue)"
133+
>
114134
<div class="flex w-full items-center justify-between gap-2">
115135
<div class="flex items-center gap-2">
116136
<component :is="item.icon" v-if="item.icon" class="h-5 w-5" />
@@ -151,7 +171,16 @@
151171
<script setup lang="ts" generic="T">
152172
import { ChevronLeftIcon, SearchIcon } from '@modrinth/assets'
153173
import { onClickOutside } from '@vueuse/core'
154-
import { type Component, computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
174+
import {
175+
type Component,
176+
computed,
177+
nextTick,
178+
onMounted,
179+
onUnmounted,
180+
ref,
181+
useSlots,
182+
watch,
183+
} from 'vue'
155184
156185
import StyledInput from './StyledInput.vue'
157186
@@ -223,11 +252,14 @@ const props = withDefaults(
223252
const emit = defineEmits<{
224253
'update:modelValue': [value: T]
225254
select: [option: ComboboxOption<T>]
255+
'option-hover': [option: ComboboxOption<T>]
226256
open: []
227257
close: []
228258
searchInput: [query: string]
229259
}>()
230260
261+
const slots = useSlots()
262+
231263
const isOpen = ref(false)
232264
const searchQuery = ref('')
233265
const userHasTyped = ref(false)
@@ -261,6 +293,23 @@ const selectedOption = computed<ComboboxOption<T> | undefined>(() => {
261293
)
262294
})
263295
296+
/** Extra content (e.g. channel pill) next to the label while the search field is idle */
297+
const searchSelectionOverlayVisible = computed(() => {
298+
if (!props.searchable || !props.syncWithSelection || !selectedOption.value) return false
299+
if (!slots['search-selection-affix']) return false
300+
if (isOpen.value || userHasTyped.value) return false
301+
return true
302+
})
303+
304+
const searchableInputClass = computed(() => {
305+
const parts = ['!bg-transparent']
306+
if (props.showChevron) parts.push('!pr-9')
307+
if (searchSelectionOverlayVisible.value) {
308+
parts.push('!text-transparent [caret-color:var(--color-text-primary)] selection:bg-transparent')
309+
}
310+
return parts.join(' ')
311+
})
312+
264313
const triggerText = computed(() => {
265314
if (props.displayValue !== undefined) return props.displayValue
266315
if (selectedOption.value) return selectedOption.value.label
@@ -446,6 +495,12 @@ function handleOptionClick(option: ComboboxOption<T>, index: number) {
446495
}
447496
}
448497
498+
function handleOptionMouseEnter(option: ComboboxOption<T>, index: number) {
499+
if (option.disabled) return
500+
focusedIndex.value = index
501+
emit('option-hover', option)
502+
}
503+
449504
function findNextFocusableOption(currentIndex: number, direction: 'next' | 'previous'): number {
450505
const length = filteredOptions.value.length
451506
let index = currentIndex
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<template>
2+
<span
3+
v-if="channel === 'ALPHA'"
4+
class="rounded-full bg-bg-red px-2 text-sm font-bold text-red"
5+
:class="{ 'shrink-0': affix }"
6+
>
7+
{{ formatMessage(commonMessages.alpha) }}
8+
</span>
9+
<span
10+
v-else-if="channel === 'BETA'"
11+
class="rounded-full bg-bg-orange px-2 text-sm font-bold text-orange"
12+
:class="{ 'shrink-0': affix }"
13+
>
14+
{{ formatMessage(commonMessages.beta) }}
15+
</span>
16+
</template>
17+
18+
<script setup lang="ts">
19+
import { useVIntl } from '#ui/composables/i18n'
20+
import { commonMessages } from '#ui/utils/common-messages'
21+
22+
defineProps<{
23+
channel: 'ALPHA' | 'BETA' | null | undefined
24+
/** When true, prevents the badge from shrinking in flex rows (e.g. search field affix). */
25+
affix?: boolean
26+
}>()
27+
28+
const { formatMessage } = useVIntl()
29+
</script>

0 commit comments

Comments
 (0)