mirror of
https://github.com/modrinth/code.git
synced 2026-08-31 03:55:59 +00:00
feat: qa + app routing bugs
This commit is contained in:
@@ -146,6 +146,7 @@ const APP_LEFT_NAV_WIDTH = '4rem'
|
||||
const APP_SIDEBAR_WIDTH = 300
|
||||
const INTERCOM_BUBBLE_DEFAULT_PADDING = 20
|
||||
const PRIDE_FUNDRAISER_END_DATE = new Date('2026-07-01T00:00:00Z').getTime()
|
||||
const ROUTE_SUSPENSE_TIMEOUT_MS = 60_000
|
||||
const credentials = ref()
|
||||
const sidebarToggled = ref(true)
|
||||
const unsubscribeSidebarToggle = themeStore.$subscribe(() => {
|
||||
@@ -155,6 +156,22 @@ const forceSidebar = computed(
|
||||
() => route.path.startsWith('/browse') || route.path.startsWith('/project'),
|
||||
)
|
||||
const sidebarVisible = computed(() => sidebarToggled.value || forceSidebar.value)
|
||||
const keepAliveRouteComponents = computed(() => [
|
||||
...new Set(
|
||||
router
|
||||
.getRoutes()
|
||||
.map((route) => route.meta.keepAliveComponent)
|
||||
.filter((name) => typeof name === 'string'),
|
||||
),
|
||||
])
|
||||
|
||||
function getRouteViewKey(viewRoute) {
|
||||
const keepAliveKey = viewRoute.meta.keepAliveKey
|
||||
if (typeof keepAliveKey === 'function') return keepAliveKey(viewRoute)
|
||||
if (typeof keepAliveKey === 'string') return keepAliveKey
|
||||
return undefined
|
||||
}
|
||||
|
||||
const hostingRouteActive = computed(() => route.path.startsWith('/hosting'))
|
||||
const prideFundraiserEnabled = computed(
|
||||
() => themeStore.getFeatureFlag('pride_fundraiser') && Date.now() < PRIDE_FUNDRAISER_END_DATE,
|
||||
@@ -1673,11 +1690,17 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
>
|
||||
{{ formatMessage(messages.authUnreachableBody) }}
|
||||
</Admonition>
|
||||
<RouterView v-slot="{ Component }">
|
||||
<RouterView v-slot="{ Component, route: viewRoute }">
|
||||
<template v-if="Component">
|
||||
<Suspense @pending="onSuspensePending" @resolve="onSuspenseResolve">
|
||||
<component :is="Component"></component>
|
||||
</Suspense>
|
||||
<KeepAlive :include="keepAliveRouteComponents" :max="3">
|
||||
<Suspense
|
||||
:timeout="ROUTE_SUSPENSE_TIMEOUT_MS"
|
||||
@pending="onSuspensePending"
|
||||
@resolve="onSuspenseResolve"
|
||||
>
|
||||
<component :is="Component" :key="getRouteViewKey(viewRoute)"></component>
|
||||
</Suspense>
|
||||
</KeepAlive>
|
||||
</template>
|
||||
</RouterView>
|
||||
</div>
|
||||
|
||||
@@ -8,13 +8,18 @@ import type { ComputedRef, Ref } from 'vue'
|
||||
import { onUnmounted, ref, shallowRef } from 'vue'
|
||||
import type { Router } from 'vue-router'
|
||||
|
||||
import {
|
||||
fetchCachedServerStatus,
|
||||
getFreshCachedServerStatus,
|
||||
} from '@/composables/instances/use-server-status-query'
|
||||
import { process_listener } from '@/helpers/events'
|
||||
import { kill, list as listInstances } from '@/helpers/instance'
|
||||
import { get_by_instance_id } from '@/helpers/process'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import { add_server_to_instance, getServerAddress, getServerLatency } from '@/helpers/worlds'
|
||||
import { add_server_to_instance, getServerAddress } from '@/helpers/worlds'
|
||||
|
||||
interface BrowseServerInstance {
|
||||
id: string
|
||||
name: string
|
||||
path: string
|
||||
}
|
||||
@@ -68,12 +73,10 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
|
||||
const queryClient = useQueryClient()
|
||||
const debugLog = useDebugLogger('BrowseServer')
|
||||
const serverPings = shallowRef<Record<string, number | undefined>>({})
|
||||
const serverPingCache = new Map<string, number | undefined>()
|
||||
const pendingServerPings = new Map<string, Promise<number | undefined>>()
|
||||
const runningServerProjects = ref<Record<string, string>>({})
|
||||
const lastServerHits = shallowRef<Labrinth.Search.v3.ResultSearchProject[]>([])
|
||||
const contextMenuRef = ref<ContextMenuHandle | null>(null)
|
||||
let serverPingCacheActive = true
|
||||
let serverPingsActive = true
|
||||
let unlistenProcesses: (() => void) | null = null
|
||||
|
||||
async function checkServerRunningStates(hits: Labrinth.Search.v3.ResultSearchProject[]) {
|
||||
@@ -146,37 +149,26 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
|
||||
})
|
||||
const nextPings = { ...serverPings.value }
|
||||
for (const { hit, address } of pingsToFetch) {
|
||||
if (serverPingCache.has(address)) {
|
||||
nextPings[hit.project_id] = serverPingCache.get(address)
|
||||
const cachedStatus = getFreshCachedServerStatus(queryClient, address)
|
||||
if (cachedStatus) {
|
||||
nextPings[hit.project_id] = cachedStatus.ping
|
||||
}
|
||||
}
|
||||
serverPings.value = nextPings
|
||||
|
||||
await Promise.all(
|
||||
pingsToFetch.map(async ({ hit, address }) => {
|
||||
if (serverPingCache.has(address)) return
|
||||
if (getFreshCachedServerStatus(queryClient, address)) return
|
||||
|
||||
let pending = pendingServerPings.get(address)
|
||||
if (!pending) {
|
||||
pending = getServerLatency(address)
|
||||
.then((latency) => {
|
||||
if (serverPingCacheActive) serverPingCache.set(address, latency)
|
||||
return latency
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`Failed to ping server ${address}:`, error)
|
||||
if (serverPingCacheActive) serverPingCache.set(address, undefined)
|
||||
return undefined
|
||||
})
|
||||
.finally(() => {
|
||||
pendingServerPings.delete(address)
|
||||
})
|
||||
pendingServerPings.set(address, pending)
|
||||
try {
|
||||
const status = await fetchCachedServerStatus(queryClient, address)
|
||||
if (!serverPingsActive) return
|
||||
serverPings.value = { ...serverPings.value, [hit.project_id]: status.ping }
|
||||
} catch (error) {
|
||||
console.error(`Failed to ping server ${address}:`, error)
|
||||
if (!serverPingsActive) return
|
||||
serverPings.value = { ...serverPings.value, [hit.project_id]: undefined }
|
||||
}
|
||||
|
||||
const latency = await pending
|
||||
if (!serverPingCacheActive) return
|
||||
serverPings.value = { ...serverPings.value, [hit.project_id]: latency }
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -308,10 +300,8 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
|
||||
.catch(options.handleError)
|
||||
|
||||
onUnmounted(() => {
|
||||
serverPingCacheActive = false
|
||||
serverPingsActive = false
|
||||
unlistenProcesses?.()
|
||||
serverPingCache.clear()
|
||||
pendingServerPings.clear()
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { QueryClient } from '@tanstack/vue-query'
|
||||
|
||||
import {
|
||||
get_server_status,
|
||||
normalizeServerAddress,
|
||||
type ProtocolVersion,
|
||||
type ServerStatus,
|
||||
} from '@/helpers/worlds'
|
||||
|
||||
export const SERVER_STATUS_CACHE_MS = 10 * 60 * 1000
|
||||
|
||||
function getProtocolVersionKey(protocolVersion: ProtocolVersion | null) {
|
||||
if (!protocolVersion) return 'default'
|
||||
return `${protocolVersion.version}:${protocolVersion.legacy ? 'legacy' : 'modern'}`
|
||||
}
|
||||
|
||||
export function getServerStatusQueryKey(
|
||||
address: string,
|
||||
protocolVersion: ProtocolVersion | null = null,
|
||||
) {
|
||||
return [
|
||||
'minecraft-server-status',
|
||||
normalizeServerAddress(address) || address.trim().toLowerCase(),
|
||||
getProtocolVersionKey(protocolVersion),
|
||||
] as const
|
||||
}
|
||||
|
||||
export function getFreshCachedServerStatus(
|
||||
queryClient: QueryClient,
|
||||
address: string,
|
||||
protocolVersion: ProtocolVersion | null = null,
|
||||
) {
|
||||
const queryKey = getServerStatusQueryKey(address, protocolVersion)
|
||||
const updatedAt = queryClient.getQueryState(queryKey)?.dataUpdatedAt ?? 0
|
||||
if (!updatedAt || Date.now() - updatedAt >= SERVER_STATUS_CACHE_MS) return undefined
|
||||
return queryClient.getQueryData<ServerStatus>(queryKey)
|
||||
}
|
||||
|
||||
export async function fetchCachedServerStatus(
|
||||
queryClient: QueryClient,
|
||||
address: string,
|
||||
protocolVersion: ProtocolVersion | null = null,
|
||||
) {
|
||||
return await queryClient.fetchQuery({
|
||||
queryKey: getServerStatusQueryKey(address, protocolVersion),
|
||||
queryFn: () => get_server_status(address, protocolVersion),
|
||||
staleTime: SERVER_STATUS_CACHE_MS,
|
||||
gcTime: SERVER_STATUS_CACHE_MS,
|
||||
})
|
||||
}
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
requestInstall,
|
||||
SelectedProjectsFloatingBar,
|
||||
useBrowseSearch,
|
||||
useDebugLogger,
|
||||
useStickyObserver,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
@@ -61,19 +60,26 @@ import {
|
||||
import { useBreadcrumbs } from '@/store/breadcrumbs'
|
||||
import { useTheming } from '@/store/state'
|
||||
|
||||
defineOptions({
|
||||
name: 'Browse',
|
||||
})
|
||||
|
||||
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 })
|
||||
const serverInstallContent = createServerInstallContent({
|
||||
serverSetupModalRef,
|
||||
isRouteInContext: (targetRoute) => targetRoute.path.startsWith('/browse/'),
|
||||
})
|
||||
provideServerInstallContent(serverInstallContent)
|
||||
const {
|
||||
serverIdQuery,
|
||||
@@ -110,8 +116,6 @@ const {
|
||||
handleServerModpackFlowCreate,
|
||||
markServerProjectInstalled,
|
||||
} = serverInstallContent
|
||||
|
||||
debugLog('fetching tags (categories, loaders, gameVersions)')
|
||||
const [categories, loaders, availableGameVersions] = await Promise.all([
|
||||
get_categories()
|
||||
.catch(handleError)
|
||||
@@ -131,6 +135,7 @@ const tags: Ref<Tags> = computed(() => ({
|
||||
}))
|
||||
|
||||
type Instance = {
|
||||
id: string
|
||||
game_version: string
|
||||
loader: string
|
||||
path: string
|
||||
@@ -198,7 +203,6 @@ async function refreshInstalledProjectIds() {
|
||||
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
|
||||
}
|
||||
@@ -206,45 +210,29 @@ async function refreshInstalledProjectIds() {
|
||||
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'
|
||||
}
|
||||
}
|
||||
@@ -295,7 +283,7 @@ function syncHiddenServerContentProjectIds() {
|
||||
watch(
|
||||
serverContentProjectIds,
|
||||
() => {
|
||||
if (!hiddenServerContentProjectIdsInitialized.value) {
|
||||
if (!hiddenServerContentProjectIdsInitialized.value || serverHideInstalled.value) {
|
||||
syncHiddenServerContentProjectIds()
|
||||
}
|
||||
},
|
||||
@@ -368,11 +356,9 @@ const {
|
||||
|
||||
const offline = ref(!navigator.onLine)
|
||||
window.addEventListener('offline', () => {
|
||||
debugLog('went offline')
|
||||
offline.value = true
|
||||
})
|
||||
window.addEventListener('online', () => {
|
||||
debugLog('went online')
|
||||
offline.value = false
|
||||
})
|
||||
|
||||
@@ -480,7 +466,6 @@ 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
|
||||
@@ -495,6 +480,9 @@ function resetInstanceContext() {
|
||||
watch(
|
||||
() => route.params.projectType as ProjectType,
|
||||
async (newType) => {
|
||||
if (!browseRouteActive.value) {
|
||||
return
|
||||
}
|
||||
if (isSetupServerContext.value) {
|
||||
enforceSetupModpackRoute(newType)
|
||||
if (newType !== 'modpack') return
|
||||
@@ -502,7 +490,6 @@ watch(
|
||||
|
||||
if (!newType || newType === projectType.value) return
|
||||
|
||||
debugLog('projectType route param changed', { from: projectType.value, to: newType })
|
||||
projectType.value = newType
|
||||
},
|
||||
)
|
||||
@@ -616,6 +603,7 @@ const installContext = computed(() => {
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const stickyInstallHeaderRef = ref<HTMLElement | null>(null)
|
||||
const { isStuck: isInstallHeaderStuck } = useStickyObserver(
|
||||
stickyInstallHeaderRef,
|
||||
@@ -718,11 +706,10 @@ function getCardActions(
|
||||
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 isInstalled = isServerContext.value
|
||||
? serverContentProjectIds.value.has(projectResult.project_id || '') ||
|
||||
serverContextServerData.value?.upstream?.project_id === projectResult.project_id
|
||||
: projectResult.installed || allInstalledIds.value.has(projectResult.project_id || '')
|
||||
const isInstalling = installingProjectIds.value.has(projectResult.project_id)
|
||||
|
||||
if (
|
||||
@@ -886,7 +873,6 @@ function onSearchResultsInstalled(ids: string[]) {
|
||||
}
|
||||
|
||||
async function search(requestParams: string) {
|
||||
debugLog('searching v3', requestParams)
|
||||
const isServer = projectType.value === 'server'
|
||||
|
||||
const rawResults = await queryClient.fetchQuery({
|
||||
@@ -968,6 +954,7 @@ const lockedFilterMessages = computed(() => ({
|
||||
const searchState = useBrowseSearch({
|
||||
projectType,
|
||||
tags,
|
||||
active: browseRouteActive,
|
||||
providedFilters: combinedProvidedFilters,
|
||||
search,
|
||||
persistentQueryParams: ['i', 'ai', 'shi', 'sid', 'wid', 'from'],
|
||||
@@ -1043,7 +1030,12 @@ onUnmounted(() => {
|
||||
})
|
||||
|
||||
function getProjectBrowseQuery() {
|
||||
if (!installContext.value) return undefined
|
||||
if (!browseRouteActive.value) {
|
||||
return undefined
|
||||
}
|
||||
if (!installContext.value) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
...route.query,
|
||||
b: route.fullPath,
|
||||
@@ -1110,10 +1102,10 @@ provideBrowseManager({
|
||||
<div
|
||||
v-if="installContext"
|
||||
ref="stickyInstallHeaderRef"
|
||||
class="sticky top-0 z-20 -mx-6 -mt-6 rounded-tl-[--radius-xl] border-0 border-b border-solid bg-surface-1 p-3 border-surface-5"
|
||||
class="sticky top-0 z-20 -mx-6 -mt-6 rounded-tl-[--radius-xl] border-0 border-b border-solid border-divider bg-surface-1 px-6 pt-6"
|
||||
:class="[isInstallHeaderStuck ? 'border-t' : '']"
|
||||
>
|
||||
<BrowseInstallHeader />
|
||||
<BrowseInstallHeader bottom-padding />
|
||||
</div>
|
||||
|
||||
<SelectedProjectsFloatingBar v-if="installContext" />
|
||||
@@ -1145,7 +1137,7 @@ provideBrowseManager({
|
||||
@create="handleServerModpackFlowCreate"
|
||||
/>
|
||||
|
||||
<Teleport to="#sidebar-teleport-target">
|
||||
<Teleport v-if="browseRouteActive" to="#sidebar-teleport-target">
|
||||
<BrowseSidebar />
|
||||
</Teleport>
|
||||
</div>
|
||||
|
||||
@@ -127,6 +127,7 @@ import {
|
||||
ExternalIcon,
|
||||
EyeIcon,
|
||||
FolderOpenIcon,
|
||||
TagCategoryGamepad2Icon as Gamepad2Icon,
|
||||
GlobeIcon,
|
||||
HashIcon,
|
||||
MoreVerticalIcon,
|
||||
@@ -135,7 +136,6 @@ import {
|
||||
PlusIcon,
|
||||
SettingsIcon,
|
||||
StopCircleIcon,
|
||||
TagCategoryGamepad2Icon as Gamepad2Icon,
|
||||
TerminalSquareIcon,
|
||||
TimerIcon,
|
||||
UpdatedIcon,
|
||||
@@ -145,9 +145,9 @@ import {
|
||||
Avatar,
|
||||
formatLoaderLabel,
|
||||
injectNotificationManager,
|
||||
LoaderIcon as ServerLoaderIcon,
|
||||
NavTabs,
|
||||
PageHeader,
|
||||
LoaderIcon as ServerLoaderIcon,
|
||||
ServerOnlinePlayers,
|
||||
ServerPing,
|
||||
ServerRecentPlays,
|
||||
@@ -161,12 +161,16 @@ import dayjs from 'dayjs'
|
||||
import duration from 'dayjs/plugin/duration'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import { computed, onUnmounted, ref, shallowRef, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { onBeforeRouteUpdate, useRoute, useRouter, type LocationQuery } from 'vue-router'
|
||||
|
||||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import ExportModal from '@/components/ui/ExportModal.vue'
|
||||
import InstanceSettingsModal from '@/components/ui/modal/InstanceSettingsModal.vue'
|
||||
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
|
||||
import {
|
||||
fetchCachedServerStatus,
|
||||
getFreshCachedServerStatus,
|
||||
} from '@/composables/instances/use-server-status-query'
|
||||
import { useInstanceConsole } from '@/composables/useInstanceConsole'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { get_project_v3 } from '@/helpers/cache.js'
|
||||
@@ -177,7 +181,7 @@ import { type InstanceContentData, loadInstanceContentData } from '@/helpers/ins
|
||||
import { get_by_instance_id } from '@/helpers/process'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import { createInstanceShortcut, showInstanceInFolder } from '@/helpers/utils.js'
|
||||
import { get_server_status, refreshWorlds } from '@/helpers/worlds'
|
||||
import { refreshWorlds, type ServerStatus } from '@/helpers/worlds'
|
||||
import { injectServerInstall } from '@/providers/server-install'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
import { useBreadcrumbs, useTheming } from '@/store/state'
|
||||
@@ -222,13 +226,16 @@ const selected = ref<unknown[]>([])
|
||||
|
||||
const minecraftServer = computed(() => linkedProjectV3.value?.minecraft_server)
|
||||
const javaServerPingData = computed(() => linkedProjectV3.value?.minecraft_java_server?.ping?.data)
|
||||
const statusOnline = computed(() => !!javaServerPingData.value)
|
||||
const liveServerStatusOnline = ref(false)
|
||||
const statusOnline = computed(() => liveServerStatusOnline.value || !!javaServerPingData.value)
|
||||
const recentPlays = computed(
|
||||
() => linkedProjectV3.value?.minecraft_java_server?.verified_plays_2w ?? undefined,
|
||||
)
|
||||
const playersOnline = ref<number | undefined>(undefined)
|
||||
const ping = ref<number | undefined>(undefined)
|
||||
const loadingServerPing = ref(false)
|
||||
const activeInstanceId = ref<string>()
|
||||
let fetchInstanceRequestId = 0
|
||||
|
||||
watch(
|
||||
() => router.currentRoute.value,
|
||||
@@ -240,24 +247,68 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function isContentSubpageRoute(routeName = displayedInstanceRoute.value.name) {
|
||||
type InstanceRouteContext = {
|
||||
name: unknown
|
||||
path: string
|
||||
query: LocationQuery
|
||||
}
|
||||
|
||||
type InstancePageData = {
|
||||
instanceId: string
|
||||
instance?: GameInstance
|
||||
linkedProjectV3?: Labrinth.Projects.v3.Project
|
||||
isServerInstance: boolean
|
||||
preloadedContent: InstanceContentData | null
|
||||
}
|
||||
|
||||
function applyServerStatus(status: ServerStatus) {
|
||||
playersOnline.value = status.players?.online
|
||||
ping.value = status.ping
|
||||
liveServerStatusOnline.value = true
|
||||
loadingServerPing.value = true
|
||||
}
|
||||
|
||||
function isContentSubpageRoute(routeName: unknown = displayedInstanceRoute.value.name) {
|
||||
return typeof routeName === 'string' && contentSubpageRouteNames.has(routeName)
|
||||
}
|
||||
|
||||
async function fetchInstance() {
|
||||
isServerInstance.value = false
|
||||
linkedProjectV3.value = undefined
|
||||
preloadedContent.value = null
|
||||
function resetServerStatus() {
|
||||
ping.value = undefined
|
||||
playersOnline.value = undefined
|
||||
liveServerStatusOnline.value = false
|
||||
loadingServerPing.value = false
|
||||
}
|
||||
|
||||
const nextInstance = await get(route.params.id as string).catch(handleError)
|
||||
function isCurrentInstanceRequest(requestId: number, instanceId: string) {
|
||||
return (
|
||||
requestId === fetchInstanceRequestId &&
|
||||
route.path.startsWith('/instance') &&
|
||||
route.params.id === instanceId
|
||||
)
|
||||
}
|
||||
|
||||
function setInstanceBreadcrumbs(nextInstance: GameInstance, routeContext: InstanceRouteContext) {
|
||||
breadcrumbs.setName(
|
||||
'Instance',
|
||||
nextInstance.name.length > 40 ? nextInstance.name.substring(0, 40) + '...' : nextInstance.name,
|
||||
)
|
||||
breadcrumbs.setContext({
|
||||
name: nextInstance.name,
|
||||
link: routeContext.path,
|
||||
query: routeContext.query,
|
||||
})
|
||||
}
|
||||
|
||||
async function loadInstancePageData(
|
||||
instanceId: string,
|
||||
routeName: unknown = displayedInstanceRoute.value.name,
|
||||
): Promise<InstancePageData> {
|
||||
const nextInstance = await get(instanceId).catch(handleError)
|
||||
let nextLinkedProjectV3: Labrinth.Projects.v3.Project | undefined
|
||||
let nextIsServerInstance = false
|
||||
|
||||
const contentPreloadPromise =
|
||||
nextInstance && isContentSubpageRoute()
|
||||
nextInstance && isContentSubpageRoute(routeName)
|
||||
? loadInstanceContentData(nextInstance.id, undefined, handleError)
|
||||
: Promise.resolve(null)
|
||||
|
||||
@@ -275,59 +326,113 @@ async function fetchInstance() {
|
||||
|
||||
const nextPreloadedContent = await contentPreloadPromise
|
||||
|
||||
instance.value = nextInstance ?? undefined
|
||||
linkedProjectV3.value = nextLinkedProjectV3
|
||||
isServerInstance.value = nextIsServerInstance
|
||||
preloadedContent.value = nextPreloadedContent
|
||||
return {
|
||||
instanceId,
|
||||
instance: nextInstance ?? undefined,
|
||||
linkedProjectV3: nextLinkedProjectV3,
|
||||
isServerInstance: nextIsServerInstance,
|
||||
preloadedContent: nextPreloadedContent,
|
||||
}
|
||||
}
|
||||
|
||||
fetchDeferredData()
|
||||
function applyInstancePageData(data: InstancePageData, routeContext: InstanceRouteContext) {
|
||||
activeInstanceId.value = data.instanceId
|
||||
resetServerStatus()
|
||||
playing.value = false
|
||||
|
||||
if (nextInstance) {
|
||||
instance.value = data.instance
|
||||
linkedProjectV3.value = data.linkedProjectV3
|
||||
isServerInstance.value = data.isServerInstance
|
||||
preloadedContent.value = data.preloadedContent
|
||||
|
||||
if (data.instance) {
|
||||
setInstanceBreadcrumbs(data.instance, routeContext)
|
||||
}
|
||||
|
||||
fetchDeferredData(data.instanceId)
|
||||
|
||||
if (data.instance) {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ['worlds', nextInstance.id],
|
||||
queryFn: () => refreshWorlds(nextInstance.id),
|
||||
queryKey: ['worlds', data.instance.id],
|
||||
queryFn: () => refreshWorlds(data.instance!.id),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function fetchDeferredData() {
|
||||
async function fetchInstance(instanceId = route.params.id as string) {
|
||||
const requestId = ++fetchInstanceRequestId
|
||||
const data = await loadInstancePageData(instanceId)
|
||||
if (!isCurrentInstanceRequest(requestId, instanceId)) return
|
||||
|
||||
applyInstancePageData(data, {
|
||||
name: route.name,
|
||||
path: route.path,
|
||||
query: route.query,
|
||||
})
|
||||
}
|
||||
|
||||
function fetchDeferredData(instanceId: string) {
|
||||
const serverAddress = linkedProjectV3.value?.minecraft_java_server?.address
|
||||
if (isServerInstance.value && serverAddress) {
|
||||
get_server_status(serverAddress)
|
||||
const cachedStatus = getFreshCachedServerStatus(queryClient, serverAddress)
|
||||
if (cachedStatus) {
|
||||
applyServerStatus(cachedStatus)
|
||||
} else {
|
||||
playersOnline.value = undefined
|
||||
ping.value = undefined
|
||||
loadingServerPing.value = false
|
||||
}
|
||||
|
||||
fetchCachedServerStatus(queryClient, serverAddress)
|
||||
.then((status) => {
|
||||
playersOnline.value = status.players?.online
|
||||
ping.value = status.ping
|
||||
if (
|
||||
activeInstanceId.value !== instanceId ||
|
||||
linkedProjectV3.value?.minecraft_java_server?.address !== serverAddress
|
||||
)
|
||||
return
|
||||
applyServerStatus(status)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`Failed to fetch server status for ${serverAddress}:`, error)
|
||||
})
|
||||
.finally(() => {
|
||||
if (activeInstanceId.value !== instanceId) return
|
||||
loadingServerPing.value = true
|
||||
})
|
||||
} else {
|
||||
loadingServerPing.value = true
|
||||
}
|
||||
|
||||
updatePlayState()
|
||||
updatePlayState(instanceId)
|
||||
}
|
||||
|
||||
async function updatePlayState() {
|
||||
if (!route.params.id) return
|
||||
const runningProcesses = await get_by_instance_id(route.params.id as string).catch(handleError)
|
||||
async function updatePlayState(instanceId = route.params.id as string) {
|
||||
if (!instanceId) return
|
||||
const runningProcesses = await get_by_instance_id(instanceId).catch(handleError)
|
||||
if (activeInstanceId.value !== instanceId) return
|
||||
|
||||
playing.value = Array.isArray(runningProcesses) && runningProcesses.length > 0
|
||||
}
|
||||
|
||||
await fetchInstance()
|
||||
watch(
|
||||
() => route.params.id,
|
||||
async () => {
|
||||
if (route.params.id && route.path.startsWith('/instance')) {
|
||||
await fetchInstance()
|
||||
}
|
||||
},
|
||||
)
|
||||
await fetchInstance(route.params.id as string)
|
||||
|
||||
onBeforeRouteUpdate(async (to) => {
|
||||
if (!to.path.startsWith('/instance')) return
|
||||
const instanceId = Array.isArray(to.params.id) ? to.params.id[0] : to.params.id
|
||||
if (typeof instanceId !== 'string') return false
|
||||
|
||||
const requestId = ++fetchInstanceRequestId
|
||||
const data = await loadInstancePageData(instanceId, to.name)
|
||||
if (requestId !== fetchInstanceRequestId) return false
|
||||
|
||||
displayedInstanceRoute.value = to
|
||||
applyInstancePageData(data, {
|
||||
name: to.name,
|
||||
path: to.path,
|
||||
query: to.query,
|
||||
})
|
||||
})
|
||||
|
||||
const basePath = computed(
|
||||
() => `/instance/${encodeURIComponent(displayedInstanceRoute.value.params.id as string)}`,
|
||||
@@ -372,20 +477,6 @@ const tabs = computed(() => [
|
||||
},
|
||||
])
|
||||
|
||||
if (instance.value) {
|
||||
breadcrumbs.setName(
|
||||
'Instance',
|
||||
instance.value.name.length > 40
|
||||
? instance.value.name.substring(0, 40) + '...'
|
||||
: instance.value.name,
|
||||
)
|
||||
breadcrumbs.setContext({
|
||||
name: instance.value.name,
|
||||
link: displayedInstanceRoute.value.path,
|
||||
query: displayedInstanceRoute.value.query,
|
||||
})
|
||||
}
|
||||
|
||||
const options = ref<InstanceType<typeof ContextMenu> | null>(null)
|
||||
|
||||
const startInstance = async (context: string) => {
|
||||
|
||||
@@ -47,9 +47,9 @@
|
||||
<div class="flex flex-col gap-4 p-6">
|
||||
<div
|
||||
v-if="projectInstallContext"
|
||||
class="sticky top-0 z-20 -mx-6 -mt-6 rounded-tl-[--radius-xl] border-0 border-b border-solid bg-surface-1 p-3 border-surface-5"
|
||||
class="sticky top-0 z-20 -mx-6 -mt-6 rounded-tl-[--radius-xl] border-0 border-b border-solid border-divider bg-surface-1 px-6 pt-6"
|
||||
>
|
||||
<BrowseInstallHeader :install-context="projectInstallContext" />
|
||||
<BrowseInstallHeader :install-context="projectInstallContext" bottom-padding />
|
||||
</div>
|
||||
<InstanceIndicator v-if="instance && !projectInstallContext" :instance="instance" />
|
||||
<template v-if="data">
|
||||
@@ -172,6 +172,7 @@ import {
|
||||
SelectedProjectsFloatingBar,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import dayjs from 'dayjs'
|
||||
@@ -181,6 +182,10 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import InstanceIndicator from '@/components/ui/InstanceIndicator.vue'
|
||||
import {
|
||||
fetchCachedServerStatus,
|
||||
getFreshCachedServerStatus,
|
||||
} from '@/composables/instances/use-server-status-query'
|
||||
import {
|
||||
get_organization,
|
||||
get_project,
|
||||
@@ -199,7 +204,7 @@ import {
|
||||
import { get_loader_versions as getLoaderManifest } from '@/helpers/metadata'
|
||||
import { get_by_instance_id } from '@/helpers/process'
|
||||
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
|
||||
import { getServerAddress, getServerLatency } from '@/helpers/worlds'
|
||||
import { getServerAddress } from '@/helpers/worlds'
|
||||
import { injectContentInstall } from '@/providers/content-install'
|
||||
import { injectServerInstall } from '@/providers/server-install'
|
||||
import { createServerInstallContent } from '@/providers/setup/server-install-content'
|
||||
@@ -212,6 +217,7 @@ const { handleError } = injectNotificationManager()
|
||||
const { install: installVersion } = injectContentInstall()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
const breadcrumbs = useBreadcrumbs()
|
||||
const themeStore = useTheming()
|
||||
const { formatMessage } = useVIntl()
|
||||
@@ -225,6 +231,10 @@ const messages = defineMessages({
|
||||
id: 'app.project.install-context.install-content-to-instance',
|
||||
defaultMessage: 'Install content to instance',
|
||||
},
|
||||
worldFallbackName: {
|
||||
id: 'app.project.install-context.world-fallback-name',
|
||||
defaultMessage: 'Instance',
|
||||
},
|
||||
alreadyInstalled: {
|
||||
id: 'app.project.install-button.already-installed',
|
||||
defaultMessage: 'This project is already installed',
|
||||
@@ -255,7 +265,10 @@ const serverStatusOnline = ref(false)
|
||||
const serverInstancePath = ref(null)
|
||||
const serverPlaying = ref(false)
|
||||
const serverSetupModalRef = ref(null)
|
||||
const serverInstallContent = createServerInstallContent({ serverSetupModalRef })
|
||||
const serverInstallContent = createServerInstallContent({
|
||||
serverSetupModalRef,
|
||||
isRouteInContext: (targetRoute) => targetRoute.path.startsWith('/project/'),
|
||||
})
|
||||
|
||||
serverInstallContent.watchServerContextChanges()
|
||||
await serverInstallContent.initServerContext()
|
||||
@@ -322,7 +335,9 @@ const projectInstallContext = computed(() => {
|
||||
const serverData = serverInstallContent.serverContextServerData.value
|
||||
if (serverData) {
|
||||
return {
|
||||
name: serverData.name,
|
||||
name:
|
||||
serverInstallContent.serverContextWorldName.value ??
|
||||
formatMessage(messages.worldFallbackName),
|
||||
loader: serverData.loader ?? '',
|
||||
gameVersion: serverData.mc_version ?? '',
|
||||
serverId: serverInstallContent.serverIdQuery.value,
|
||||
@@ -611,10 +626,19 @@ async function fetchProjectData() {
|
||||
function fetchDeferredServerData(project) {
|
||||
const serverAddress = projectV3.value?.minecraft_java_server?.address
|
||||
if (serverAddress) {
|
||||
serverPing.value = undefined
|
||||
getServerLatency(serverAddress)
|
||||
.then((latency) => {
|
||||
serverPing.value = latency
|
||||
const cachedStatus = getFreshCachedServerStatus(queryClient, serverAddress)
|
||||
if (cachedStatus) {
|
||||
serverPing.value = cachedStatus.ping
|
||||
serverStatusOnline.value = true
|
||||
} else {
|
||||
serverPing.value = undefined
|
||||
}
|
||||
|
||||
fetchCachedServerStatus(queryClient, serverAddress)
|
||||
.then((status) => {
|
||||
if (projectV3.value?.minecraft_java_server?.address !== serverAddress) return
|
||||
serverPing.value = status.ping
|
||||
serverStatusOnline.value = true
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`Failed to ping server ${serverAddress}:`, error)
|
||||
|
||||
@@ -18,7 +18,18 @@ import {
|
||||
writeStoredServerInstallQueue,
|
||||
} from '@modrinth/ui'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, type ComputedRef, nextTick, type Ref, ref, watch } from 'vue'
|
||||
import {
|
||||
computed,
|
||||
type ComputedRef,
|
||||
nextTick,
|
||||
onActivated,
|
||||
onDeactivated,
|
||||
type Ref,
|
||||
ref,
|
||||
shallowRef,
|
||||
watch,
|
||||
} from 'vue'
|
||||
import type { RouteLocationNormalizedLoaded } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
type ServerFlowFrom = 'onboarding' | 'reset-server'
|
||||
@@ -204,6 +215,7 @@ async function getQueuedInstallPlaceholders(
|
||||
|
||||
export function createServerInstallContent(opts: {
|
||||
serverSetupModalRef: Ref<ServerSetupModalHandle | null>
|
||||
isRouteInContext?: (route: RouteLocationNormalizedLoaded) => boolean
|
||||
}) {
|
||||
const { serverSetupModalRef } = opts
|
||||
const route = useRoute()
|
||||
@@ -212,9 +224,22 @@ export function createServerInstallContent(opts: {
|
||||
const { handleError } = injectNotificationManager()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const serverIdQuery = computed(() => readQueryString(route.query.sid))
|
||||
const worldIdQuery = computed(() => readQueryString(route.query.wid))
|
||||
const browseFrom = computed(() => readQueryString(route.query.from))
|
||||
const routeInContext = computed(() => opts.isRouteInContext?.(route) ?? true)
|
||||
const contextQuery = shallowRef(route.query)
|
||||
|
||||
watch(
|
||||
[() => route.fullPath, routeInContext],
|
||||
() => {
|
||||
if (routeInContext.value) {
|
||||
contextQuery.value = route.query
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const serverIdQuery = computed(() => readQueryString(contextQuery.value.sid))
|
||||
const worldIdQuery = computed(() => readQueryString(contextQuery.value.wid))
|
||||
const browseFrom = computed(() => readQueryString(contextQuery.value.from))
|
||||
const serverFlowFrom = computed<ServerFlowFrom | null>(() =>
|
||||
browseFrom.value === 'onboarding' || browseFrom.value === 'reset-server'
|
||||
? browseFrom.value
|
||||
@@ -233,6 +258,7 @@ export function createServerInstallContent(opts: {
|
||||
const queuedServerInstalls = ref<Map<string, BrowseInstallPlan<InstallableSearchResult>>>(
|
||||
new Map(),
|
||||
)
|
||||
const componentActive = ref(true)
|
||||
const queuedServerInstallProjectIds = computed(() => new Set(queuedServerInstalls.value.keys()))
|
||||
const queuedServerInstallCount = computed(() => queuedServerInstalls.value.size)
|
||||
const selectedServerInstallProjects = computed<BrowseSelectedProject[]>(() =>
|
||||
@@ -280,6 +306,14 @@ export function createServerInstallContent(opts: {
|
||||
return 'Installing content'
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
componentActive.value = true
|
||||
})
|
||||
|
||||
onDeactivated(() => {
|
||||
componentActive.value = false
|
||||
})
|
||||
|
||||
async function getServerContextServerFull(serverId: string) {
|
||||
if (serverContextServerFull.value?.id === serverId) {
|
||||
return serverContextServerFull.value
|
||||
@@ -349,53 +383,79 @@ export function createServerInstallContent(opts: {
|
||||
}
|
||||
|
||||
function watchServerContextChanges() {
|
||||
watch([serverIdQuery, effectiveServerWorldId], async ([sid, wid], [prevSid, prevWid]) => {
|
||||
if (!sid) {
|
||||
serverContextServerData.value = null
|
||||
serverContextServerFull.value = null
|
||||
serverContentProjectIds.value = new Set()
|
||||
serverContentInstallKeys.value = new Set()
|
||||
setQueuedServerInstallPlans(new Map())
|
||||
return
|
||||
}
|
||||
watch(
|
||||
[componentActive, routeInContext, serverIdQuery, effectiveServerWorldId],
|
||||
async ([active, inContext, sid, wid], [prevActive, prevInContext, prevSid, prevWid]) => {
|
||||
if (!active || !inContext) return
|
||||
|
||||
if (sid !== prevSid) {
|
||||
serverContextWorldId.value = worldIdQuery.value
|
||||
serverContextServerFull.value = null
|
||||
serverContentProjectIds.value = new Set()
|
||||
serverContentInstallKeys.value = new Set()
|
||||
queuedServerInstalls.value = readStoredServerInstallQueue(sid, wid)
|
||||
try {
|
||||
serverContextServerData.value = await client.archon.servers_v0.get(sid)
|
||||
} catch (err) {
|
||||
handleError(err as Error)
|
||||
if (!sid) {
|
||||
serverContextServerData.value = null
|
||||
serverContextServerFull.value = null
|
||||
serverContentProjectIds.value = new Set()
|
||||
serverContentInstallKeys.value = new Set()
|
||||
setQueuedServerInstallPlans(new Map())
|
||||
return
|
||||
}
|
||||
try {
|
||||
const serverFull = await getServerContextServerFull(sid)
|
||||
if (!worldIdQuery.value) {
|
||||
const activeWorld = serverFull.worlds.find((world) => world.is_active)
|
||||
serverContextWorldId.value = activeWorld?.id ?? serverFull.worlds[0]?.id ?? null
|
||||
|
||||
const hasServerDataForRoute = serverContextServerData.value?.server_id === sid
|
||||
const hasServerFullForRoute = serverContextServerFull.value?.id === sid
|
||||
const didEnterContext = !prevActive || !prevInContext
|
||||
const shouldReloadRouteContext =
|
||||
didEnterContext ||
|
||||
sid !== prevSid ||
|
||||
wid !== prevWid ||
|
||||
!hasServerDataForRoute ||
|
||||
!hasServerFullForRoute
|
||||
|
||||
if (!hasServerDataForRoute || !hasServerFullForRoute) {
|
||||
serverContextWorldId.value = worldIdQuery.value
|
||||
if (!hasServerDataForRoute) {
|
||||
serverContextServerData.value = null
|
||||
}
|
||||
} catch (err) {
|
||||
handleError(err as Error)
|
||||
if (!hasServerFullForRoute) {
|
||||
serverContextServerFull.value = null
|
||||
}
|
||||
serverContentProjectIds.value = new Set()
|
||||
serverContentInstallKeys.value = new Set()
|
||||
}
|
||||
}
|
||||
|
||||
if (wid !== prevWid) {
|
||||
queuedServerInstalls.value = readStoredServerInstallQueue(sid, wid)
|
||||
}
|
||||
if (!hasServerDataForRoute) {
|
||||
try {
|
||||
serverContextServerData.value = await client.archon.servers_v0.get(sid)
|
||||
} catch (err) {
|
||||
handleError(err as Error)
|
||||
}
|
||||
}
|
||||
|
||||
if (wid && (sid !== prevSid || wid !== prevWid)) {
|
||||
await refreshServerInstalledContent(sid, wid)
|
||||
}
|
||||
})
|
||||
if (!hasServerFullForRoute) {
|
||||
try {
|
||||
const serverFull = await getServerContextServerFull(sid)
|
||||
if (!worldIdQuery.value) {
|
||||
const activeWorld = serverFull.worlds.find((world) => world.is_active)
|
||||
serverContextWorldId.value = activeWorld?.id ?? serverFull.worlds[0]?.id ?? null
|
||||
}
|
||||
} catch (err) {
|
||||
handleError(err as Error)
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldReloadRouteContext) {
|
||||
queuedServerInstalls.value = readStoredServerInstallQueue(sid, wid)
|
||||
}
|
||||
|
||||
if (wid && shouldReloadRouteContext) {
|
||||
await refreshServerInstalledContent(sid, wid)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function enforceSetupModpackRoute(currentProjectType: string | undefined) {
|
||||
if (!isSetupServerContext.value || currentProjectType === 'modpack') return
|
||||
if (!routeInContext.value || !isSetupServerContext.value || currentProjectType === 'modpack')
|
||||
return
|
||||
router.replace({
|
||||
path: '/browse/modpack',
|
||||
query: route.query,
|
||||
query: contextQuery.value,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,11 @@ import * as Instance from '@/pages/instance'
|
||||
import * as Library from '@/pages/library'
|
||||
import * as Project from '@/pages/project'
|
||||
|
||||
function getQueryParam(value) {
|
||||
if (Array.isArray(value)) return value[0] ?? ''
|
||||
return value ?? ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures application routing. Add page to pages/index and then add to route table here.
|
||||
*/
|
||||
@@ -131,6 +136,17 @@ export default new createRouter({
|
||||
component: Pages.Browse,
|
||||
meta: {
|
||||
useContext: true,
|
||||
keepAliveComponent: 'Browse',
|
||||
keepAliveKey: (route) => {
|
||||
return [
|
||||
'browse',
|
||||
getQueryParam(route.params.projectType),
|
||||
getQueryParam(route.query.i),
|
||||
getQueryParam(route.query.sid),
|
||||
getQueryParam(route.query.wid),
|
||||
getQueryParam(route.query.from),
|
||||
].join(':')
|
||||
},
|
||||
breadcrumb: [{ name: '?BrowseTitle' }],
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user