Files
Starlight_Lancher/apps/app-frontend/src/pages/Browse.vue
T
AxTps 9b75ca4f3f
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
fix: 未配置 CurseForge API Key 时浏览页仍显示导航栏
此前在世界地图浏览(projectType=world)且未配置 CurseForge API Key
时,整个 BrowsePageLayout 被 v-if 隐藏,导致选择栏(整合包/模组/
资源包/…/服务器/收藏夹)与搜索栏全部空白。

现在始终渲染 BrowsePageLayout,仅在其结果区上方(#above-results)
于该场景下显示“地图不可用 / 请配置 CurseForge API Key”的提示,导航
与搜索栏保持可见。
2026-09-30 21:31:07 +08:00

3233 lines
100 KiB
Vue

<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
BookmarkFilledIcon,
BookmarkIcon,
CheckIcon,
ChevronDownIcon,
ClipboardCopyIcon,
CurseForgeIcon,
ExternalIcon,
FileArchiveIcon,
GenericListIcon,
GlobeIcon,
GridIcon,
LanguagesIcon,
ListIcon,
ModrinthIcon,
PlusIcon,
SparklesIcon,
SpinnerIcon,
} from '@modrinth/assets'
import type {
BrowseDisplayMode,
BrowseDisplayModeOption,
BrowseInstallContentType,
CardAction,
ProjectType,
Tags,
} from '@modrinth/ui'
import {
BrowsePageLayout,
BrowseSidebar,
ButtonStyled,
commonMessages,
CreationFlowModal,
defineMessages,
EmptyState,
getLatestMatchingInstallVersion,
getSelectedInstallPreferences,
getTargetInstallPreferences,
injectNotificationManager,
isVersionTypeMatch,
PopoutMenu,
preferencesDiffer,
provideBrowseManager,
requestInstall,
stripServerRuntimeInstallFilters,
stripServerRuntimeInstallOverrides,
useBrowseSearch,
useDebugLogger,
usesTargetGameVersion,
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { computed, nextTick, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'
import type { LocationQuery } from 'vue-router'
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
import BrowseInstanceSelector from '@/components/browse/BrowseInstanceSelector.vue'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
import { useAppServerBrowse } from '@/composables/browse/use-app-server-browse'
import { useContentFavorites } from '@/composables/useContentFavorites'
import { useNetworkStatus } from '@/composables/useNetworkStatus'
import { useTranslationToggle } from '@/composables/useTranslationToggle'
import {
type BrowseFilterMemory,
getBrowseFilterMemory,
setBrowseFilterMemory,
} from '@/helpers/browse-filter-memory'
import { mergeProviderResults } from '@/helpers/browse-merge'
import { createBrowseProjectTabs, getBrowseProjectTabOptions } from '@/helpers/browse-project-tabs'
import {
completeBrowseReturnNavigation,
consumeBrowseReturnSnapshot,
isBrowseReturnSourcePath,
saveBrowseReturnSnapshot,
} from '@/helpers/browse-return-state.ts'
import {
cancel_search_request,
get_project,
get_project_v3,
get_project_v3_many,
get_search_results_v3,
get_version_many,
} from '@/helpers/cache.js'
import { type FavoriteContentType, isFavoriteContentType } from '@/helpers/content-favorites'
import {
bilingualTitle,
type ChineseSearchResolution,
type ChineseSearchTranslation,
containsChineseSearchText,
expandContentSearchQuery,
resolveChineseContentSearch,
translateSearchHitTitles,
} from '@/helpers/content-search'
import {
type CurseForgeCategory,
getCurseForgeCapability,
getCurseForgeCategories,
getCurseForgeFiles,
getCurseForgeImageUrl,
searchCurseForgeProjects,
type UnifiedSearchHit,
} from '@/helpers/curseforge'
import {
CF_EXTRA_CATEGORY_HEADER,
curseForgeCategoryValue,
findUnmappedCurseForgeCategories,
isCurseForgeOnlyCategoryName,
localizeCurseForgeCategoryName,
localizeCurseForgeLabel,
resolveCurseForgeCategoryIdsFromFilterValues,
} from '@/helpers/curseforge-category-map'
import { instance_listener } from '@/helpers/events.js'
import {
get as getInstance,
get_installed_project_ids as getInstalledProjectIds,
} from '@/helpers/instance'
import { getDisplayInstanceIcon } from '@/helpers/instance-icons'
import {
getMcArchiveGameVersions,
type McArchiveGameVersion,
type McArchiveMod,
searchMcArchiveMods,
} from '@/helpers/mcarchive'
import { get_loader_versions as getLoaderManifest } from '@/helpers/metadata'
import {
planetMinecraftConnectorAvailable,
type PlanetMinecraftProject,
searchPlanetMinecraftProjects,
} from '@/helpers/planet-minecraft'
import {
curseForgeQueryVariants,
expandSearchQuery,
normalizeSearchText,
} from '@/helpers/search-query'
import {
type BrowseContentSource,
get as getSettings,
getLastBrowseContentDisplayMode,
getLastBrowseContentSource,
isBrowseContentProjectType,
set as setSettings,
setLastBrowseContentDisplayMode,
setLastBrowseContentProjectType,
setLastBrowseContentSource,
} from '@/helpers/settings.ts'
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
import { translateSearchDescriptions } from '@/helpers/translation'
import type { GameInstance } from '@/helpers/types'
import { get_instance_worlds } from '@/helpers/worlds'
import i18n from '@/i18n.config'
import { injectContentInstall } from '@/providers/content-install'
import { injectContentSelection, makeContentSelectionKey } from '@/providers/content-selection'
import { injectServerInstall } from '@/providers/server-install'
import {
createServerInstallContent,
provideServerInstallContent,
} from '@/providers/setup/server-install-content'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { useTheming } from '@/store/state'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const { installingServerProjects, playServerProject, showAddServerToInstanceModal } =
injectServerInstall()
const { install: installVersion, installCurseForge } = injectContentInstall()
const contentSelection = injectContentSelection()
const contentFavorites = useContentFavorites()
const queryClient = useQueryClient()
const debugLog = useDebugLogger('Browse')
const router = useRouter()
const route = useRoute()
void contentFavorites.load().catch(handleError)
const projectType = ref<ProjectType>(route.params.projectType as ProjectType)
const WORLD_BROWSE_PROJECT_TYPE = 'world' as ProjectType
const isWorldMapBrowse = computed(() => projectType.value === WORLD_BROWSE_PROJECT_TYPE)
const isWorldMapContext = computed(() => route.query.from === 'world-maps')
function rememberBrowseContentProjectType(type: ProjectType) {
if (
!route.query.i &&
!route.query.sid &&
!route.query.wid &&
!route.query.from &&
isBrowseContentProjectType(type)
) {
setLastBrowseContentProjectType(type)
}
}
watch(projectType, rememberBrowseContentProjectType, { immediate: true })
const curseForgeClassIds: Partial<Record<ProjectType, number>> = {
mod: 6,
plugin: 5,
resourcepack: 12,
datapack: 6945,
shader: 6552,
modpack: 4471,
[WORLD_BROWSE_PROJECT_TYPE]: 17,
}
const [initialCurseForgeCapability, initialPlanetMinecraftAvailable] = await Promise.all([
getCurseForgeCapability().catch(() => ({
status: 'missing_key' as const,
configured: false,
})),
planetMinecraftConnectorAvailable().catch(() => false),
])
const curseForgeCapability = ref(initialCurseForgeCapability)
const planetMinecraftAvailable = ref(initialPlanetMinecraftAvailable)
const rememberedContentSource = getLastBrowseContentSource()
function resolveInitialContentSource(): BrowseContentSource {
if (route.params.projectType === WORLD_BROWSE_PROJECT_TYPE) {
return 'curseforge'
}
if (route.params.projectType === 'mod' && route.query.source === 'mcarchive') {
return 'mcarchive'
}
if (
route.params.projectType === 'mod' &&
route.query.source === 'planet_minecraft' &&
planetMinecraftAvailable.value
) {
return 'planet_minecraft'
}
if (curseForgeCapability.value.configured && route.query.source === 'curseforge') {
return 'curseforge'
}
if (route.query.source === 'modrinth') {
return 'modrinth'
}
if (rememberedContentSource === 'curseforge' && curseForgeCapability.value.configured) {
return 'curseforge'
}
if (rememberedContentSource === 'modrinth') {
return 'modrinth'
}
if (rememberedContentSource === 'mcarchive' && route.params.projectType === 'mod') {
return 'mcarchive'
}
if (
rememberedContentSource === 'planet_minecraft' &&
route.params.projectType === 'mod' &&
planetMinecraftAvailable.value
) {
return 'planet_minecraft'
}
return curseForgeCapability.value.configured ? 'all' : 'modrinth'
}
const contentSource = ref<BrowseContentSource>(resolveInitialContentSource())
const sourceBeforeWorldMapBrowse = ref<BrowseContentSource>(
isWorldMapBrowse.value ? (getLastBrowseContentSource() ?? 'all') : contentSource.value,
)
if (isWorldMapBrowse.value) {
contentSource.value = 'curseforge'
}
const curseForgeCategoriesByClass = ref<Record<number, CurseForgeCategory[]>>({})
async function ensureCurseForgeCategories(projectTypeValue: ProjectType) {
const classId = curseForgeClassIds[projectTypeValue]
if (!classId || curseForgeCategoriesByClass.value[classId]) return
const classCategories = await getCurseForgeCategories(classId)
curseForgeCategoriesByClass.value = {
...curseForgeCategoriesByClass.value,
[classId]: classCategories,
}
}
const initialCurseForgeCategoriesPromise =
curseForgeCapability.value.configured &&
(contentSource.value === 'curseforge' || contentSource.value === 'all')
? ensureCurseForgeCategories(projectType.value).catch(handleError)
: Promise.resolve()
if (route.query.f || route.query.g) await initialCurseForgeCategoriesPromise
const themeStore = useTheming()
const serverSetupModalRef = ref<InstanceType<typeof CreationFlowModal> | null>(null)
const serverInstallContent = createServerInstallContent({ serverSetupModalRef })
provideServerInstallContent(serverInstallContent)
const {
serverIdQuery,
serverFlowFrom,
isFromWorlds,
isServerContext,
isSetupServerContext,
effectiveServerWorldId,
serverContextServerData,
serverContentProjectIds,
queuedServerInstallProjectIds,
queuedServerInstallCount,
selectedServerInstallProjects,
isInstallingQueuedServerInstalls,
queuedInstallProgress,
serverBackUrl,
serverBackLabel,
serverBrowseHeading,
clearQueuedServerInstalls,
removeQueuedServerInstall,
flushQueuedServerInstalls,
discardQueuedServerInstallsAndBack,
installQueuedServerInstallsAndBack,
initServerContext,
watchServerContextChanges,
searchServerModpacks,
getServerProjectVersions,
enforceSetupModpackRoute,
getQueuedServerInstallPlans,
setQueuedServerInstallPlans,
openServerModpackInstallFlow,
onServerFlowBack,
handleServerModpackFlowCreate,
markServerProjectInstalled,
} = serverInstallContent
debugLog('fetching tags (categories, loaders, gameVersions)')
let categories: Ref<Labrinth.Tags.v2.Category[]> = ref([])
let loaders: Ref<Labrinth.Tags.v2.Loader[]> = ref([])
let availableGameVersions: Ref<Labrinth.Tags.v2.GameVersion[]> = ref([])
const mcArchiveGameVersions = ref<McArchiveGameVersion[]>([])
if (!isWorldMapBrowse.value) {
;[categories, loaders, availableGameVersions] = await Promise.all([
get_categories()
.catch(handleError)
.then(ref<Labrinth.Tags.v2.Category[]>),
get_loaders()
.catch(handleError)
.then(ref<Labrinth.Tags.v2.Loader[]>),
get_game_versions()
.catch(handleError)
.then(ref<Labrinth.Tags.v2.GameVersion[]>),
])
}
async function refreshMcArchiveGameVersions() {
if (mcArchiveGameVersions.value.length > 0) return
mcArchiveGameVersions.value = await getMcArchiveGameVersions().catch((error) => {
handleError(error)
return []
})
}
if (contentSource.value === 'mcarchive') await refreshMcArchiveGameVersions()
const curseForgeCategoryTags = computed(() => {
const classId = curseForgeClassIds[projectType.value]
if (!classId) return []
const classCategories = curseForgeCategoriesByClass.value[classId] ?? []
const categoriesById = new Map(classCategories.map((category) => [category.id, category]))
return classCategories
.filter((category) => !category.isClass)
.map((category) => {
const parent = category.parentCategoryId
? categoriesById.get(category.parentCategoryId)
: undefined
const isResolution =
classId === 12 && category.displayIndex != null && category.displayIndex < 0
const displayName = localizeCurseForgeCategoryName(category)
const header = isResolution
? 'resolutions'
: parent && parent.id !== classId
? parent.slug
: 'categories'
const headerDisplayName =
header === 'resolutions' || header === 'categories'
? undefined
: localizeCurseForgeLabel(parent?.slug, parent?.name, header)
return {
icon: getCurseForgeImageUrl(category.iconUrl, 32) ?? '',
icon_url: getCurseForgeImageUrl(category.iconUrl, 32),
name: category.slug,
display_name: displayName,
header_display_name: headerDisplayName,
display_index: category.displayIndex,
project_type: projectType.value,
header,
}
})
})
const allSourceCategoryTags = computed(() => {
const classId = curseForgeClassIds[projectType.value]
const modrinthCategories = categories.value ?? []
if (!classId) return modrinthCategories
const classCategories = curseForgeCategoriesByClass.value[classId] ?? []
if (classCategories.length === 0) return modrinthCategories
const unmapped = findUnmappedCurseForgeCategories(
modrinthCategories.map((category) => category.name),
classCategories,
)
const extraTags = unmapped.map((category) => ({
icon: getCurseForgeImageUrl(category.iconUrl, 32) ?? '',
icon_url: getCurseForgeImageUrl(category.iconUrl, 32),
name: curseForgeCategoryValue(category.id),
display_name: localizeCurseForgeCategoryName(category),
display_index: category.displayIndex,
project_type: projectType.value,
header: CF_EXTRA_CATEGORY_HEADER,
}))
return [...modrinthCategories, ...extraTags]
})
const tags: Ref<Tags> = computed(() => ({
gameVersions:
contentSource.value === 'mcarchive'
? mcArchiveGameVersions.value.map((version) => ({
version: version.name,
version_type: version.versionType ?? 'release',
date: '',
major: false,
}))
: (availableGameVersions.value ?? []),
loaders:
contentSource.value === 'mcarchive' || contentSource.value === 'planet_minecraft'
? []
: (loaders.value ?? []),
categories:
contentSource.value === 'mcarchive' || contentSource.value === 'planet_minecraft'
? []
: contentSource.value === 'curseforge'
? curseForgeCategoryTags.value
: contentSource.value === 'all'
? allSourceCategoryTags.value
: (categories.value ?? []),
}))
const instance = ref<GameInstance | null>(null)
const instanceSelector = ref()
const pendingRouteInstanceSwitch = ref<GameInstance | null>(null)
const activeInstance = computed(() => contentSelection.targetInstance.value ?? instance.value)
const mcArchiveAvailable = computed(() => {
const gameVersion = activeInstance.value?.game_version
if (!gameVersion) return false
const metadata = availableGameVersions.value.find((version) => version.version === gameVersion)
return metadata ? isVersionTypeMatch(metadata.version_type, gameVersion, 'ancient') : false
})
const installedProjectIds = ref<string[] | null>(null)
const installedProjectIdsInstanceId = ref<string | null>(null)
let installedProjectRequestId = 0
const instanceHideInstalled = ref(false)
const newlyInstalled = ref<string[]>([])
const hiddenInstanceProjectIds = ref<Set<string>>(new Set())
const hiddenInstanceProjectIdsInitialized = ref(false)
const isServerInstance = ref(false)
if (isFromWorlds.value && route.params.projectType !== 'server') {
router.replace({
path: '/browse/server',
query: route.query,
})
}
enforceSetupModpackRoute(route.params.projectType as string | undefined)
const scopedInstalledProjectIds = computed(() =>
installedProjectIdsInstanceId.value === activeInstance.value?.id
? (installedProjectIds.value ?? [])
: [],
)
const allInstalledIds = computed(
() => new Set([...newlyInstalled.value, ...scopedInstalledProjectIds.value]),
)
function syncHiddenInstanceProjectIds() {
hiddenInstanceProjectIds.value = new Set([
...scopedInstalledProjectIds.value,
...newlyInstalled.value,
])
hiddenInstanceProjectIdsInitialized.value = true
}
watch(
installedProjectIds,
(ids) => {
if (!ids) return
if (!hiddenInstanceProjectIdsInitialized.value) {
syncHiddenInstanceProjectIds()
}
},
{ immediate: true },
)
watchServerContextChanges()
let initialInstanceFilterPromise: Promise<void> = Promise.resolve()
let initialInstalledProjectsPromise: Promise<void> = Promise.resolve()
await initInstanceContext()
async function refreshInstalledProjectIds() {
const requestId = ++installedProjectRequestId
const instanceId = activeInstance.value?.id
if (!instanceId) {
installedProjectIds.value = null
installedProjectIdsInstanceId.value = null
return
}
const requestInstanceId = instanceId
if (installedProjectIdsInstanceId.value !== requestInstanceId) {
installedProjectIds.value = null
installedProjectIdsInstanceId.value = null
hiddenInstanceProjectIds.value = new Set()
hiddenInstanceProjectIdsInitialized.value = false
}
if (route.query.from === 'worlds') {
const worlds = await get_instance_worlds(requestInstanceId).catch(handleError)
if (!worlds) return
if (requestId !== installedProjectRequestId || activeInstance.value?.id !== requestInstanceId)
return
const serverProjectIds = worlds
.filter((w) => w.type === 'server' && 'project_id' in w && w.project_id)
.map((w) => (w as { project_id: string }).project_id)
debugLog('installedServerProjectIds loaded', { count: serverProjectIds.length })
installedProjectIds.value = serverProjectIds
installedProjectIdsInstanceId.value = requestInstanceId
return
}
const ids = await getInstalledProjectIds(requestInstanceId).catch(handleError)
if (
ids &&
requestId === installedProjectRequestId &&
activeInstance.value?.id === requestInstanceId
) {
debugLog('installedProjectIds loaded', { count: ids.length })
installedProjectIds.value = ids
installedProjectIdsInstanceId.value = requestInstanceId
await contentSelection.refreshInstalledIdentities()
}
}
async function initInstanceContext() {
debugLog('initInstanceContext', {
queryI: route.query.i,
queryAi: route.query.ai,
querySid: route.query.sid,
queryWid: route.query.wid,
queryFrom: route.query.from,
})
await initServerContext()
await contentSelection.refreshInstances((route.query.i as string | undefined) ?? undefined)
if (route.query.i) {
instance.value = (await getInstance(route.query.i as string).catch(handleError)) ?? null
if (
instance.value?.install_stage === 'installed' &&
(contentSelection.selectedCount.value === 0 ||
contentSelection.targetInstance.value?.id === instance.value.id)
) {
contentSelection.setTarget(instance.value)
} else if (
instance.value?.install_stage === 'installed' &&
contentSelection.selectedCount.value > 0 &&
contentSelection.targetInstance.value?.id !== instance.value.id
) {
pendingRouteInstanceSwitch.value = instance.value
}
debugLog('instance loaded', {
name: instance.value?.name,
loader: instance.value?.loader,
gameVersion: instance.value?.game_version,
})
if (instance.value?.link?.project_id) {
debugLog('checking linked project for server status', instance.value.link.project_id)
initialInstanceFilterPromise = get_project_v3(
instance.value.link.project_id,
'must_revalidate',
)
.then((projectV3) => {
if (projectV3?.minecraft_server != null) {
debugLog('instance is a server instance')
isServerInstance.value = true
}
})
.catch((error) => handleError(error))
}
}
if (!instance.value && activeInstance.value?.link?.project_id) {
initialInstanceFilterPromise = get_project_v3(
activeInstance.value.link.project_id,
'must_revalidate',
)
.then((projectV3) => {
isServerInstance.value = projectV3?.minecraft_server != null
})
.catch((error) => handleError(error))
}
if (route.query.ai && !(route.params.projectType === 'modpack')) {
debugLog('setting instanceHideInstalled from query', route.query.ai)
instanceHideInstalled.value = route.query.ai === 'true'
}
initialInstalledProjectsPromise = refreshInstalledProjectIds()
}
const instanceFilters = computed(() => {
const filters = []
if (activeInstance.value) {
const gameVersion = activeInstance.value.game_version
if (gameVersion && usesTargetGameVersion(projectType.value)) {
filters.push({ type: 'game_version', option: gameVersion })
}
const platform = activeInstance.value.loader
const supportedModLoaders = ['fabric', 'forge', 'quilt', 'neoforge']
if (platform && projectType.value === 'mod' && supportedModLoaders.includes(platform)) {
filters.push({ type: 'mod_loader', option: platform })
}
if (isServerInstance.value) {
filters.push({ type: 'environment', option: 'client' })
}
if (instanceHideInstalled.value && hiddenInstanceProjectIds.value.size > 0) {
for (const id of hiddenInstanceProjectIds.value) {
filters.push({ type: 'project_id', option: `project_id:${id}`, negative: true })
}
}
}
return filters
})
const serverHideInstalled = ref(false)
const hideSelectedServerInstalls = ref(false)
if (route.query.shi) {
serverHideInstalled.value = route.query.shi === 'true'
}
const hiddenServerContentProjectIds = ref<Set<string>>(new Set())
const hiddenServerContentProjectIdsInitialized = ref(false)
function syncHiddenServerContentProjectIds() {
hiddenServerContentProjectIds.value = new Set(serverContentProjectIds.value)
hiddenServerContentProjectIdsInitialized.value = true
}
watch(
serverContentProjectIds,
() => {
if (!hiddenServerContentProjectIdsInitialized.value) {
syncHiddenServerContentProjectIds()
}
},
{ immediate: true },
)
const serverContextFilters = computed(() => {
const filters: { type: string; option: string; negative?: boolean }[] = []
if (!serverContextServerData.value) return filters
const pt = projectType.value
if (pt !== 'modpack') {
const gameVersion = serverContextServerData.value.mc_version
if (gameVersion && usesTargetGameVersion(pt)) {
filters.push({ type: 'game_version', option: gameVersion })
}
const platform = serverContextServerData.value.loader?.toLowerCase()
if (platform && ['fabric', 'forge', 'quilt', 'neoforge'].includes(platform))
filters.push({ type: 'mod_loader', option: platform })
if (platform && ['paper', 'purpur'].includes(platform))
filters.push({ type: 'plugin_loader', option: platform })
if (pt === 'mod') filters.push({ type: 'environment', option: 'server' })
if (hideSelectedServerInstalls.value && queuedServerInstallProjectIds.value.size > 0) {
for (const id of queuedServerInstallProjectIds.value) {
filters.push({ type: 'project_id', option: `project_id:${id}`, negative: true })
}
}
}
if (pt === 'modpack') {
filters.push(
{ type: 'environment', option: 'client' },
{ type: 'environment', option: 'server' },
)
}
if (serverHideInstalled.value && hiddenServerContentProjectIds.value.size > 0) {
for (const id of hiddenServerContentProjectIds.value) {
filters.push({ type: 'project_id', option: `project_id:${id}`, negative: true })
}
}
return filters
})
const combinedProvidedFilters = computed(() => {
if (contentSource.value === 'mcarchive') return []
if (isServerContext.value) return serverContextFilters.value
if (projectType.value === 'modpack' || projectType.value === 'server') return []
return instanceFilters.value
})
const {
serverPings,
contextMenuRef,
updateServerHits,
getServerModpackContent,
getServerCardActions,
handleRightClick,
handleOptionsClick,
} = useAppServerBrowse({
instance,
isFromWorlds,
allInstalledIds,
newlyInstalled,
installingServerProjects,
playServerProject,
showAddServerToInstanceModal,
handleError,
router,
})
const { offline } = useNetworkStatus()
const messages = defineMessages({
add: {
id: 'app.browse.add',
defaultMessage: 'Add',
},
chooseInstance: {
id: 'app.browse.choose-instance',
defaultMessage: 'Choose instance',
},
installSelected: {
id: 'app.browse.install-selected',
defaultMessage: 'Install {count} content',
},
preparingSelected: {
id: 'app.browse.preparing-selected',
defaultMessage: 'Preparing {completed}/{total}',
},
selected: {
id: 'app.browse.selected',
defaultMessage: 'Selected',
},
addToFavorites: {
id: 'app.content-favorites.add',
defaultMessage: 'Add to favorites',
},
removeFromFavorites: {
id: 'app.content-favorites.remove',
defaultMessage: 'Remove from favorites',
},
noCompatibleVersion: {
id: 'app.browse.no-compatible-version',
defaultMessage: 'No compatible version was found for the selected instance.',
},
addServersToInstance: {
id: 'app.browse.add-servers-to-instance',
defaultMessage: 'Adding server to instance',
},
addToAnInstance: {
id: 'app.browse.add-to-an-instance',
defaultMessage: 'Add to an instance',
},
discoverContent: {
id: 'app.browse.discover-content',
defaultMessage: 'Discover content',
},
discoverServers: {
id: 'app.browse.discover-servers',
defaultMessage: 'Discover servers',
},
discoverMaps: {
id: 'app.browse.discover-maps',
defaultMessage: 'Discover maps',
},
mapsProjectType: {
id: 'app.browse.project-type.maps',
defaultMessage: 'Maps',
},
mapsUnavailable: {
id: 'app.browse.maps-unavailable',
defaultMessage: 'Maps unavailable',
},
mapsUnavailableDescription: {
id: 'app.browse.maps-unavailable-description',
defaultMessage: 'Configure a CurseForge API key to browse and install maps.',
},
mapsNoInstallableFile: {
id: 'app.browse.maps-no-installable-file',
defaultMessage: 'The selected CurseForge map does not have an installable file.',
},
fuzzySearchPrefix: {
id: 'app.browse.fuzzy-search.prefix',
defaultMessage: 'No results found for “{original}”. Showing results for',
},
fuzzySearchSuffix: {
id: 'app.browse.fuzzy-search.suffix',
defaultMessage: 'instead',
},
environmentProvidedByServer: {
id: 'search.filter.locked.server-environment.title',
defaultMessage: 'Only client-side mods can be added to the server instance',
},
gameVersionProvidedByInstance: {
id: 'search.filter.locked.instance-game-version.title',
defaultMessage: 'Game version is provided by the instance',
},
gameVersionProvidedByServer: {
id: 'search.filter.locked.server-game-version.title',
defaultMessage: 'Game version is provided by the server',
},
hideAddedServers: {
id: 'app.browse.hide-added-servers',
defaultMessage: 'Hide servers already added',
},
installingToServer: {
id: 'app.browse.server.installing',
defaultMessage: 'Installing',
},
backToInstance: {
id: 'app.browse.back-to-instance',
defaultMessage: 'Back to instance',
},
serverInstanceContentWarning: {
id: 'app.browse.server-instance-content-warning',
defaultMessage:
'Adding content can break compatibility when joining the server. Any added content will also be lost when you update the server instance content.',
},
modLoaderProvidedByInstance: {
id: 'search.filter.locked.instance-loader.title',
defaultMessage: 'Loader is provided by the instance',
},
modpacksProjectType: {
id: 'app.browse.project-type.modpacks',
defaultMessage: 'Modpacks',
},
modsProjectType: {
id: 'app.browse.project-type.mods',
defaultMessage: 'Mods',
},
resourcepacksProjectType: {
id: 'app.browse.project-type.resourcepacks',
defaultMessage: 'Resource Packs',
},
datapacksProjectType: {
id: 'app.browse.project-type.datapacks',
defaultMessage: 'Data Packs',
},
shadersProjectType: {
id: 'app.browse.project-type.shaders',
defaultMessage: 'Shaders',
},
serversProjectType: {
id: 'app.browse.project-type.servers',
defaultMessage: 'Servers',
},
favoritesProjectType: {
id: 'app.browse.project-type.favorites',
defaultMessage: 'Favorites',
},
modLoaderProvidedByServer: {
id: 'search.filter.locked.server-loader.title',
defaultMessage: 'Loader is provided by the server',
},
providedByInstance: {
id: 'search.filter.locked.instance',
defaultMessage: 'Provided by the instance',
},
providedByServer: {
id: 'search.filter.locked.server',
defaultMessage: 'Provided by the server',
},
syncFilterButton: {
id: 'search.filter.locked.instance.sync',
defaultMessage: 'Sync with instance',
},
allSources: {
id: 'app.browse.source.all',
defaultMessage: 'All sources',
},
compactListView: {
id: 'app.browse.display-mode.compact-list',
defaultMessage: 'Compact list',
},
gridView: {
id: 'app.browse.display-mode.grid',
defaultMessage: 'Grid',
},
listView: {
id: 'app.browse.display-mode.list',
defaultMessage: 'List',
},
modrinthSource: {
id: 'app.browse.source.modrinth',
defaultMessage: 'Modrinth',
},
switchView: {
id: 'app.browse.display-mode.switch',
defaultMessage: 'Switch view',
},
curseForgeSource: {
id: 'app.browse.source.curseforge',
defaultMessage: 'CurseForge',
},
mcArchiveSource: {
id: 'app.browse.source.mcarchive',
defaultMessage: 'MCArchive',
},
planetMinecraftSource: {
id: 'app.browse.source.planet-minecraft',
defaultMessage: 'Planet Minecraft',
},
translateProject: {
id: 'app.project.translation.translate',
defaultMessage: 'Translate',
},
showOriginal: {
id: 'app.project.translation.show-original',
defaultMessage: 'Show original',
},
translating: {
id: 'app.project.translation.translating',
defaultMessage: 'Translating…',
},
})
const sourceIcon = computed(() => {
if (!sourceOptions.value.some((option) => option.id === contentSource.value)) {
return GlobeIcon
}
switch (contentSource.value) {
case 'modrinth':
return ModrinthIcon
case 'curseforge':
return CurseForgeIcon
case 'mcarchive':
return FileArchiveIcon
case 'planet_minecraft':
return GlobeIcon
default:
return GlobeIcon
}
})
const sourceOptions = computed(() =>
isWorldMapBrowse.value
? [{ id: 'curseforge' as const, label: messages.curseForgeSource, icon: CurseForgeIcon }]
: [
{ id: 'all' as const, label: messages.allSources, icon: GlobeIcon },
{ id: 'modrinth' as const, label: messages.modrinthSource, icon: ModrinthIcon },
...(curseForgeCapability.value.configured
? [{ id: 'curseforge' as const, label: messages.curseForgeSource, icon: CurseForgeIcon }]
: []),
...(projectType.value === 'mod' && mcArchiveAvailable.value
? [{ id: 'mcarchive' as const, label: messages.mcArchiveSource, icon: FileArchiveIcon }]
: []),
...(projectType.value === 'mod' && planetMinecraftAvailable.value
? [
{
id: 'planet_minecraft' as const,
label: messages.planetMinecraftSource,
icon: GlobeIcon,
},
]
: []),
],
)
const currentSourceLabel = computed(() => {
const current = sourceOptions.value.find((opt) => opt.id === contentSource.value)
return current?.label ?? messages.allSources
})
const breadcrumbs = useBreadcrumbs()
const browseTitle = computed(() =>
formatMessage(
isFromWorlds.value
? messages.discoverServers
: isWorldMapBrowse.value
? messages.discoverMaps
: messages.discoverContent,
),
)
breadcrumbs.setName('BrowseTitle', browseTitle.value)
if (instance.value) {
const instanceLink = `/instance/${encodeURIComponent(instance.value.id)}`
const instanceIcon = getDisplayInstanceIcon(instance.value.icon_path, instance.value.loader).url
breadcrumbs.setContext({
name: instance.value.name,
link: isFromWorlds.value || isWorldMapContext.value ? `${instanceLink}/worlds` : instanceLink,
iconUrl: instanceIcon,
})
} else {
breadcrumbs.setContext(null)
}
onBeforeRouteLeave((to) => {
if (isBrowseReturnSourcePath(to.path)) {
const viewport = document.querySelector<HTMLElement>('.app-viewport')
saveBrowseReturnSnapshot({
url: route.fullPath,
scrollTop: viewport?.scrollTop ?? 0,
state: { currentPage: searchState.currentPage.value },
})
}
breadcrumbs.setContext({
name: '?BrowseTitle',
link: `/browse/${projectType.value}`,
query: route.query,
})
})
function resetInstanceContext() {
if (!instance.value) return
debugLog('instance context removed, resetting')
instance.value = null
installedProjectRequestId += 1
installedProjectIds.value = null
installedProjectIdsInstanceId.value = null
instanceHideInstalled.value = false
newlyInstalled.value = []
hiddenInstanceProjectIds.value = new Set()
hiddenInstanceProjectIdsInitialized.value = false
isServerInstance.value = false
breadcrumbs.setName('BrowseTitle', formatMessage(messages.discoverContent))
breadcrumbs.setContext(null)
}
watch(
() => route.params.projectType as ProjectType,
async (newType) => {
if (isSetupServerContext.value) {
enforceSetupModpackRoute(newType)
if (newType !== 'modpack') return
}
if (!newType || newType === projectType.value) return
debugLog('projectType route param changed', { from: projectType.value, to: newType })
projectType.value = newType
},
)
watch(
() => route.query.i,
(instanceId) => {
if (!instanceId && route.path.startsWith('/browse')) {
resetInstanceContext()
}
},
)
const selectableProjectTypes = computed(() => {
const params: LocationQuery = {}
if (route.query.i) params.i = route.query.i
if (route.query.ai) params.ai = route.query.ai
if (route.query.from) params.from = route.query.from
if (route.query.sid) params.sid = route.query.sid
if (effectiveServerWorldId.value) params.wid = effectiveServerWorldId.value
const queryString = new URLSearchParams(params as Record<string, string>).toString()
const suffix = queryString ? `?${queryString}` : ''
if (isSetupServerContext.value) {
return [
{ label: formatMessage(messages.modpacksProjectType), href: `/browse/modpack${suffix}` },
]
}
if (isFromWorlds.value) {
return [{ label: formatMessage(messages.serversProjectType), href: `/browse/server${suffix}` }]
}
if (isWorldMapContext.value) {
return [
{
label: formatMessage(messages.mapsProjectType),
href: `/browse/${WORLD_BROWSE_PROJECT_TYPE}${suffix}`,
},
]
}
return createBrowseProjectTabs(
{
modpacks: formatMessage(messages.modpacksProjectType),
mods: formatMessage(messages.modsProjectType),
resourcepacks: formatMessage(messages.resourcepacksProjectType),
datapacks: formatMessage(messages.datapacksProjectType),
maps: formatMessage(messages.mapsProjectType),
shaders: formatMessage(messages.shadersProjectType),
servers: formatMessage(messages.serversProjectType),
favorites: formatMessage(messages.favoritesProjectType),
},
suffix,
getBrowseProjectTabOptions({
instance: activeInstance.value,
hasInstanceContext: !!instance.value,
isServerInstance: isServerInstance.value,
}),
)
})
const installContext = computed(() => {
if (isServerContext.value && serverContextServerData.value) {
return {
name: serverContextServerData.value.name,
loader: serverContextServerData.value.loader ?? '',
gameVersion: serverContextServerData.value.mc_version ?? '',
serverId: serverIdQuery.value,
upstream: serverContextServerData.value.upstream,
iconSrc: null as string | null,
isMedal: serverContextServerData.value.is_medal,
backUrl: serverBackUrl.value,
backLabel: serverBackLabel.value,
heading: serverBrowseHeading.value,
queuedCount: queuedServerInstallCount.value,
selectedProjects: selectedServerInstallProjects.value,
isInstallingSelected: isInstallingQueuedServerInstalls.value,
skipNonEssentialWarnings: themeStore.getFeatureFlag('skip_non_essential_warnings'),
installProgress: queuedInstallProgress.value,
clearQueued: clearQueuedServerInstalls,
clearSelected: clearQueuedServerInstalls,
onBack: flushQueuedServerInstalls,
discardSelectedAndBack: discardQueuedServerInstallsAndBack,
installSelected: installQueuedServerInstallsAndBack,
}
}
if (activeInstance.value) {
const target = activeInstance.value
const displayIcon = getDisplayInstanceIcon(target.icon_path, target.loader)
const processing = ['validating', 'reviewing', 'queueing'].includes(
contentSelection.state.value,
)
return {
showInstallHeader: !!instance.value,
name: target.name,
loader: target.loader,
gameVersion: target.game_version,
iconSrc: displayIcon.url,
iconFrameless: displayIcon.frameless,
backUrl: instance.value
? `/instance/${encodeURIComponent(instance.value.id)}${isFromWorlds.value || isWorldMapContext.value ? '/worlds' : ''}`
: route.fullPath,
backLabel: formatMessage(messages.backToInstance),
heading: formatMessage(
isFromWorlds.value ? messages.addServersToInstance : commonMessages.installingContentLabel,
),
warning:
isServerInstance.value && !isFromWorlds.value
? formatMessage(messages.serverInstanceContentWarning)
: undefined,
selectedProjects: contentSelection.selectedProjects.value,
isInstallingSelected: processing,
installProgress: contentSelection.progress.value,
installButtonLabel: formatMessage(messages.installSelected, {
count: contentSelection.selectedCount.value,
}),
processingLabel: formatMessage(messages.preparingSelected, {
completed: contentSelection.progress.value.completed,
total: contentSelection.progress.value.total,
}),
clearSelected: contentSelection.clear,
installSelected: contentSelection.installSelected,
}
}
return null
})
const installingProjectIds = ref<Set<string>>(new Set())
const CART_CONTENT_TYPES = new Set(['mod', 'resourcepack', 'datapack', 'shader', 'world'])
function projectInstallingKey(projectId: string, instanceId?: string | null) {
return `${instanceId ?? activeInstance.value?.id ?? ''}\0${projectId}`
}
function setProjectInstalling(
projectId: string,
installing: boolean,
instanceId?: string | null,
) {
const key = projectInstallingKey(projectId, instanceId)
const next = new Set(installingProjectIds.value)
if (installing) {
next.add(key)
} else {
next.delete(key)
}
installingProjectIds.value = next
}
async function selectTargetInstance(target: GameInstance) {
contentSelection.setTarget(target)
newlyInstalled.value = []
hiddenInstanceProjectIds.value = new Set()
hiddenInstanceProjectIdsInitialized.value = false
if (route.query.i) {
instance.value = target
await router.replace({ query: { ...route.query, i: target.id } })
breadcrumbs.setContext({
name: target.name,
link: `/instance/${encodeURIComponent(target.id)}`,
iconUrl: getDisplayInstanceIcon(target.icon_path, target.loader).url,
})
}
await refreshInstalledProjectIds()
await searchState.refreshSearch()
}
async function cancelTargetInstanceSwitch() {
const target = contentSelection.targetInstance.value
if (!target || !route.query.i || route.query.i === target.id) return
instance.value = target
await router.replace({ query: { ...route.query, i: target.id } })
breadcrumbs.setContext({
name: target.name,
link: `/instance/${encodeURIComponent(target.id)}`,
iconUrl: getDisplayInstanceIcon(target.icon_path, target.loader).url,
})
}
async function toggleContentSelection(
project: (Labrinth.Search.v2.ResultSearchProject & Labrinth.Search.v3.ResultSearchProject) & {
provider: BrowseContentProvider
provider_project_id?: string
latest_version?: string | null
},
contentType: string,
) {
const target = activeInstance.value
if (!target) {
instanceSelector.value?.show()
return
}
const providerProjectId =
project.provider === 'curseforge'
? (project.provider_project_id ?? project.project_id.replace(/^curseforge:/, ''))
: project.project_id
const key = makeContentSelectionKey(project.provider, providerProjectId)
if (contentSelection.isSelected(key)) {
contentSelection.remove(key)
return
}
setProjectInstalling(project.project_id, true, target.id)
try {
const preferences = getInstanceInstallTargetPreferences(contentType)
let versionId = project.latest_version || null
if (project.provider === 'modrinth') {
versionId =
getLatestMatchingInstallVersion(
await getInstallProjectVersions(project.project_id),
preferences,
)?.id ?? null
} else {
const files = await getCurseForgeFiles(Number(providerProjectId), {
gameVersion: usesTargetGameVersion(contentType) ? target.game_version : undefined,
modLoaderType: contentType === 'mod' ? curseForgeLoaderTypes[target.loader] : undefined,
})
versionId = files.files.find((file) => file.isAvailable)?.id.toString() ?? null
}
if (!versionId) {
throw new Error(
contentType === WORLD_BROWSE_PROJECT_TYPE
? formatMessage(messages.mapsNoInstallableFile)
: formatMessage(messages.noCompatibleVersion),
)
}
await contentSelection.add({
key,
provider: project.provider,
projectId: project.provider === 'modrinth' ? project.project_id : providerProjectId,
providerProjectId,
versionId,
contentType: contentType as 'mod' | 'resourcepack' | 'datapack' | 'shader' | 'world',
title: project.title,
iconUrl: project.icon_url,
slug: project.slug,
preferences,
})
} finally {
setProjectInstalling(project.project_id, false, target.id)
}
}
const serverInstallQueue = {
get: getQueuedServerInstallPlans,
set: setQueuedServerInstallPlans,
}
function getCurrentSelectedInstallPreferences(projectTypeValue: string) {
return getSelectedInstallPreferences({
contentType: projectTypeValue,
selectedFilters: searchState.currentFilters.value,
providedFilters: combinedProvidedFilters.value,
overriddenProvidedFilterTypes: searchState.overriddenProvidedFilterTypes.value,
})
}
function getServerInstallTargetPreferences(contentType: BrowseInstallContentType) {
return getTargetInstallPreferences(
{
gameVersion: serverContextServerData.value?.mc_version,
loader: serverContextServerData.value?.loader,
},
contentType,
)
}
function getInstanceInstallTargetPreferences(projectTypeValue: string) {
return getTargetInstallPreferences(
{
gameVersion: activeInstance.value?.game_version,
loader: activeInstance.value?.loader,
},
projectTypeValue,
)
}
async function getInstallProjectVersions(projectId: string) {
const project = await get_project(projectId, 'must_revalidate')
return (await get_version_many(
project.versions,
'must_revalidate',
)) as Labrinth.Versions.v2.Version[]
}
async function chooseInstanceInstallVersion(
project: Labrinth.Search.v2.ResultSearchProject & Labrinth.Search.v3.ResultSearchProject,
projectTypeValue: string,
) {
const targetInstance = activeInstance.value
if (!targetInstance) {
return { versionId: null as string | null }
}
const selectedPreferences = getCurrentSelectedInstallPreferences(projectTypeValue)
const targetPreferences = getInstanceInstallTargetPreferences(projectTypeValue)
if (!preferencesDiffer(selectedPreferences, targetPreferences)) {
return { versionId: null as string | null }
}
const selectedVersion = getLatestMatchingInstallVersion(
await getInstallProjectVersions(project.project_id),
selectedPreferences,
)
if (!selectedVersion) {
return { versionId: null as string | null }
}
return { versionId: selectedVersion.id }
}
type BrowseContentProvider = 'modrinth' | 'curseforge' | 'mcarchive' | 'planet_minecraft'
function isBrowseContentProvider(provider: unknown): provider is BrowseContentProvider {
return (
provider === 'modrinth' ||
provider === 'curseforge' ||
provider === 'mcarchive' ||
provider === 'planet_minecraft'
)
}
function getCardActions(
result: Labrinth.Search.v2.ResultSearchProject | Labrinth.Search.v3.ResultSearchProject,
currentProjectType: string,
): CardAction[] {
if (currentProjectType === 'server') {
return getServerCardActions(result as Labrinth.Search.v3.ResultSearchProject)
}
// Non-server project actions
const projectResult = result as (Labrinth.Search.v2.ResultSearchProject &
Labrinth.Search.v3.ResultSearchProject) & {
installed?: boolean
installing?: boolean
provider?: unknown
provider_project_id?: string
}
if (!isBrowseContentProvider(projectResult.provider)) return []
if (projectResult.provider === 'mcarchive' || projectResult.provider === 'planet_minecraft')
return []
const providerProjectId =
projectResult.provider === 'curseforge'
? (projectResult.provider_project_id ?? projectResult.project_id.replace(/^curseforge:/, ''))
: projectResult.project_id
const selectionKey = makeContentSelectionKey(projectResult.provider, providerProjectId)
const isInstalling =
installingProjectIds.value.has(projectInstallingKey(projectResult.project_id)) ||
contentSelection.isInstalling(selectionKey)
const isSelected = contentSelection.isSelected(selectionKey)
const isInstalled =
(isServerContext.value
? serverContentProjectIds.value.has(projectResult.project_id || '')
: !!activeInstance.value && allInstalledIds.value.has(projectResult.project_id || '')) ||
(!isServerContext.value &&
!!activeInstance.value &&
contentSelection.isInstalledIdentity(
projectResult.provider,
providerProjectId,
projectResult.slug,
)) ||
(isServerContext.value &&
serverContextServerData.value?.upstream?.project_id === projectResult.project_id)
const favoriteAction =
!isServerContext.value && isFavoriteContentType(currentProjectType)
? createFavoriteCardAction(projectResult, providerProjectId, currentProjectType)
: null
if (
isServerContext.value &&
projectResult.provider === 'modrinth' &&
['modpack', 'mod', 'plugin', 'datapack'].includes(currentProjectType)
) {
const isQueued = queuedServerInstallProjectIds.value.has(projectResult.project_id)
const isInstallingSelection = isInstallingQueuedServerInstalls.value
const validatingInstall =
isInstalling && currentProjectType !== 'modpack' && !isInstallingSelection
const installLabel = isInstalled
? commonMessages.installedLabel
: isQueued
? isInstalling || isInstallingSelection
? validatingInstall
? commonMessages.validatingLabel
: messages.installingToServer
: commonMessages.selectedLabel
: isInstalling || isInstallingSelection
? validatingInstall
? commonMessages.validatingLabel
: messages.installingToServer
: commonMessages.installButton
return [
{
key: 'install',
label: formatMessage(installLabel),
icon:
isInstalling || isInstallingSelection
? SpinnerIcon
: isQueued || isInstalled
? CheckIcon
: PlusIcon,
iconClass: isInstalling || isInstallingSelection ? 'animate-spin' : undefined,
disabled: isInstalled || isInstalling || isInstallingSelection,
color: isQueued && !isInstalling && !isInstallingSelection ? 'green' : 'brand',
type: 'outlined',
onClick: async () => {
const installInstanceId = activeInstance.value?.id ?? null
if (isQueued) {
removeQueuedServerInstall(projectResult.project_id)
return
}
const contentType = currentProjectType as BrowseInstallContentType
const isModpack = contentType === 'modpack'
const shouldShowInstalling = isModpack || !isQueued
if (shouldShowInstalling) {
setProjectInstalling(projectResult.project_id, true, installInstanceId)
}
try {
await requestInstall({
project: projectResult,
contentType,
mode: isModpack ? 'immediate' : 'queue',
selectedFilters: isModpack
? []
: stripServerRuntimeInstallFilters(searchState.currentFilters.value),
providedFilters: isModpack ? [] : combinedProvidedFilters.value,
overriddenProvidedFilterTypes: isModpack
? []
: stripServerRuntimeInstallOverrides(
searchState.overriddenProvidedFilterTypes.value,
),
targetPreferences: getServerInstallTargetPreferences(contentType),
getProjectVersions: getInstallProjectVersions,
queue: serverInstallQueue,
install: (plan) =>
openServerModpackInstallFlow({
projectId: plan.projectId,
versionId: plan.versionId,
name: plan.project.name,
iconUrl: plan.project.icon_url ?? undefined,
}),
})
} catch (err) {
handleError(err as Error)
} finally {
if (shouldShowInstalling) {
setProjectInstalling(projectResult.project_id, false, installInstanceId)
}
}
},
},
...(favoriteAction ? [favoriteAction] : []),
]
}
const isModpack =
projectResult.project_types?.includes('modpack') || projectResult.project_type === 'modpack'
if (CART_CONTENT_TYPES.has(currentProjectType)) {
if (
currentProjectType === WORLD_BROWSE_PROJECT_TYPE &&
projectResult.provider !== 'curseforge'
) {
return []
}
return [
{
key: 'install',
label: formatMessage(
isInstalling
? commonMessages.validatingLabel
: isSelected
? messages.selected
: activeInstance.value
? commonMessages.installButton
: messages.chooseInstance,
),
compactLabel:
!isInstalling && !isSelected && !activeInstance.value
? formatMessage(messages.add)
: undefined,
icon: isInstalling ? SpinnerIcon : isSelected ? CheckIcon : PlusIcon,
iconClass: isInstalling ? 'animate-spin' : undefined,
disabled: isInstalled || isInstalling,
color: isSelected ? 'green' : 'brand',
type: 'outlined',
onClick: async () => {
try {
await toggleContentSelection(projectResult, currentProjectType)
} catch (error) {
handleError(error)
}
},
},
...(favoriteAction ? [favoriteAction] : []),
]
}
const shouldUseInstallIcon = !!instance.value || isModpack
const installActionLabel = isInstalling
? messages.installingToServer
: isInstalled
? commonMessages.installedLabel
: shouldUseInstallIcon
? commonMessages.installButton
: messages.addToAnInstance
const compactInstallLabel =
!isInstalling && !isInstalled && !shouldUseInstallIcon ? formatMessage(messages.add) : undefined
return [
{
key: 'install',
label: formatMessage(installActionLabel),
compactLabel: compactInstallLabel,
icon: isInstalling ? SpinnerIcon : isInstalled ? CheckIcon : PlusIcon,
iconClass: isInstalling ? 'animate-spin' : undefined,
disabled: isInstalled || isInstalling,
color: 'brand',
type: 'outlined',
onClick: async () => {
const installInstanceId = instance.value?.id ?? null
setProjectInstalling(projectResult.project_id, true, installInstanceId)
try {
const selectedInstall =
instance.value && projectResult.provider === 'modrinth'
? await chooseInstanceInstallVersion(projectResult, currentProjectType)
: { versionId: null as string | null }
if (selectedInstall === null) {
setProjectInstalling(projectResult.project_id, false, installInstanceId)
return
}
const selectedPreferences = getCurrentSelectedInstallPreferences(currentProjectType)
const installContent =
projectResult.provider === 'curseforge'
? installCurseForge
: projectResult.provider === 'modrinth'
? installVersion
: null
if (!installContent) return
await installContent(
projectResult.provider_project_id ?? projectResult.project_id,
selectedInstall.versionId,
instance.value ? instance.value.id : null,
'SearchCard',
(versionId, installedProjectIds) => {
setProjectInstalling(projectResult.project_id, false, installInstanceId)
if (versionId && activeInstance.value?.id === installInstanceId) {
onSearchResultsInstalled(installedProjectIds ?? [projectResult.project_id])
}
},
(profile) => {
router.push(isModpack ? '/downloads' : `/instance/${profile}`)
},
{
preferredLoader: instance.value?.loader ?? selectedPreferences.loaders?.[0],
preferredGameVersion:
instance.value?.game_version ?? selectedPreferences.gameVersions?.[0],
},
)
} catch (err) {
setProjectInstalling(projectResult.project_id, false, installInstanceId)
handleError(err)
}
},
},
]
}
function createFavoriteCardAction(
project: Labrinth.Search.v2.ResultSearchProject & {
provider: BrowseContentProvider
},
providerProjectId: string,
contentType: FavoriteContentType,
): CardAction {
const favorite = contentFavorites.isFavorite(project.provider, providerProjectId)
const pending = contentFavorites.isPending(project.provider, providerProjectId)
return {
key: 'favorite',
label: formatMessage(favorite ? messages.removeFromFavorites : messages.addToFavorites),
icon: pending ? SpinnerIcon : favorite ? BookmarkFilledIcon : BookmarkIcon,
iconClass: pending ? 'animate-spin' : undefined,
disabled: pending,
color: favorite ? 'brand' : undefined,
type: 'transparent',
circular: true,
tooltip: formatMessage(favorite ? messages.removeFromFavorites : messages.addToFavorites),
onClick: async () => {
try {
await contentFavorites.toggle({
provider: project.provider,
project_id: providerProjectId,
content_type: contentType,
})
} catch (error) {
handleError(error)
}
},
}
}
function onSearchResultInstalled(id: string) {
if (isServerContext.value) {
markServerProjectInstalled(id)
return
}
if (!newlyInstalled.value.includes(id)) {
newlyInstalled.value = [...newlyInstalled.value, id]
}
}
function onSearchResultsInstalled(ids: string[]) {
if (isServerContext.value) {
for (const id of ids) {
markServerProjectInstalled(id)
}
return
}
newlyInstalled.value = Array.from(new Set([...newlyInstalled.value, ...ids]))
}
const curseForgeLoaderTypes: Record<string, number> = {
forge: 1,
fabric: 4,
quilt: 5,
neoforge: 6,
}
function extractQuotedFilterValues(source: string): string[] {
return [...source.matchAll(/[`"]([^`"]+)[`"]/g)].map((match) => match[1])
}
function getFirstSearchFilter(filters: string, field: string) {
// Modrinth search filter values are backtick-quoted (`value`); keep double-quote
// support for any legacy/manual strings.
return new RegExp(`${field}\\s*(?:=|IN\\s*\\[)\\s*[\`"]([^\`"]+)`).exec(filters)?.[1]
}
function getSearchFilterValues(filters: string, field: string) {
const values: string[] = []
const pattern = new RegExp(`${field}\\s*(?:=\\s*[\`"]([^\`"]+)[\`"]|IN\\s*\\[([^\\]]+)\\])`, 'g')
for (const match of filters.matchAll(pattern)) {
if (match[1]) {
values.push(match[1])
} else if (match[2]) {
values.push(...extractQuotedFilterValues(match[2]))
}
}
return values
}
function stripCurseForgeOnlyCategoryFilters(requestParams: string) {
const params = new URLSearchParams(
requestParams.startsWith('?') ? requestParams.slice(1) : requestParams,
)
const filters = params.get('new_filters')
if (!filters) return requestParams
const parts = filters
.split(' AND ')
.map((part) => part.trim())
.filter(Boolean)
.flatMap((part) => {
if (!part.includes('categories') || !part.includes('cf:')) return [part]
const equalMatch = /^categories\s*=\s*[`"]([^`"]+)[`"]$/.exec(part)
if (equalMatch) {
return equalMatch[1].startsWith('cf:') ? [] : [part]
}
const inMatch = /^categories\s+IN\s+\[([^\]]+)\]$/.exec(part)
if (inMatch) {
const kept = extractQuotedFilterValues(inMatch[1]).filter(
(value) => !value.startsWith('cf:'),
)
if (kept.length === 0) return []
if (kept.length === 1) return [`categories = \`${kept[0]}\``]
return [`categories IN [${kept.map((value) => `\`${value}\``).join(', ')}]`]
}
// Unknown shape containing cf: — drop rather than send invalid MR facets.
return []
})
if (parts.length === 0) {
params.delete('new_filters')
} else {
params.set('new_filters', parts.join(' AND '))
}
const query = params.toString()
return query ? `?${query}` : ''
}
function getCurseForgeCategoryIds(filters: string) {
const classId = curseForgeClassIds[projectType.value]
if (!classId || contentSource.value === 'modrinth') return []
const classCategories = curseForgeCategoriesByClass.value[classId] ?? []
if (classCategories.length === 0) return []
const loaderSlugs = new Set(Object.keys(curseForgeLoaderTypes))
return resolveCurseForgeCategoryIdsFromFilterValues(
getSearchFilterValues(filters, 'categories'),
classCategories,
loaderSlugs,
)
}
function getCurseForgeSortField(sort: string | null) {
switch (sort) {
case 'downloads':
return 6
case 'newest':
return 11
case 'updated':
return 3
case 'follows':
return 12
default:
return undefined
}
}
const CURSEFORGE_LOADER_SLUGS = new Set([
'forge',
'fabric',
'quilt',
'neoforge',
'liteloader',
'bukkit',
'spigot',
'paper',
'bungeecord',
'velocity',
'sponge',
'waterfall',
'folia',
'purpur',
'iris',
'optifine',
'canvas',
'geyser',
])
function mapCurseForgeHit(hit: UnifiedSearchHit) {
const categories = hit.categories.map((cat) => {
const normalized = cat.toLowerCase().replace(/[_\s]+/g, '-')
if (CURSEFORGE_LOADER_SLUGS.has(normalized)) {
return normalized
}
return localizeCurseForgeLabel(cat)
})
return {
project_id: `curseforge:${hit.project_id}`,
provider_project_id: hit.project_id,
provider: 'curseforge' as const,
project_type: hit.project_type,
slug: hit.slug,
author: hit.author,
author_url: hit.author_url,
title: hit.title,
description: hit.description,
categories,
display_categories: categories,
versions: hit.versions,
downloads: hit.downloads,
follows: 0,
icon_url: getCurseForgeImageUrl(hit.icon_url),
date_created: hit.date_created,
date_modified: hit.date_modified,
latest_version: hit.latest_version ?? '',
license: '',
client_side: 'unknown',
server_side: 'unknown',
gallery: hit.gallery,
featured_gallery: hit.gallery[0] ?? null,
color: null,
website_url: hit.website_url,
source_url: hit.source_url,
allow_mod_distribution: hit.allow_mod_distribution,
}
}
type ChineseSearchHit = Labrinth.Search.v2.ResultSearchProject & {
provider: 'modrinth' | 'curseforge'
provider_project_id?: string
installed?: boolean
chinese_search_score?: number
}
type McArchiveSearchHit = Labrinth.Search.v2.ResultSearchProject & {
provider: 'mcarchive'
provider_project_id: string
}
function mapMcArchiveHit(project: McArchiveMod): McArchiveSearchHit {
const versions = Array.from(
new Set(
project.modVersions.flatMap((version) =>
version.gameVersions.map((gameVersion) => gameVersion.name),
),
),
)
return {
project_id: `mcarchive:${project.uuid}`,
provider_project_id: project.uuid,
provider: 'mcarchive',
project_type: 'mod',
project_types: ['mod'],
slug: project.slug,
author: '',
title: project.name,
description: project.summary ?? project.description ?? '',
categories: [],
display_categories: [],
versions,
downloads: 0,
follows: 0,
icon_url: '',
date_created: '',
date_modified: '',
latest_version: '',
license: '',
client_side: 'unknown',
server_side: 'unknown',
gallery: [],
featured_gallery: null,
color: null,
} as McArchiveSearchHit
}
function mapPlanetMinecraftHit(project: PlanetMinecraftProject) {
return {
project_id: `planet_minecraft:${project.id}`,
provider_project_id: project.id,
provider: 'planet_minecraft' as const,
project_type: 'mod',
slug: project.id,
author: '',
title: project.title,
description: project.summary ?? '',
categories: [],
display_categories: [],
versions: Array.from(new Set(project.versions.flatMap((version) => version.gameVersions))),
downloads: 0,
follows: 0,
icon_url: '',
date_created: '',
date_modified: '',
latest_version: '',
license: '',
client_side: 'unknown',
server_side: 'unknown',
gallery: [],
featured_gallery: null,
color: null,
} as Labrinth.Search.v2.ResultSearchProject & {
provider: 'planet_minecraft'
provider_project_id: string
}
}
interface DirectModrinthProject {
id: string
slug?: string
project_types?: string[]
name: string
summary: string
published?: string
updated?: string
downloads?: number
followers?: number
categories?: string[]
additional_categories?: string[]
loaders?: string[]
game_versions?: string[]
icon_url?: string
color?: number
gallery?: Array<{ url?: string; raw_url?: string; featured?: boolean }>
}
function replaceSearchQuery(requestParams: string, query: string) {
const params = new URLSearchParams(
requestParams.startsWith('?') ? requestParams.slice(1) : requestParams,
)
params.set('query', query)
const result = params.toString()
return result ? `?${result}` : ''
}
/**
* Upper bound on sequential fallback search attempts when the primary query
* yields no hits. Each attempt queries the providers in parallel.
*/
const MAX_SEARCH_VARIANT_ATTEMPTS = 3
/**
* A dictionary-split replacement result must be clearly better than the
* primary compact-query match before it may replace the primary hits.
*/
const MIN_VARIANT_REPLACEMENT_HITS = 5
interface FuzzySearchNotice {
/** The query exactly as the user typed it. */
original: string
/** The rewritten query that actually produced the shown results. */
used: string
}
let pendingFuzzyNotice: FuzzySearchNotice | null = null
const searchNotice = ref<FuzzySearchNotice | null>(null)
function findChineseTranslation(
resolution: ChineseSearchResolution | null,
provider: 'modrinth' | 'curseforge',
slug?: string | null,
): ChineseSearchTranslation | undefined {
if (!resolution || !slug) return undefined
const normalizedSlug = slug.toLocaleLowerCase()
return resolution.translations.find((translation) => {
const candidate =
provider === 'modrinth' ? translation.modrinthSlug : translation.curseforgeSlug
return candidate?.toLocaleLowerCase() === normalizedSlug
})
}
function applyChineseTranslation(
hit: ChineseSearchHit,
resolution: ChineseSearchResolution | null,
): ChineseSearchHit {
if (hit.provider !== 'modrinth' && hit.provider !== 'curseforge') return hit
const provider = hit.provider
const translation = findChineseTranslation(resolution, provider, hit.slug)
if (!translation) return hit
return {
...hit,
title: bilingualTitle(translation.chineseName, hit.title),
chinese_search_score: (translation.exact ? 10 : 0) + translation.matchScore,
}
}
function matchesDirectModrinthFilters(
project: DirectModrinthProject,
gameVersion: string | undefined,
loader: string | undefined,
categoryValues: string[],
) {
if (!project.project_types?.includes(projectType.value)) return false
if (gameVersion && !project.game_versions?.includes(gameVersion)) return false
if (loader && !project.loaders?.includes(loader)) return false
const modrinthCategories = categoryValues.filter(
(value) => !value.startsWith('cf:') && curseForgeLoaderTypes[value] === undefined,
)
if (
modrinthCategories.length > 0 &&
!modrinthCategories.some(
(category) =>
project.categories?.includes(category) || project.additional_categories?.includes(category),
)
) {
return false
}
return true
}
function mapDirectModrinthProject(project: DirectModrinthProject): ChineseSearchHit {
const gallery = project.gallery?.flatMap((item) => (item.url ? [item.url] : [])) ?? []
return {
project_id: project.id,
project_type: project.project_types?.[0] ?? projectType.value,
slug: project.slug,
author: '',
title: project.name,
description: project.summary,
categories: [...(project.categories ?? []), ...(project.additional_categories ?? [])],
display_categories: project.categories ?? [],
versions: project.game_versions ?? [],
downloads: project.downloads ?? 0,
follows: project.followers ?? 0,
icon_url: project.icon_url,
date_created: project.published ?? '',
date_modified: project.updated ?? '',
latest_version: '',
license: '',
client_side: 'unknown',
server_side: 'unknown',
gallery,
featured_gallery: project.gallery?.find((item) => item.featured)?.url ?? gallery[0] ?? null,
color: project.color ?? null,
provider: 'modrinth',
} as ChineseSearchHit
}
function dedupeProviderHits(hits: ChineseSearchHit[]) {
const seen = new Set<string>()
return hits.filter((hit) => {
const key = `${hit.provider}:${hit.project_id}`
if (seen.has(key)) return false
seen.add(key)
return true
})
}
function rankChineseProviderHits(hits: ChineseSearchHit[], sort: string | null) {
const metric = (hit: ChineseSearchHit) => {
switch (sort) {
case 'downloads':
return hit.downloads ?? 0
case 'follows':
return hit.follows ?? 0
case 'newest':
return Date.parse(hit.date_created ?? '') || 0
case 'updated':
return Date.parse(hit.date_modified ?? '') || 0
default:
return null
}
}
if (sort && sort !== 'relevance') {
return [...hits].sort((left, right) => (metric(right) ?? 0) - (metric(left) ?? 0))
}
if (!hits.some((hit) => hit.chinese_search_score)) return hits
return hits
.map((hit, index) => ({ hit, index }))
.sort((left, right) => {
const score = (right.hit.chinese_search_score ?? 0) - (left.hit.chinese_search_score ?? 0)
if (score !== 0) return score
const downloads = (right.hit.downloads ?? 0) - (left.hit.downloads ?? 0)
if (downloads !== 0) return downloads
return left.index - right.index
})
.map(({ hit }) => hit)
}
async function search(requestParams: string, signal: AbortSignal) {
debugLog('searching v3', requestParams)
pendingFuzzyNotice = null
const isServer = projectType.value === 'server'
if (isWorldMapBrowse.value && !curseForgeCapability.value.configured) {
return {
projectHits: [],
serverHits: [],
total_hits: 0,
per_page: 20,
}
}
const params = new URLSearchParams(requestParams)
const limit = Math.min(Number(params.get('limit') ?? 20), 50)
const offset = Number(params.get('offset') ?? 0)
const rawQuery = params.get('query') ?? ''
const filters = params.get('new_filters') ?? ''
const gameVersion = getFirstSearchFilter(filters, 'game_versions')
const isMcArchiveBrowse =
mcArchiveAvailable.value &&
(contentSource.value === 'mcarchive' || route.query.source === 'mcarchive')
if (isMcArchiveBrowse) {
if (projectType.value !== 'mod') {
return {
projectHits: [],
serverHits: [],
total_hits: 0,
per_page: limit,
}
}
const projects = await searchMcArchiveMods(normalizeSearchText(rawQuery), gameVersion).catch(
(error) => {
debugLog('mcarchive search failed', error)
throw error
},
)
signal.throwIfAborted()
// The MCArchive list endpoint deliberately returns project summaries. It does
// not include modVersions, so applying the selected Minecraft version here
// would discard every result. Version compatibility is resolved after opening
// the project detail, where the full release list is available.
const hits = projects.map(mapMcArchiveHit)
const safeOffset = offset < hits.length ? offset : 0
return {
projectHits: hits.slice(safeOffset, safeOffset + limit),
serverHits: [],
total_hits: hits.length,
per_page: limit,
}
}
if (contentSource.value === 'planet_minecraft') {
if (projectType.value !== 'mod' || !planetMinecraftAvailable.value) {
return { projectHits: [], serverHits: [], total_hits: 0, per_page: limit }
}
const projects = await searchPlanetMinecraftProjects(
normalizeSearchText(rawQuery),
gameVersion,
).catch((error) => {
debugLog('planet minecraft search failed', error)
throw error
})
signal.throwIfAborted()
const hits = projects.map(mapPlanetMinecraftHit)
return {
projectHits: hits.slice(offset, offset + limit),
serverHits: [],
total_hits: hits.length,
per_page: limit,
}
}
let chineseResolution: ChineseSearchResolution | null = null
if (!isWorldMapBrowse.value && containsChineseSearchText(rawQuery)) {
chineseResolution = await resolveChineseContentSearch(rawQuery).catch((error) => {
debugLog('chinese search resolution failed, using original query', error)
return null
})
}
const categoryValues = getSearchFilterValues(filters, 'categories')
const hasOnlyCurseForgeExclusiveCategories =
categoryValues.length > 0 &&
categoryValues.every(
(value) => isCurseForgeOnlyCategoryName(value) || curseForgeLoaderTypes[value] !== undefined,
) &&
categoryValues.some((value) => isCurseForgeOnlyCategoryName(value))
const includeModrinth =
!isWorldMapBrowse.value &&
(contentSource.value !== 'curseforge' || isServer) &&
!(contentSource.value === 'all' && hasOnlyCurseForgeExclusiveCategories)
let includeCurseForge =
!isServer &&
contentSource.value !== 'modrinth' &&
curseForgeCapability.value.configured &&
curseForgeClassIds[projectType.value] !== undefined
if (includeCurseForge) {
await ensureCurseForgeCategories(projectType.value).catch(handleError)
}
const loader = categoryValues.find((value) => curseForgeLoaderTypes[value] !== undefined)
const nonLoaderCategoryValues = categoryValues.filter(
(value) => curseForgeLoaderTypes[value] === undefined,
)
const curseForgeCategoryIds = includeCurseForge ? getCurseForgeCategoryIds(filters) : []
// In unified browse, never mix unfiltered CurseForge hits with filtered Modrinth hits.
// If the user picked categories that cannot be mapped to CF, only query Modrinth.
if (
contentSource.value === 'all' &&
includeCurseForge &&
nonLoaderCategoryValues.length > 0 &&
curseForgeCategoryIds.length === 0 &&
!hasOnlyCurseForgeExclusiveCategories
) {
includeCurseForge = false
debugLog('skipping unfiltered curseforge results for unmapped categories', {
categoryValues: nonLoaderCategoryValues,
})
}
signal.throwIfAborted()
let modrinthRequestParams =
includeModrinth && (includeCurseForge || hasOnlyCurseForgeExclusiveCategories)
? stripCurseForgeOnlyCategoryFilters(requestParams)
: requestParams
if (chineseResolution?.modrinthQuery) {
modrinthRequestParams = replaceSearchQuery(
modrinthRequestParams,
chineseResolution.modrinthQuery,
)
}
const providerRequestGroupId = crypto.randomUUID()
const modrinthRequestId = `${providerRequestGroupId}:modrinth`
const curseForgeRequestId = `${providerRequestGroupId}:curseforge`
const activeProviderRequestIds = new Set<string>()
function trackProviderRequest<T>(requestId: string, request: Promise<T>) {
activeProviderRequestIds.add(requestId)
return request.finally(() => activeProviderRequestIds.delete(requestId))
}
let rejectProviderSearch: (reason?: unknown) => void = () => {}
const providerSearchCancelled = new Promise<never>((_, reject) => {
rejectProviderSearch = reject
})
const cancelProviderRequests = () => {
for (const requestId of activeProviderRequestIds) {
cancel_search_request(requestId).catch((error) => {
debugLog('failed to cancel provider search', { requestId, error })
})
}
rejectProviderSearch(signal.reason)
}
signal.addEventListener('abort', cancelProviderRequests, { once: true })
interface ProviderAttemptStep {
modrinthQuery: string | null
curseforgeFilter: string | null
}
const queryExpansion = expandSearchQuery(rawQuery)
const isChineseQuery = chineseResolution?.isChinese ?? false
const curseForgeQueryBase = chineseResolution?.curseforgeQuery ?? rawQuery
const curseForgeQueryVariantsList = queryExpansion
? curseForgeQueryVariants(curseForgeQueryBase)
: []
// An absent query must keep CurseForge browsable unfiltered: `null` means
// "skip CurseForge in this attempt" (used by fallback steps), so an empty
// query maps to an empty filter, which the request layer turns into no
// searchFilter.
const primaryCurseForgeFilter = queryExpansion ? (curseForgeQueryVariantsList[0] ?? null) : ''
const runProviderAttempt = async (
attemptParams: {
modrinthParams: string | null
curseforgeFilter: string | null
},
includeDirectModrinth: boolean,
) => {
const modrinthRequest =
includeModrinth && attemptParams.modrinthParams !== null
? queryClient.fetchQuery({
queryKey: ['search', 'v3', attemptParams.modrinthParams],
queryFn: () =>
trackProviderRequest(
modrinthRequestId,
get_search_results_v3(
attemptParams.modrinthParams,
'must_revalidate',
modrinthRequestId,
) as Promise<{
result: Labrinth.Search.v3.SearchResults & {
hits: (Labrinth.Search.v3.ResultSearchProject & {
installed?: boolean
})[]
}
} | null>,
),
staleTime: 30_000,
})
: Promise.resolve(null)
const curseForgeRequest =
includeCurseForge && attemptParams.curseforgeFilter !== null
? trackProviderRequest(
curseForgeRequestId,
searchCurseForgeProjects(
{
classId: curseForgeClassIds[projectType.value]!,
categoryIds: curseForgeCategoryIds,
searchFilter: attemptParams.curseforgeFilter || undefined,
gameVersion: gameVersion || undefined,
modLoaderType: loader ? curseForgeLoaderTypes[loader] : undefined,
sortField: getCurseForgeSortField(params.get('index')),
sortOrder: 'desc',
index: offset,
pageSize: limit,
},
curseForgeRequestId,
),
)
: Promise.resolve(null)
const directModrinthRequest =
includeModrinth &&
includeDirectModrinth &&
!isServer &&
offset === 0 &&
(chineseResolution?.modrinthSlugs.length ?? 0) > 0
? get_project_v3_many(chineseResolution!.modrinthSlugs, 'must_revalidate')
: Promise.resolve([])
const [modrinthResult, curseForgeResult, directModrinthResult] = await Promise.allSettled([
modrinthRequest,
curseForgeRequest,
directModrinthRequest,
])
return {
rawResults: modrinthResult.status === 'fulfilled' ? modrinthResult.value : null,
rawCurseForge: curseForgeResult.status === 'fulfilled' ? curseForgeResult.value : null,
rawDirectModrinth:
directModrinthResult.status === 'fulfilled'
? (directModrinthResult.value as DirectModrinthProject[])
: [],
modrinthError: modrinthResult.status === 'rejected' ? modrinthResult.reason : null,
curseForgeError: curseForgeResult.status === 'rejected' ? curseForgeResult.reason : null,
directModrinthError:
directModrinthResult.status === 'rejected' ? directModrinthResult.reason : null,
}
}
type ProviderAttemptResults = Awaited<ReturnType<typeof runProviderAttempt>>
const attemptHasHits = (results: ProviderAttemptResults) =>
(results.rawResults?.result.total_hits ?? 0) > 0 ||
(results.rawCurseForge?.total_hits ?? 0) > 0 ||
results.rawDirectModrinth.length > 0
let cachedCompactSplit: string | null | undefined
const resolveCompactSplit = async () => {
if (cachedCompactSplit === undefined) {
cachedCompactSplit = await expandContentSearchQuery(rawQuery)
.then((expansion) => expansion.suggestedSplit ?? null)
.catch((error) => {
debugLog('search query expansion failed', error)
return null
})
}
return cachedCompactSplit
}
const buildFallbackSteps = async (): Promise<ProviderAttemptStep[]> => {
const steps: ProviderAttemptStep[] = []
if (!queryExpansion) return steps
const mrVariants = isChineseQuery ? [] : queryExpansion.modrinthVariants.slice(1)
const cfVariants = queryExpansion.curseforgeVariants.slice(1)
if (queryExpansion.compact && !isChineseQuery && includeModrinth) {
const dictSplit = await resolveCompactSplit()
if (dictSplit && !queryExpansion.modrinthVariants.includes(dictSplit)) {
steps.push({
modrinthQuery: dictSplit,
curseforgeFilter: curseForgeQueryVariants(dictSplit)[0] ?? null,
})
}
}
const maxVariants = Math.max(mrVariants.length, cfVariants.length)
for (
let index = 0;
index < maxVariants && steps.length < MAX_SEARCH_VARIANT_ATTEMPTS;
index += 1
) {
const modrinthQuery = mrVariants[index] ?? null
const curseforgeFilter = cfVariants[index] ?? null
if (modrinthQuery !== null || curseforgeFilter !== null) {
steps.push({ modrinthQuery, curseforgeFilter })
}
}
return steps
}
const shouldImproveCompactMatch = (results: ProviderAttemptResults) => {
if (!queryExpansion || !queryExpansion.compact || isChineseQuery) return false
if (!includeModrinth || (results.rawResults?.result.hits.length ?? 0) === 0) return false
const total = results.rawResults?.result.total_hits ?? 0
return total >= 1 && total <= 2
}
const performSearch = async (): Promise<ProviderAttemptResults> => {
if (includeCurseForge) {
debugLog('curseforge filters', {
filters,
categoryValues,
categoryIds: curseForgeCategoryIds,
gameVersion,
loader,
searchFilter: primaryCurseForgeFilter,
})
}
const attemptParams = (step: ProviderAttemptStep) => ({
modrinthParams:
step.modrinthQuery === null
? null
: replaceSearchQuery(modrinthRequestParams, step.modrinthQuery),
curseforgeFilter: step.curseforgeFilter,
})
const primaryResults = await runProviderAttempt(
{
modrinthParams: includeModrinth ? modrinthRequestParams : null,
curseforgeFilter: primaryCurseForgeFilter,
},
true,
)
let results = primaryResults
if (!attemptHasHits(primaryResults)) {
const fallbackAttempts = await buildFallbackSteps()
for (const step of fallbackAttempts) {
signal.throwIfAborted()
results = await runProviderAttempt(attemptParams(step), false)
if (attemptHasHits(results)) {
debugLog('search fallback matched query variant', step)
pendingFuzzyNotice = {
original: rawQuery,
used:
step.modrinthQuery ??
(step.curseforgeFilter ? step.curseforgeFilter.replaceAll('-', ' ') : ''),
}
break
}
}
} else if (shouldImproveCompactMatch(primaryResults)) {
// Server fuzzy matching can return a single poor hit for compact
// queries (e.g. `irisshaders`); when the dictionary splits the query
// into a clearly better result set, prefer the split form.
const swapQuery = await resolveCompactSplit()
if (swapQuery && !queryExpansion!.modrinthVariants.includes(swapQuery)) {
const swapped = await runProviderAttempt(
attemptParams({ modrinthQuery: swapQuery, curseforgeFilter: null }),
false,
)
if ((swapped.rawResults?.result.total_hits ?? 0) >= MIN_VARIANT_REPLACEMENT_HITS) {
debugLog('search compact query replaced with split form', swapQuery)
pendingFuzzyNotice = { original: rawQuery, used: swapQuery }
results = {
...swapped,
rawCurseForge: primaryResults.rawCurseForge,
rawDirectModrinth: primaryResults.rawDirectModrinth,
}
}
}
}
return results
}
const searched = await Promise.race([performSearch(), providerSearchCancelled]).finally(() =>
signal.removeEventListener('abort', cancelProviderRequests),
)
const rawResults = searched.rawResults
const rawCurseForge = searched.rawCurseForge
const rawDirectModrinth = searched.rawDirectModrinth
if (searched.modrinthError) {
debugLog('modrinth search failed', searched.modrinthError)
}
if (searched.curseForgeError) {
debugLog('curseforge search failed', searched.curseForgeError)
}
if (searched.directModrinthError) {
debugLog('direct modrinth chinese candidates failed', searched.directModrinthError)
}
if (!rawResults && !rawCurseForge && rawDirectModrinth.length === 0) {
const error =
searched.modrinthError ??
searched.curseForgeError ??
new Error('No content providers are available')
throw error
}
if (isServer) {
if (!rawResults) throw new Error('The server project provider is unavailable')
const hits = rawResults.result.hits ?? []
updateServerHits(hits)
return {
projectHits: [],
serverHits: hits,
total_hits: rawResults.result.total_hits ?? 0,
per_page: rawResults.result.hits_per_page,
}
}
const hits = (rawResults?.result.hits ?? []).map((hit) => {
const mapped = {
...hit,
title: hit.name,
description: hit.summary,
provider: 'modrinth' as const,
} as unknown as Labrinth.Search.v2.ResultSearchProject & {
installed?: boolean
provider: 'modrinth' | 'curseforge' | 'mcarchive' | 'planet_minecraft'
}
if (activeInstance.value || isServerContext.value) {
const installedIds = activeInstance.value
? allInstalledIds.value
: serverContentProjectIds.value
mapped.installed = installedIds.has(hit.project_id)
}
return applyChineseTranslation(mapped, chineseResolution)
})
const directModrinthHits = rawDirectModrinth
.filter((project) => matchesDirectModrinthFilters(project, gameVersion, loader, categoryValues))
.slice(0, limit)
.map(mapDirectModrinthProject)
.map((hit) => applyChineseTranslation(hit, chineseResolution))
.map((hit) => {
if (activeInstance.value || isServerContext.value) {
const installedIds = activeInstance.value
? allInstalledIds.value
: serverContentProjectIds.value
hit.installed = installedIds.has(hit.project_id)
}
return hit
})
const modrinthHits = rankChineseProviderHits(
dedupeProviderHits([...directModrinthHits, ...hits]),
params.get('index'),
)
const directModrinthHitIds = new Set(directModrinthHits.map((hit) => hit.project_id))
const searchedModrinthHitIds = new Set(hits.map((hit) => hit.project_id))
const injectedModrinthCount = [...directModrinthHitIds].filter(
(id) => !searchedModrinthHitIds.has(id),
).length
const curseForgeHits = (rawCurseForge?.hits ?? [])
.map(mapCurseForgeHit)
.map((hit) => {
if (activeInstance.value) hit.installed = allInstalledIds.value.has(hit.project_id)
return hit
})
.map((hit) => applyChineseTranslation(hit as ChineseSearchHit, chineseResolution))
const locale = i18n.global.locale.value
const [localizedModrinthHits, localizedCurseForgeHits] = await Promise.all([
translateSearchHitTitles(modrinthHits, locale),
translateSearchHitTitles(curseForgeHits, locale),
])
return {
projectHits:
contentSource.value === 'all'
? mergeProviderResults({
modrinthHits: localizedModrinthHits,
curseForgeHits: localizedCurseForgeHits,
sort: params.get('index'),
query: params.get('query'),
limit,
})
: contentSource.value === 'curseforge'
? localizedCurseForgeHits
: localizedModrinthHits.slice(0, limit),
serverHits: [],
total_hits:
contentSource.value === 'all'
? Math.max(
(rawResults?.result.total_hits ?? 0) + injectedModrinthCount,
rawCurseForge?.total_hits ?? 0,
)
: contentSource.value === 'curseforge'
? (rawCurseForge?.total_hits ?? 0)
: (rawResults?.result.total_hits ?? 0) + injectedModrinthCount,
per_page: limit,
}
}
const isServerFilterContext = computed(() => isServerContext.value || isServerInstance.value)
const lockedFilterMessages = computed(() => ({
gameVersion: formatMessage(
isServerFilterContext.value
? messages.gameVersionProvidedByServer
: messages.gameVersionProvidedByInstance,
),
modLoader: formatMessage(
isServerFilterContext.value
? messages.modLoaderProvidedByServer
: messages.modLoaderProvidedByInstance,
),
environment: formatMessage(messages.environmentProvidedByServer),
syncButton: formatMessage(messages.syncFilterButton),
providedBy: formatMessage(
isServerFilterContext.value ? messages.providedByServer : messages.providedByInstance,
),
}))
type BrowseReturnState = {
currentPage: number
}
const browseReturnSnapshot = consumeBrowseReturnSnapshot<BrowseReturnState>(route.fullPath)
const displayMode = ref<BrowseDisplayMode>(getLastBrowseContentDisplayMode())
const displayModeOptions = computed<BrowseDisplayModeOption[]>(() => [
{ id: 'list', label: formatMessage(messages.listView), icon: ListIcon },
{ id: 'compact', label: formatMessage(messages.compactListView), icon: GenericListIcon },
{ id: 'grid', label: formatMessage(messages.gridView), icon: GridIcon },
])
const displayModeTooltip = computed(() => formatMessage(messages.switchView))
function setDisplayMode(mode: BrowseDisplayMode) {
if (mode === 'gallery') return
displayMode.value = mode
setLastBrowseContentDisplayMode(mode)
}
const searchState = useBrowseSearch({
projectType,
tags,
providedFilters: combinedProvidedFilters,
installContextLoader: computed(() => installContext.value?.loader),
search: async (requestParams: string, signal: AbortSignal) => {
const response = await search(requestParams, signal)
searchNotice.value = pendingFuzzyNotice
return response
},
persistentQueryParams: ['i', 'ai', 'shi', 'sid', 'wid', 'from', 'source'],
getExtraQueryParams: () => ({
sid: serverIdQuery.value || undefined,
wid: effectiveServerWorldId.value || undefined,
ai: instanceHideInstalled.value ? 'true' : undefined,
shi: serverHideInstalled.value ? 'true' : undefined,
source:
isWorldMapBrowse.value || contentSource.value === 'all' ? undefined : contentSource.value,
}),
displayMode,
})
function restoreBrowseReturnPage() {
if (!browseReturnSnapshot || !Number.isInteger(browseReturnSnapshot.state.currentPage)) return
searchState.currentPage.value = Math.max(1, browseReturnSnapshot.state.currentPage)
}
const NON_FILTER_BROWSE_QUERY_PARAMS = new Set([
'i',
'ai',
'shi',
'sid',
'wid',
'from',
'source',
'q',
's',
'ss',
'm',
'o',
'page',
'b',
])
function hasExplicitFilterQuery() {
return Object.keys(route.query).some((key) => !NON_FILTER_BROWSE_QUERY_PARAMS.has(key))
}
function availableRememberedFilters(type: ProjectType, filters: BrowseFilterMemory['filters']) {
const filterTypes =
type === 'server' ? searchState.serverFilterTypes.value : searchState.filters.value
return filters.filter((filter) => {
const filterType = filterTypes.find((candidate) => candidate.id === filter.type)
return (
filterType &&
(filterType.allows_custom_options ||
filterType.options.some((option) => option.id === filter.option))
)
})
}
function applyRememberedFilters(type: ProjectType, resetWhenMissing: boolean) {
const memory = getBrowseFilterMemory(type)
if (!memory && !resetWhenMissing) return
if (type === 'server') {
searchState.serverCurrentFilters.value = memory
? availableRememberedFilters(type, memory.filters)
: [{ type: 'server_status', option: 'online' }]
searchState.serverToggledGroups.value = memory ? [...memory.toggledGroups] : []
return
}
const filterTypeIds = new Set(searchState.filters.value.map((filter) => filter.id))
searchState.currentFilters.value = memory ? availableRememberedFilters(type, memory.filters) : []
searchState.toggledGroups.value = memory ? [...memory.toggledGroups] : []
searchState.overriddenProvidedFilterTypes.value = memory
? memory.overriddenProvidedFilterTypes.filter((filterType) => filterTypeIds.has(filterType))
: []
}
if (!hasExplicitFilterQuery()) {
applyRememberedFilters(projectType.value, false)
}
watch(projectType, (type) => applyRememberedFilters(type, true))
watch(
[
projectType,
searchState.currentFilters,
searchState.toggledGroups,
searchState.overriddenProvidedFilterTypes,
searchState.serverCurrentFilters,
searchState.serverToggledGroups,
],
() => {
const isServer = projectType.value === 'server'
setBrowseFilterMemory(projectType.value, {
filters: isServer ? searchState.serverCurrentFilters.value : searchState.currentFilters.value,
toggledGroups: isServer
? searchState.serverToggledGroups.value
: searchState.toggledGroups.value,
overriddenProvidedFilterTypes: isServer
? []
: searchState.overriddenProvidedFilterTypes.value,
})
},
{ deep: true },
)
/** Translation state for search result titles and descriptions. */
const originalProjectHits = shallowRef<typeof searchState.projectHits.value>([])
const originalServerHits = shallowRef<typeof searchState.serverHits.value>([])
let isUpdatingProjectHitsFromTranslation = false
const {
translationActive,
translationLoading,
start: startTranslation,
isStale,
done: doneTranslation,
toggle,
cancel: cancelTranslation,
} = useTranslationToggle()
// Keep a pristine copy when genuine search results arrive (project hits).
watch(
() => searchState.projectHits.value,
(hits) => {
if (isUpdatingProjectHitsFromTranslation) return
if (hits && hits.length > 0) {
originalProjectHits.value = hits
const version = cancelTranslation()
void autoTranslateNewSearchResults(version, false)
}
},
{ flush: 'sync' },
)
// Keep a pristine copy when genuine search results arrive (server hits).
watch(
() => searchState.serverHits.value,
(hits) => {
if (isUpdatingProjectHitsFromTranslation) return
if (hits && hits.length > 0) {
originalServerHits.value = hits
const version = cancelTranslation()
// Always restart auto-translate since this bumps the version and
// cancels the one started by the projectHits watcher.
const useServer = originalServerHits.value.length > 0
void autoTranslateNewSearchResults(version, useServer)
}
},
{ flush: 'sync' },
)
async function autoTranslateNewSearchResults(version: number, useServer: boolean) {
try {
const hits = useServer ? originalServerHits.value : originalProjectHits.value
if (!hits?.length) return
const translated = await translateSearchDescriptions(
hits,
i18n.global.locale.value,
false,
useServer,
)
if (isStale(version)) return
if (translated !== hits) {
isUpdatingProjectHitsFromTranslation = true
if (useServer) searchState.serverHits.value = translated
else searchState.projectHits.value = translated
translationActive.value = true
isUpdatingProjectHitsFromTranslation = false
}
} catch (error) {
debugLog('Automatic search result translation failed', error)
}
}
async function translateCurrentHits() {
const version = startTranslation()
try {
const serverHits = originalServerHits.value
const projectHits = originalProjectHits.value
const useServer = serverHits.length > 0
const hits = useServer ? serverHits : projectHits
if (!hits || hits.length === 0) return
const translated = await translateSearchDescriptions(
hits,
i18n.global.locale.value,
true,
useServer,
)
if (isStale(version)) return // superseded
if (translated !== hits) {
isUpdatingProjectHitsFromTranslation = true
if (useServer) searchState.serverHits.value = translated
else searchState.projectHits.value = translated
translationActive.value = true
}
isUpdatingProjectHitsFromTranslation = false
} finally {
doneTranslation(version)
}
}
function toggleTranslation() {
toggle(
() => {
isUpdatingProjectHitsFromTranslation = true
searchState.projectHits.value = originalProjectHits.value
searchState.serverHits.value = originalServerHits.value
isUpdatingProjectHitsFromTranslation = false
},
() => void translateCurrentHits(),
)
}
watch(contentSource, async (source) => {
searchState.projectHits.value = []
searchState.totalHits.value = 0
searchState.currentPage.value = 1
searchState.loading.value = true
searchState.currentFilters.value = searchState.currentFilters.value.filter(
(filter) => !filter.type.startsWith('category_'),
)
if (source === 'curseforge' || source === 'all') {
await ensureCurseForgeCategories(projectType.value).catch(handleError)
}
if (source === 'mcarchive') await refreshMcArchiveGameVersions()
await searchState.refreshSearch()
})
watch(projectType, async (type, previousType) => {
if (type === WORLD_BROWSE_PROJECT_TYPE) {
sourceBeforeWorldMapBrowse.value = contentSource.value
contentSource.value = 'curseforge'
return
}
if (previousType === WORLD_BROWSE_PROJECT_TYPE) {
contentSource.value = sourceBeforeWorldMapBrowse.value
}
if (
type !== 'mod' &&
(contentSource.value === 'mcarchive' || contentSource.value === 'planet_minecraft')
) {
contentSource.value = 'all'
setLastBrowseContentSource('all')
}
if (contentSource.value === 'curseforge' || contentSource.value === 'all') {
await ensureCurseForgeCategories(type).catch(handleError)
}
})
watch(
mcArchiveAvailable,
(available) => {
if (!available && contentSource.value === 'mcarchive') {
contentSource.value = 'all'
setLastBrowseContentSource('all')
}
},
{ immediate: !route.query.i },
)
function selectContentSource(source: string) {
if (isWorldMapBrowse.value) return
if (
source === 'all' ||
source === 'modrinth' ||
source === 'curseforge' ||
(source === 'mcarchive' && projectType.value === 'mod' && mcArchiveAvailable.value) ||
(source === 'planet_minecraft' && projectType.value === 'mod' && planetMinecraftAvailable.value)
) {
contentSource.value = source
setLastBrowseContentSource(source)
}
}
watch(
[
() => searchState.query.value,
() => searchState.currentFilters.value,
() => searchState.serverCurrentFilters.value,
() => projectType.value,
],
() => {
if (isServerContext.value) {
syncHiddenServerContentProjectIds()
} else if (activeInstance.value) {
syncHiddenInstanceProjectIds()
}
},
{ deep: true },
)
watch(queuedServerInstallCount, (count) => {
if (count === 0) {
hideSelectedServerInstalls.value = false
}
})
type UnlistenFn = () => void
let isUnmounted = false
let unlistenInstances: UnlistenFn | null = null
let pendingBrowseReturnScroll = browseReturnSnapshot !== null
async function restoreBrowseReturnScroll() {
if (!browseReturnSnapshot) return
await nextTick()
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
if (isUnmounted) return
document.querySelector<HTMLElement>('.app-viewport')?.scrollTo({
top: browseReturnSnapshot.scrollTop,
})
completeBrowseReturnNavigation(route.fullPath)
}
watch(
searchState.loading,
(isLoading) => {
if (isLoading || !pendingBrowseReturnScroll) return
pendingBrowseReturnScroll = false
void restoreBrowseReturnScroll()
},
{ flush: 'post' },
)
onMounted(() => {
if (pendingRouteInstanceSwitch.value) {
instanceSelector.value?.requestSwitch(pendingRouteInstanceSwitch.value)
pendingRouteInstanceSwitch.value = null
}
const initialSearchDependencies = [initialInstanceFilterPromise]
if (instanceHideInstalled.value) initialSearchDependencies.push(initialInstalledProjectsPromise)
void Promise.allSettled(initialSearchDependencies).then(() => {
if (isUnmounted) return
restoreBrowseReturnPage()
void searchState.refreshSearch()
})
instance_listener(async (event: { event: string; instance_id: string }) => {
if (
activeInstance.value &&
event.instance_id === activeInstance.value.id &&
['synced', 'content_install_finished', 'content_install_failed'].includes(event.event)
) {
await refreshInstalledProjectIds()
await searchState.refreshSearch()
}
})
.then((unlisten) => {
if (isUnmounted) {
unlisten()
return
}
unlistenInstances = unlisten
})
.catch(handleError)
})
onUnmounted(() => {
isUnmounted = true
unlistenInstances?.()
})
function getProjectBrowseQuery() {
return {
...route.query,
b: route.fullPath,
}
}
const advancedFiltersCollapsed = computed({
get: () => themeStore.getFeatureFlag('advanced_filters_collapsed'),
set: (value) => {
themeStore.featureFlags['advanced_filters_collapsed'] = value
getSettings()
.then((settings) => {
settings.feature_flags['advanced_filters_collapsed'] = value
return setSettings(settings)
})
.catch(handleError)
},
})
provideBrowseManager({
tags,
projectType,
...searchState,
advancedFiltersCollapsed,
getProjectLink: (
result: Labrinth.Search.v2.ResultSearchProject & {
provider: 'modrinth' | 'curseforge' | 'mcarchive' | 'planet_minecraft'
provider_project_id?: string
},
) => ({
path:
result.provider === 'curseforge'
? `/project/curseforge/${result.provider_project_id}`
: result.provider === 'mcarchive'
? `/project/mcarchive/${result.slug}`
: result.provider === 'planet_minecraft'
? `/project/planet-minecraft/${result.provider_project_id}`
: result.provider === 'modrinth'
? `/project/${result.project_id ?? result.slug}`
: route.path,
query: getProjectBrowseQuery(),
}),
getServerProjectLink: (result: Labrinth.Search.v3.ResultSearchProject) => ({
path: `/project/${result.slug ?? result.project_id}`,
query: getProjectBrowseQuery(),
}),
selectableProjectTypes,
showProjectTypeTabs: computed(() => !isServerContext.value),
variant: 'app',
getCardActions,
installContext,
providedFilters: combinedProvidedFilters,
hideInstalled: computed({
get: () => (isServerContext.value ? serverHideInstalled.value : instanceHideInstalled.value),
set: (val: boolean) => {
if (isServerContext.value) {
serverHideInstalled.value = val
if (val) syncHiddenServerContentProjectIds()
} else {
instanceHideInstalled.value = val
if (val) syncHiddenInstanceProjectIds()
}
},
}),
showHideInstalled: computed(
() =>
!isWorldMapBrowse.value &&
((isServerContext.value && projectType.value !== 'modpack') || !!activeInstance.value),
),
hideInstalledLabel: computed(() =>
formatMessage(
isFromWorlds.value ? messages.hideAddedServers : commonMessages.hideInstalledContentLabel,
),
),
hideSelected: hideSelectedServerInstalls,
showHideSelected: computed(
() =>
isServerContext.value &&
projectType.value !== 'modpack' &&
queuedServerInstallCount.value > 0,
),
hideSelectedLabel: computed(() => formatMessage(commonMessages.hideSelectedContentLabel)),
onInstalled: onSearchResultInstalled,
serverPings,
getServerModpackContent,
onContextMenu: (event, result) => {
if ('provider' in result && result.provider !== 'modrinth') return
if (!('provider' in result) && projectType.value !== 'server') return
handleRightClick(event, result)
},
offline,
lockedFilterMessages,
displayMode,
displayModeOptions,
displayModeTooltip,
setDisplayMode,
})
</script>
<template>
<div data-onboarding-id="browse-content" class="flex flex-col gap-3 p-6">
<BrowsePageLayout>
<template #nav-tabs-actions>
<ButtonStyled size="large" type="transparent">
<button :disabled="translationLoading" @click="toggleTranslation">
<SpinnerIcon v-if="translationLoading" class="animate-spin" />
<LanguagesIcon v-else />
{{
formatMessage(
translationLoading
? messages.translating
: translationActive
? messages.showOriginal
: messages.translateProject,
)
}}
</button>
</ButtonStyled>
</template>
<template #search-bar-actions>
<ButtonStyled
v-if="!isServerContext && projectType !== 'server' && projectType !== 'modpack'"
size="standard"
type="standard"
>
<button class="flex min-w-0 items-center gap-2" @click="instanceSelector?.show()">
<InstanceIcon
v-if="activeInstance"
class="shrink-0"
size="1.25rem"
:icon-path="activeInstance.icon_path"
:instance-id="activeInstance.id"
:loader="activeInstance.loader"
/>
<PlusIcon v-else class="size-5 shrink-0" />
<span class="max-w-40 truncate font-medium">
{{ activeInstance?.name ?? formatMessage(messages.chooseInstance) }}
</span>
<span
aria-hidden="true"
class="flex size-4 shrink-0 items-center justify-center text-secondary"
>
<ChevronDownIcon class="size-4" />
</span>
</button>
</ButtonStyled>
<PopoutMenu v-if="projectType !== 'server' && !isWorldMapBrowse" placement="bottom-end">
<ButtonStyled size="standard" type="standard">
<button class="flex items-center gap-2">
<component :is="sourceIcon" class="h-5 w-5" />
<span>{{ formatMessage(currentSourceLabel) }}</span>
</button>
</ButtonStyled>
<template #menu>
<div class="flex w-min flex-col gap-1 p-1">
<ButtonStyled
v-for="option in sourceOptions"
:key="option.id"
:type="contentSource === option.id ? 'filled' : 'transparent'"
>
<button
class="flex w-full items-center gap-2 !justify-start text-left"
@click="selectContentSource(option.id)"
>
<component :is="option.icon" class="h-4 w-4" />
{{ formatMessage(option.label) }}
</button>
</ButtonStyled>
</div>
</template>
</PopoutMenu>
</template>
<template #after>
<ContextMenu ref="contextMenuRef" @option-clicked="handleOptionsClick">
<template #open_link>
<GlobeIcon /> {{ formatMessage(commonMessages.openInModrinthButton) }} <ExternalIcon />
</template>
<template #copy_link>
<ClipboardCopyIcon /> {{ formatMessage(commonMessages.copyLinkButton) }}
</template>
</ContextMenu>
</template>
<template #above-results>
<div
v-if="searchNotice"
class="flex items-center gap-2 px-1 pb-1 text-sm"
aria-live="polite"
>
<SparklesIcon class="size-3.5 shrink-0 text-brand" />
<p class="m-0 text-secondary">
{{ formatMessage(messages.fuzzySearchPrefix, searchNotice) }}
<button
type="button"
class="font-medium text-contrast underline-offset-2 hover:text-brand hover:underline"
@click="searchState.query.value = searchNotice.used"
>
{{ searchNotice.used }}
</button>
{{ formatMessage(messages.fuzzySearchSuffix) }}
</p>
</div>
<EmptyState
v-if="isWorldMapBrowse && !curseForgeCapability.configured"
type="empty-inbox"
:heading="formatMessage(messages.mapsUnavailable)"
:description="formatMessage(messages.mapsUnavailableDescription)"
/>
</template>
</BrowsePageLayout>
<CreationFlowModal
v-if="isServerContext && projectType === 'modpack'"
ref="serverSetupModalRef"
:type="serverFlowFrom === 'reset-server' ? 'reset-server' : 'server-onboarding'"
:available-loaders="['vanilla', 'fabric', 'neoforge', 'forge', 'quilt', 'paper', 'purpur']"
:show-snapshot-toggle="true"
:on-back="onServerFlowBack"
:search-modpacks="searchServerModpacks"
:get-project-versions="getServerProjectVersions"
:get-loader-manifest="getLoaderManifest"
@hide="() => {}"
@browse-modpacks="() => {}"
@create="handleServerModpackFlowCreate"
/>
<BrowseInstanceSelector
ref="instanceSelector"
:instances="contentSelection.instances.value"
:selected-instance="activeInstance"
:selected-count="contentSelection.selectedCount.value"
:install-current="contentSelection.installSelected"
:clear-current="contentSelection.clear"
@select="selectTargetInstance"
@cancel-switch="cancelTargetInstanceSwitch"
/>
<Teleport to="#sidebar-teleport-target">
<BrowseSidebar />
</Teleport>
</div>
</template>