mirror of
https://github.com/modrinth/code.git
synced 2026-08-25 00:55:25 +00:00
1155 lines
33 KiB
Vue
1155 lines
33 KiB
Vue
<script setup lang="ts">
|
|
import type { Labrinth } from '@modrinth/api-client'
|
|
import {
|
|
CheckIcon,
|
|
ClipboardCopyIcon,
|
|
ExternalIcon,
|
|
GlobeIcon,
|
|
PlusIcon,
|
|
SpinnerIcon,
|
|
} from '@modrinth/assets'
|
|
import type { BrowseInstallContentType, CardAction, ProjectType, Tags } from '@modrinth/ui'
|
|
import {
|
|
BrowsePageLayout,
|
|
BrowseSidebar,
|
|
commonMessages,
|
|
CreationFlowModal,
|
|
defineMessages,
|
|
getLatestMatchingInstallVersion,
|
|
getSelectedInstallPreferences,
|
|
getTargetInstallPreferences,
|
|
injectNotificationManager,
|
|
preferencesDiffer,
|
|
provideBrowseManager,
|
|
requestInstall,
|
|
stripServerRuntimeInstallFilters,
|
|
stripServerRuntimeInstallOverrides,
|
|
useBrowseSearch,
|
|
useDebugLogger,
|
|
useVIntl,
|
|
} from '@modrinth/ui'
|
|
import { useQueryClient } from '@tanstack/vue-query'
|
|
import { convertFileSrc } from '@tauri-apps/api/core'
|
|
import type { Ref } from 'vue'
|
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
|
import type { LocationQuery } from 'vue-router'
|
|
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
|
|
|
|
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
|
import { useAppServerBrowse } from '@/composables/browse/use-app-server-browse'
|
|
import {
|
|
get_project,
|
|
get_project_v3,
|
|
get_search_results_v3,
|
|
get_version_many,
|
|
} from '@/helpers/cache.js'
|
|
import { instance_listener } from '@/helpers/events.js'
|
|
import {
|
|
get as getInstance,
|
|
get_installed_project_ids as getInstalledProjectIds,
|
|
} from '@/helpers/instance'
|
|
import { get_loader_versions as getLoaderManifest } from '@/helpers/metadata'
|
|
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
|
|
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
|
|
import { get_instance_worlds } from '@/helpers/worlds'
|
|
import { injectContentInstall } from '@/providers/content-install'
|
|
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 } = injectContentInstall()
|
|
const queryClient = useQueryClient()
|
|
const debugLog = useDebugLogger('Browse')
|
|
|
|
const router = useRouter()
|
|
const route = useRoute()
|
|
const themeStore = useTheming()
|
|
const browseRouteActive = computed(() => route.path.startsWith('/browse/'))
|
|
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)')
|
|
const [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[]>),
|
|
])
|
|
|
|
const tags: Ref<Tags> = computed(() => ({
|
|
gameVersions: availableGameVersions.value ?? [],
|
|
loaders: loaders.value ?? [],
|
|
categories: categories.value ?? [],
|
|
}))
|
|
|
|
type Instance = {
|
|
game_version: string
|
|
loader: string
|
|
path: string
|
|
install_stage: string
|
|
icon_path?: string
|
|
name: string
|
|
link?: {
|
|
type: string
|
|
project_id: string
|
|
version_id: string
|
|
}
|
|
}
|
|
|
|
const instance: Ref<Instance | null> = ref(null)
|
|
const installedProjectIds: Ref<string[] | null> = ref(null)
|
|
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 allInstalledIds = computed(
|
|
() => new Set([...newlyInstalled.value, ...(installedProjectIds.value ?? [])]),
|
|
)
|
|
|
|
function syncHiddenInstanceProjectIds() {
|
|
hiddenInstanceProjectIds.value = new Set([
|
|
...(installedProjectIds.value ?? []),
|
|
...newlyInstalled.value,
|
|
])
|
|
hiddenInstanceProjectIdsInitialized.value = true
|
|
}
|
|
|
|
watch(
|
|
installedProjectIds,
|
|
(ids) => {
|
|
if (!ids) return
|
|
if (!hiddenInstanceProjectIdsInitialized.value) {
|
|
syncHiddenInstanceProjectIds()
|
|
}
|
|
},
|
|
{ immediate: true },
|
|
)
|
|
|
|
watchServerContextChanges()
|
|
|
|
await initInstanceContext()
|
|
|
|
async function refreshInstalledProjectIds() {
|
|
if (!route.query.i) return
|
|
|
|
if (route.query.from === 'worlds') {
|
|
const worlds = await get_instance_worlds(route.query.i as string).catch(handleError)
|
|
if (!worlds) 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
|
|
return
|
|
}
|
|
|
|
const ids = await getInstalledProjectIds(route.query.i as string).catch(handleError)
|
|
if (!ids) return
|
|
|
|
debugLog('installedProjectIds loaded', { count: ids.length })
|
|
installedProjectIds.value = ids
|
|
}
|
|
|
|
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()
|
|
|
|
if (route.query.i) {
|
|
instance.value = (await getInstance(route.query.i as string).catch(handleError)) ?? null
|
|
debugLog('instance loaded', {
|
|
name: instance.value?.name,
|
|
loader: instance.value?.loader,
|
|
gameVersion: instance.value?.game_version,
|
|
})
|
|
|
|
await refreshInstalledProjectIds()
|
|
|
|
if (instance.value?.link?.project_id) {
|
|
debugLog('checking linked project for server status', instance.value.link.project_id)
|
|
const projectV3 = await get_project_v3(
|
|
instance.value.link.project_id,
|
|
'must_revalidate',
|
|
).catch(handleError)
|
|
if (projectV3?.minecraft_server != null) {
|
|
debugLog('instance is a server instance')
|
|
isServerInstance.value = true
|
|
}
|
|
}
|
|
}
|
|
|
|
if (route.query.ai && !(route.params.projectType === 'modpack')) {
|
|
debugLog('setting instanceHideInstalled from query', route.query.ai)
|
|
instanceHideInstalled.value = route.query.ai === 'true'
|
|
}
|
|
}
|
|
|
|
const instanceFilters = computed(() => {
|
|
const filters = []
|
|
|
|
if (instance.value) {
|
|
const gameVersion = instance.value.game_version
|
|
if (gameVersion) {
|
|
filters.push({ type: 'game_version', option: gameVersion })
|
|
}
|
|
|
|
const platform = instance.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) 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(() =>
|
|
isServerContext.value ? serverContextFilters.value : instanceFilters.value,
|
|
)
|
|
|
|
const {
|
|
serverPings,
|
|
contextMenuRef,
|
|
updateServerHits,
|
|
getServerModpackContent,
|
|
getServerCardActions,
|
|
handleRightClick,
|
|
handleOptionsClick,
|
|
} = useAppServerBrowse({
|
|
instance,
|
|
isFromWorlds,
|
|
allInstalledIds,
|
|
newlyInstalled,
|
|
installingServerProjects,
|
|
playServerProject,
|
|
showAddServerToInstanceModal,
|
|
handleError,
|
|
router,
|
|
})
|
|
|
|
const offline = ref(!navigator.onLine)
|
|
window.addEventListener('offline', () => {
|
|
debugLog('went offline')
|
|
offline.value = true
|
|
})
|
|
window.addEventListener('online', () => {
|
|
debugLog('went online')
|
|
offline.value = false
|
|
})
|
|
|
|
const messages = defineMessages({
|
|
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',
|
|
},
|
|
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',
|
|
},
|
|
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',
|
|
},
|
|
})
|
|
|
|
const breadcrumbs = useBreadcrumbs()
|
|
const browseTitle = computed(() =>
|
|
formatMessage(isFromWorlds.value ? messages.discoverServers : messages.discoverContent),
|
|
)
|
|
breadcrumbs.setName('BrowseTitle', browseTitle.value)
|
|
if (instance.value) {
|
|
const instanceLink = `/instance/${encodeURIComponent(instance.value.id)}`
|
|
breadcrumbs.setContext({
|
|
name: instance.value.name,
|
|
link: isFromWorlds.value ? `${instanceLink}/worlds` : instanceLink,
|
|
})
|
|
} else {
|
|
breadcrumbs.setContext(null)
|
|
}
|
|
|
|
onBeforeRouteLeave(() => {
|
|
breadcrumbs.setContext({
|
|
name: browseTitle.value,
|
|
link: `/browse/${projectType.value}`,
|
|
query: route.query,
|
|
})
|
|
})
|
|
|
|
const projectType = ref<ProjectType>(route.params.projectType as ProjectType)
|
|
|
|
function resetInstanceContext() {
|
|
if (!instance.value) return
|
|
|
|
debugLog('instance context removed, resetting')
|
|
instance.value = null
|
|
installedProjectIds.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 (!browseRouteActive.value) {
|
|
return
|
|
}
|
|
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(() => {
|
|
let dataPacks = false,
|
|
mods = false,
|
|
modpacks = false
|
|
|
|
if (instance.value) {
|
|
if (
|
|
availableGameVersions.value &&
|
|
availableGameVersions.value.findIndex((x) => x.version === instance.value?.game_version) <=
|
|
availableGameVersions.value.findIndex((x) => x.version === '1.13') &&
|
|
!isServerInstance.value
|
|
) {
|
|
dataPacks = true
|
|
}
|
|
|
|
if (instance.value.loader !== 'vanilla') {
|
|
mods = true
|
|
}
|
|
} else {
|
|
dataPacks = true
|
|
mods = true
|
|
modpacks = true
|
|
}
|
|
|
|
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: 'Servers', href: `/browse/server${suffix}` }]
|
|
}
|
|
|
|
return [
|
|
{ label: 'Modpacks', href: `/browse/modpack${suffix}`, shown: modpacks },
|
|
{ label: 'Mods', href: `/browse/mod${suffix}`, shown: mods },
|
|
{ label: 'Resource Packs', href: `/browse/resourcepack${suffix}` },
|
|
{ label: 'Data Packs', href: `/browse/datapack${suffix}`, shown: dataPacks },
|
|
{ label: 'Shaders', href: `/browse/shader${suffix}` },
|
|
{ label: 'Servers', href: `/browse/server${suffix}`, shown: !instance.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 (instance.value) {
|
|
return {
|
|
name: instance.value.name,
|
|
loader: instance.value.loader,
|
|
gameVersion: instance.value.game_version,
|
|
iconSrc: instance.value.icon_path ? convertFileSrc(instance.value.icon_path) : null,
|
|
backUrl: `/instance/${encodeURIComponent(instance.value.id)}${isFromWorlds.value ? '/worlds' : ''}`,
|
|
backLabel: formatMessage(messages.backToInstance),
|
|
heading: formatMessage(
|
|
isFromWorlds.value ? messages.addServersToInstance : commonMessages.installingContentLabel,
|
|
),
|
|
warning:
|
|
isServerInstance.value && !isFromWorlds.value
|
|
? formatMessage(messages.serverInstanceContentWarning)
|
|
: undefined,
|
|
}
|
|
}
|
|
return null
|
|
})
|
|
|
|
const installingProjectIds = ref<Set<string>>(new Set())
|
|
|
|
function setProjectInstalling(projectId: string, installing: boolean) {
|
|
const next = new Set(installingProjectIds.value)
|
|
if (installing) {
|
|
next.add(projectId)
|
|
} else {
|
|
next.delete(projectId)
|
|
}
|
|
installingProjectIds.value = next
|
|
}
|
|
|
|
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: instance.value?.game_version,
|
|
loader: instance.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 = instance.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 }
|
|
}
|
|
|
|
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
|
|
}
|
|
const isInstalled =
|
|
projectResult.installed ||
|
|
allInstalledIds.value.has(projectResult.project_id || '') ||
|
|
serverContentProjectIds.value.has(projectResult.project_id || '') ||
|
|
serverContextServerData.value?.upstream?.project_id === projectResult.project_id
|
|
const isInstalling = installingProjectIds.value.has(projectResult.project_id)
|
|
|
|
if (
|
|
isServerContext.value &&
|
|
['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 () => {
|
|
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)
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
},
|
|
},
|
|
]
|
|
}
|
|
|
|
const isModpack = projectResult.project_types?.includes('modpack')
|
|
const shouldUseInstallIcon = !!instance.value || isModpack
|
|
|
|
return [
|
|
{
|
|
key: 'install',
|
|
label: formatMessage(
|
|
isInstalling
|
|
? messages.installingToServer
|
|
: isInstalled
|
|
? commonMessages.installedLabel
|
|
: shouldUseInstallIcon
|
|
? commonMessages.installButton
|
|
: messages.addToAnInstance,
|
|
),
|
|
icon: isInstalling ? SpinnerIcon : isInstalled ? CheckIcon : PlusIcon,
|
|
iconClass: isInstalling ? 'animate-spin' : undefined,
|
|
disabled: isInstalled || isInstalling,
|
|
color: 'brand',
|
|
type: 'outlined',
|
|
onClick: async () => {
|
|
setProjectInstalling(projectResult.project_id, true)
|
|
try {
|
|
const selectedInstall = instance.value
|
|
? await chooseInstanceInstallVersion(projectResult, currentProjectType)
|
|
: { versionId: null as string | null }
|
|
if (selectedInstall === null) {
|
|
setProjectInstalling(projectResult.project_id, false)
|
|
return
|
|
}
|
|
const selectedPreferences = getCurrentSelectedInstallPreferences(currentProjectType)
|
|
await installVersion(
|
|
projectResult.project_id,
|
|
selectedInstall.versionId,
|
|
instance.value ? instance.value.id : null,
|
|
'SearchCard',
|
|
(versionId, installedProjectIds) => {
|
|
setProjectInstalling(projectResult.project_id, false)
|
|
if (versionId) {
|
|
onSearchResultsInstalled(installedProjectIds ?? [projectResult.project_id])
|
|
}
|
|
},
|
|
(profile) => {
|
|
router.push(`/instance/${profile}`)
|
|
},
|
|
{
|
|
preferredLoader: instance.value?.loader ?? selectedPreferences.loaders?.[0],
|
|
preferredGameVersion:
|
|
instance.value?.game_version ?? selectedPreferences.gameVersions?.[0],
|
|
},
|
|
)
|
|
} catch (err) {
|
|
setProjectInstalling(projectResult.project_id, false)
|
|
handleError(err)
|
|
}
|
|
},
|
|
},
|
|
]
|
|
}
|
|
|
|
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]))
|
|
}
|
|
|
|
async function search(requestParams: string) {
|
|
debugLog('searching v3', requestParams)
|
|
const isServer = projectType.value === 'server'
|
|
|
|
const rawResults = await queryClient.fetchQuery({
|
|
queryKey: ['search', 'v3', requestParams],
|
|
queryFn: () =>
|
|
get_search_results_v3(requestParams, 'must_revalidate') as Promise<{
|
|
result: Labrinth.Search.v3.SearchResults & {
|
|
hits: (Labrinth.Search.v3.ResultSearchProject & { installed?: boolean })[]
|
|
}
|
|
} | null>,
|
|
staleTime: 30_000,
|
|
})
|
|
|
|
if (!rawResults) {
|
|
return {
|
|
projectHits: [],
|
|
serverHits: [],
|
|
total_hits: 0,
|
|
per_page: 20,
|
|
}
|
|
}
|
|
|
|
if (isServer) {
|
|
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,
|
|
} as unknown as Labrinth.Search.v2.ResultSearchProject & { installed?: boolean }
|
|
|
|
if (instance.value || isServerContext.value) {
|
|
const installedIds = instance.value
|
|
? new Set([...newlyInstalled.value, ...(installedProjectIds.value ?? [])])
|
|
: serverContentProjectIds.value
|
|
mapped.installed = installedIds.has(hit.project_id)
|
|
}
|
|
|
|
return mapped
|
|
})
|
|
|
|
return {
|
|
projectHits: hits,
|
|
serverHits: [],
|
|
total_hits: rawResults.result.total_hits,
|
|
per_page: rawResults.result.hits_per_page,
|
|
}
|
|
}
|
|
|
|
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,
|
|
),
|
|
}))
|
|
|
|
const searchState = useBrowseSearch({
|
|
projectType,
|
|
tags,
|
|
active: browseRouteActive,
|
|
providedFilters: combinedProvidedFilters,
|
|
search,
|
|
persistentQueryParams: ['i', 'ai', 'shi', 'sid', 'wid', 'from'],
|
|
getExtraQueryParams: () => ({
|
|
sid: serverIdQuery.value || undefined,
|
|
wid: effectiveServerWorldId.value || undefined,
|
|
ai: instanceHideInstalled.value ? 'true' : undefined,
|
|
shi: serverHideInstalled.value ? 'true' : undefined,
|
|
}),
|
|
})
|
|
|
|
watch(
|
|
[
|
|
() => searchState.query.value,
|
|
() => searchState.currentFilters.value,
|
|
() => searchState.serverCurrentFilters.value,
|
|
() => projectType.value,
|
|
],
|
|
() => {
|
|
if (isServerContext.value) {
|
|
syncHiddenServerContentProjectIds()
|
|
} else if (instance.value) {
|
|
syncHiddenInstanceProjectIds()
|
|
}
|
|
},
|
|
{ deep: true },
|
|
)
|
|
|
|
watch(queuedServerInstallCount, (count) => {
|
|
if (count === 0) {
|
|
hideSelectedServerInstalls.value = false
|
|
}
|
|
})
|
|
|
|
if (instance.value?.game_version) {
|
|
const gv = instance.value.game_version
|
|
const alreadyHasGv = searchState.serverCurrentFilters.value.some(
|
|
(f) => f.type === 'server_game_version' && f.option === gv,
|
|
)
|
|
if (!alreadyHasGv) {
|
|
searchState.serverCurrentFilters.value.push({ type: 'server_game_version', option: gv })
|
|
}
|
|
}
|
|
|
|
void searchState.refreshSearch()
|
|
|
|
type UnlistenFn = () => void
|
|
|
|
let isUnmounted = false
|
|
let unlistenInstances: UnlistenFn | null = null
|
|
|
|
onMounted(() => {
|
|
instance_listener(async (event: { event: string; instance_id: string }) => {
|
|
if (instance.value && event.instance_id === instance.value.id && event.event === 'synced') {
|
|
await refreshInstalledProjectIds()
|
|
await searchState.refreshSearch()
|
|
}
|
|
})
|
|
.then((unlisten) => {
|
|
if (isUnmounted) {
|
|
unlisten()
|
|
return
|
|
}
|
|
|
|
unlistenInstances = unlisten
|
|
})
|
|
.catch(handleError)
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
isUnmounted = true
|
|
unlistenInstances?.()
|
|
})
|
|
|
|
function getProjectBrowseQuery() {
|
|
if (!browseRouteActive.value) {
|
|
return undefined
|
|
}
|
|
if (!installContext.value) return undefined
|
|
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) => ({
|
|
path: `/project/${result.project_id ?? result.slug}`,
|
|
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(
|
|
() => (isServerContext.value && projectType.value !== 'modpack') || !!instance.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: handleRightClick,
|
|
offline,
|
|
lockedFilterMessages,
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="flex flex-col gap-3 p-6">
|
|
<BrowsePageLayout>
|
|
<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>
|
|
</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"
|
|
/>
|
|
<Teleport v-if="browseRouteActive" to="#sidebar-teleport-target">
|
|
<BrowseSidebar />
|
|
</Teleport>
|
|
</div>
|
|
</template>
|