feat: qa + app routing bugs

This commit is contained in:
Calum H. (IMB11)
2026-06-26 17:09:22 +01:00
parent de007985c1
commit 45d02bd5e6
13 changed files with 498 additions and 234 deletions
+27 -4
View File
@@ -146,6 +146,7 @@ const APP_LEFT_NAV_WIDTH = '4rem'
const APP_SIDEBAR_WIDTH = 300 const APP_SIDEBAR_WIDTH = 300
const INTERCOM_BUBBLE_DEFAULT_PADDING = 20 const INTERCOM_BUBBLE_DEFAULT_PADDING = 20
const PRIDE_FUNDRAISER_END_DATE = new Date('2026-07-01T00:00:00Z').getTime() const PRIDE_FUNDRAISER_END_DATE = new Date('2026-07-01T00:00:00Z').getTime()
const ROUTE_SUSPENSE_TIMEOUT_MS = 60_000
const credentials = ref() const credentials = ref()
const sidebarToggled = ref(true) const sidebarToggled = ref(true)
const unsubscribeSidebarToggle = themeStore.$subscribe(() => { const unsubscribeSidebarToggle = themeStore.$subscribe(() => {
@@ -155,6 +156,22 @@ const forceSidebar = computed(
() => route.path.startsWith('/browse') || route.path.startsWith('/project'), () => route.path.startsWith('/browse') || route.path.startsWith('/project'),
) )
const sidebarVisible = computed(() => sidebarToggled.value || forceSidebar.value) 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 hostingRouteActive = computed(() => route.path.startsWith('/hosting'))
const prideFundraiserEnabled = computed( const prideFundraiserEnabled = computed(
() => themeStore.getFeatureFlag('pride_fundraiser') && Date.now() < PRIDE_FUNDRAISER_END_DATE, () => themeStore.getFeatureFlag('pride_fundraiser') && Date.now() < PRIDE_FUNDRAISER_END_DATE,
@@ -1673,11 +1690,17 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
> >
{{ formatMessage(messages.authUnreachableBody) }} {{ formatMessage(messages.authUnreachableBody) }}
</Admonition> </Admonition>
<RouterView v-slot="{ Component }"> <RouterView v-slot="{ Component, route: viewRoute }">
<template v-if="Component"> <template v-if="Component">
<Suspense @pending="onSuspensePending" @resolve="onSuspenseResolve"> <KeepAlive :include="keepAliveRouteComponents" :max="3">
<component :is="Component"></component> <Suspense
</Suspense> :timeout="ROUTE_SUSPENSE_TIMEOUT_MS"
@pending="onSuspensePending"
@resolve="onSuspenseResolve"
>
<component :is="Component" :key="getRouteViewKey(viewRoute)"></component>
</Suspense>
</KeepAlive>
</template> </template>
</RouterView> </RouterView>
</div> </div>
@@ -8,13 +8,18 @@ import type { ComputedRef, Ref } from 'vue'
import { onUnmounted, ref, shallowRef } from 'vue' import { onUnmounted, ref, shallowRef } from 'vue'
import type { Router } from 'vue-router' import type { Router } from 'vue-router'
import {
fetchCachedServerStatus,
getFreshCachedServerStatus,
} from '@/composables/instances/use-server-status-query'
import { process_listener } from '@/helpers/events' import { process_listener } from '@/helpers/events'
import { kill, list as listInstances } from '@/helpers/instance' import { kill, list as listInstances } from '@/helpers/instance'
import { get_by_instance_id } from '@/helpers/process' import { get_by_instance_id } from '@/helpers/process'
import type { GameInstance } from '@/helpers/types' 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 { interface BrowseServerInstance {
id: string
name: string name: string
path: string path: string
} }
@@ -68,12 +73,10 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const debugLog = useDebugLogger('BrowseServer') const debugLog = useDebugLogger('BrowseServer')
const serverPings = shallowRef<Record<string, number | undefined>>({}) 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 runningServerProjects = ref<Record<string, string>>({})
const lastServerHits = shallowRef<Labrinth.Search.v3.ResultSearchProject[]>([]) const lastServerHits = shallowRef<Labrinth.Search.v3.ResultSearchProject[]>([])
const contextMenuRef = ref<ContextMenuHandle | null>(null) const contextMenuRef = ref<ContextMenuHandle | null>(null)
let serverPingCacheActive = true let serverPingsActive = true
let unlistenProcesses: (() => void) | null = null let unlistenProcesses: (() => void) | null = null
async function checkServerRunningStates(hits: Labrinth.Search.v3.ResultSearchProject[]) { async function checkServerRunningStates(hits: Labrinth.Search.v3.ResultSearchProject[]) {
@@ -146,37 +149,26 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
}) })
const nextPings = { ...serverPings.value } const nextPings = { ...serverPings.value }
for (const { hit, address } of pingsToFetch) { for (const { hit, address } of pingsToFetch) {
if (serverPingCache.has(address)) { const cachedStatus = getFreshCachedServerStatus(queryClient, address)
nextPings[hit.project_id] = serverPingCache.get(address) if (cachedStatus) {
nextPings[hit.project_id] = cachedStatus.ping
} }
} }
serverPings.value = nextPings serverPings.value = nextPings
await Promise.all( await Promise.all(
pingsToFetch.map(async ({ hit, address }) => { pingsToFetch.map(async ({ hit, address }) => {
if (serverPingCache.has(address)) return if (getFreshCachedServerStatus(queryClient, address)) return
let pending = pendingServerPings.get(address) try {
if (!pending) { const status = await fetchCachedServerStatus(queryClient, address)
pending = getServerLatency(address) if (!serverPingsActive) return
.then((latency) => { serverPings.value = { ...serverPings.value, [hit.project_id]: status.ping }
if (serverPingCacheActive) serverPingCache.set(address, latency) } catch (error) {
return latency console.error(`Failed to ping server ${address}:`, error)
}) if (!serverPingsActive) return
.catch((error) => { serverPings.value = { ...serverPings.value, [hit.project_id]: undefined }
console.error(`Failed to ping server ${address}:`, error)
if (serverPingCacheActive) serverPingCache.set(address, undefined)
return undefined
})
.finally(() => {
pendingServerPings.delete(address)
})
pendingServerPings.set(address, pending)
} }
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) .catch(options.handleError)
onUnmounted(() => { onUnmounted(() => {
serverPingCacheActive = false serverPingsActive = false
unlistenProcesses?.() unlistenProcesses?.()
serverPingCache.clear()
pendingServerPings.clear()
}) })
return { 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,
})
}
+29 -37
View File
@@ -25,7 +25,6 @@ import {
requestInstall, requestInstall,
SelectedProjectsFloatingBar, SelectedProjectsFloatingBar,
useBrowseSearch, useBrowseSearch,
useDebugLogger,
useStickyObserver, useStickyObserver,
useVIntl, useVIntl,
} from '@modrinth/ui' } from '@modrinth/ui'
@@ -61,19 +60,26 @@ import {
import { useBreadcrumbs } from '@/store/breadcrumbs' import { useBreadcrumbs } from '@/store/breadcrumbs'
import { useTheming } from '@/store/state' import { useTheming } from '@/store/state'
defineOptions({
name: 'Browse',
})
const { handleError } = injectNotificationManager() const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const { installingServerProjects, playServerProject, showAddServerToInstanceModal } = const { installingServerProjects, playServerProject, showAddServerToInstanceModal } =
injectServerInstall() injectServerInstall()
const { install: installVersion } = injectContentInstall() const { install: installVersion } = injectContentInstall()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const debugLog = useDebugLogger('Browse')
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const themeStore = useTheming() const themeStore = useTheming()
const browseRouteActive = computed(() => route.path.startsWith('/browse/'))
const serverSetupModalRef = ref<InstanceType<typeof CreationFlowModal> | null>(null) const serverSetupModalRef = ref<InstanceType<typeof CreationFlowModal> | null>(null)
const serverInstallContent = createServerInstallContent({ serverSetupModalRef }) const serverInstallContent = createServerInstallContent({
serverSetupModalRef,
isRouteInContext: (targetRoute) => targetRoute.path.startsWith('/browse/'),
})
provideServerInstallContent(serverInstallContent) provideServerInstallContent(serverInstallContent)
const { const {
serverIdQuery, serverIdQuery,
@@ -110,8 +116,6 @@ const {
handleServerModpackFlowCreate, handleServerModpackFlowCreate,
markServerProjectInstalled, markServerProjectInstalled,
} = serverInstallContent } = serverInstallContent
debugLog('fetching tags (categories, loaders, gameVersions)')
const [categories, loaders, availableGameVersions] = await Promise.all([ const [categories, loaders, availableGameVersions] = await Promise.all([
get_categories() get_categories()
.catch(handleError) .catch(handleError)
@@ -131,6 +135,7 @@ const tags: Ref<Tags> = computed(() => ({
})) }))
type Instance = { type Instance = {
id: string
game_version: string game_version: string
loader: string loader: string
path: string path: string
@@ -198,7 +203,6 @@ async function refreshInstalledProjectIds() {
const serverProjectIds = worlds const serverProjectIds = worlds
.filter((w) => w.type === 'server' && 'project_id' in w && w.project_id) .filter((w) => w.type === 'server' && 'project_id' in w && w.project_id)
.map((w) => (w as { project_id: string }).project_id) .map((w) => (w as { project_id: string }).project_id)
debugLog('installedServerProjectIds loaded', { count: serverProjectIds.length })
installedProjectIds.value = serverProjectIds installedProjectIds.value = serverProjectIds
return return
} }
@@ -206,45 +210,29 @@ async function refreshInstalledProjectIds() {
const ids = await getInstalledProjectIds(route.query.i as string).catch(handleError) const ids = await getInstalledProjectIds(route.query.i as string).catch(handleError)
if (!ids) return if (!ids) return
debugLog('installedProjectIds loaded', { count: ids.length })
installedProjectIds.value = ids installedProjectIds.value = ids
} }
async function initInstanceContext() { 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 initServerContext()
if (route.query.i) { if (route.query.i) {
instance.value = (await getInstance(route.query.i as string).catch(handleError)) ?? null 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() await refreshInstalledProjectIds()
if (instance.value?.link?.project_id) { if (instance.value?.link?.project_id) {
debugLog('checking linked project for server status', instance.value.link.project_id)
const projectV3 = await get_project_v3( const projectV3 = await get_project_v3(
instance.value.link.project_id, instance.value.link.project_id,
'must_revalidate', 'must_revalidate',
).catch(handleError) ).catch(handleError)
if (projectV3?.minecraft_server != null) { if (projectV3?.minecraft_server != null) {
debugLog('instance is a server instance')
isServerInstance.value = true isServerInstance.value = true
} }
} }
} }
if (route.query.ai && !(route.params.projectType === 'modpack')) { if (route.query.ai && !(route.params.projectType === 'modpack')) {
debugLog('setting instanceHideInstalled from query', route.query.ai)
instanceHideInstalled.value = route.query.ai === 'true' instanceHideInstalled.value = route.query.ai === 'true'
} }
} }
@@ -295,7 +283,7 @@ function syncHiddenServerContentProjectIds() {
watch( watch(
serverContentProjectIds, serverContentProjectIds,
() => { () => {
if (!hiddenServerContentProjectIdsInitialized.value) { if (!hiddenServerContentProjectIdsInitialized.value || serverHideInstalled.value) {
syncHiddenServerContentProjectIds() syncHiddenServerContentProjectIds()
} }
}, },
@@ -368,11 +356,9 @@ const {
const offline = ref(!navigator.onLine) const offline = ref(!navigator.onLine)
window.addEventListener('offline', () => { window.addEventListener('offline', () => {
debugLog('went offline')
offline.value = true offline.value = true
}) })
window.addEventListener('online', () => { window.addEventListener('online', () => {
debugLog('went online')
offline.value = false offline.value = false
}) })
@@ -480,7 +466,6 @@ const projectType = ref<ProjectType>(route.params.projectType as ProjectType)
function resetInstanceContext() { function resetInstanceContext() {
if (!instance.value) return if (!instance.value) return
debugLog('instance context removed, resetting')
instance.value = null instance.value = null
installedProjectIds.value = null installedProjectIds.value = null
instanceHideInstalled.value = false instanceHideInstalled.value = false
@@ -495,6 +480,9 @@ function resetInstanceContext() {
watch( watch(
() => route.params.projectType as ProjectType, () => route.params.projectType as ProjectType,
async (newType) => { async (newType) => {
if (!browseRouteActive.value) {
return
}
if (isSetupServerContext.value) { if (isSetupServerContext.value) {
enforceSetupModpackRoute(newType) enforceSetupModpackRoute(newType)
if (newType !== 'modpack') return if (newType !== 'modpack') return
@@ -502,7 +490,6 @@ watch(
if (!newType || newType === projectType.value) return if (!newType || newType === projectType.value) return
debugLog('projectType route param changed', { from: projectType.value, to: newType })
projectType.value = newType projectType.value = newType
}, },
) )
@@ -616,6 +603,7 @@ const installContext = computed(() => {
} }
return null return null
}) })
const stickyInstallHeaderRef = ref<HTMLElement | null>(null) const stickyInstallHeaderRef = ref<HTMLElement | null>(null)
const { isStuck: isInstallHeaderStuck } = useStickyObserver( const { isStuck: isInstallHeaderStuck } = useStickyObserver(
stickyInstallHeaderRef, stickyInstallHeaderRef,
@@ -718,11 +706,10 @@ function getCardActions(
installed?: boolean installed?: boolean
installing?: boolean installing?: boolean
} }
const isInstalled = const isInstalled = isServerContext.value
projectResult.installed || ? serverContentProjectIds.value.has(projectResult.project_id || '') ||
allInstalledIds.value.has(projectResult.project_id || '') || serverContextServerData.value?.upstream?.project_id === projectResult.project_id
serverContentProjectIds.value.has(projectResult.project_id || '') || : projectResult.installed || allInstalledIds.value.has(projectResult.project_id || '')
serverContextServerData.value?.upstream?.project_id === projectResult.project_id
const isInstalling = installingProjectIds.value.has(projectResult.project_id) const isInstalling = installingProjectIds.value.has(projectResult.project_id)
if ( if (
@@ -886,7 +873,6 @@ function onSearchResultsInstalled(ids: string[]) {
} }
async function search(requestParams: string) { async function search(requestParams: string) {
debugLog('searching v3', requestParams)
const isServer = projectType.value === 'server' const isServer = projectType.value === 'server'
const rawResults = await queryClient.fetchQuery({ const rawResults = await queryClient.fetchQuery({
@@ -968,6 +954,7 @@ const lockedFilterMessages = computed(() => ({
const searchState = useBrowseSearch({ const searchState = useBrowseSearch({
projectType, projectType,
tags, tags,
active: browseRouteActive,
providedFilters: combinedProvidedFilters, providedFilters: combinedProvidedFilters,
search, search,
persistentQueryParams: ['i', 'ai', 'shi', 'sid', 'wid', 'from'], persistentQueryParams: ['i', 'ai', 'shi', 'sid', 'wid', 'from'],
@@ -1043,7 +1030,12 @@ onUnmounted(() => {
}) })
function getProjectBrowseQuery() { function getProjectBrowseQuery() {
if (!installContext.value) return undefined if (!browseRouteActive.value) {
return undefined
}
if (!installContext.value) {
return undefined
}
return { return {
...route.query, ...route.query,
b: route.fullPath, b: route.fullPath,
@@ -1110,10 +1102,10 @@ provideBrowseManager({
<div <div
v-if="installContext" v-if="installContext"
ref="stickyInstallHeaderRef" 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' : '']" :class="[isInstallHeaderStuck ? 'border-t' : '']"
> >
<BrowseInstallHeader /> <BrowseInstallHeader bottom-padding />
</div> </div>
<SelectedProjectsFloatingBar v-if="installContext" /> <SelectedProjectsFloatingBar v-if="installContext" />
@@ -1145,7 +1137,7 @@ provideBrowseManager({
@create="handleServerModpackFlowCreate" @create="handleServerModpackFlowCreate"
/> />
<Teleport to="#sidebar-teleport-target"> <Teleport v-if="browseRouteActive" to="#sidebar-teleport-target">
<BrowseSidebar /> <BrowseSidebar />
</Teleport> </Teleport>
</div> </div>
+142 -51
View File
@@ -127,6 +127,7 @@ import {
ExternalIcon, ExternalIcon,
EyeIcon, EyeIcon,
FolderOpenIcon, FolderOpenIcon,
TagCategoryGamepad2Icon as Gamepad2Icon,
GlobeIcon, GlobeIcon,
HashIcon, HashIcon,
MoreVerticalIcon, MoreVerticalIcon,
@@ -135,7 +136,6 @@ import {
PlusIcon, PlusIcon,
SettingsIcon, SettingsIcon,
StopCircleIcon, StopCircleIcon,
TagCategoryGamepad2Icon as Gamepad2Icon,
TerminalSquareIcon, TerminalSquareIcon,
TimerIcon, TimerIcon,
UpdatedIcon, UpdatedIcon,
@@ -145,9 +145,9 @@ import {
Avatar, Avatar,
formatLoaderLabel, formatLoaderLabel,
injectNotificationManager, injectNotificationManager,
LoaderIcon as ServerLoaderIcon,
NavTabs, NavTabs,
PageHeader, PageHeader,
LoaderIcon as ServerLoaderIcon,
ServerOnlinePlayers, ServerOnlinePlayers,
ServerPing, ServerPing,
ServerRecentPlays, ServerRecentPlays,
@@ -161,12 +161,16 @@ import dayjs from 'dayjs'
import duration from 'dayjs/plugin/duration' import duration from 'dayjs/plugin/duration'
import relativeTime from 'dayjs/plugin/relativeTime' import relativeTime from 'dayjs/plugin/relativeTime'
import { computed, onUnmounted, ref, shallowRef, watch } from 'vue' 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 ContextMenu from '@/components/ui/ContextMenu.vue'
import ExportModal from '@/components/ui/ExportModal.vue' import ExportModal from '@/components/ui/ExportModal.vue'
import InstanceSettingsModal from '@/components/ui/modal/InstanceSettingsModal.vue' import InstanceSettingsModal from '@/components/ui/modal/InstanceSettingsModal.vue'
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.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 { useInstanceConsole } from '@/composables/useInstanceConsole'
import { trackEvent } from '@/helpers/analytics' import { trackEvent } from '@/helpers/analytics'
import { get_project_v3 } from '@/helpers/cache.js' 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 { get_by_instance_id } from '@/helpers/process'
import type { GameInstance } from '@/helpers/types' import type { GameInstance } from '@/helpers/types'
import { createInstanceShortcut, showInstanceInFolder } from '@/helpers/utils.js' 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 { injectServerInstall } from '@/providers/server-install'
import { handleSevereError } from '@/store/error.js' import { handleSevereError } from '@/store/error.js'
import { useBreadcrumbs, useTheming } from '@/store/state' import { useBreadcrumbs, useTheming } from '@/store/state'
@@ -222,13 +226,16 @@ const selected = ref<unknown[]>([])
const minecraftServer = computed(() => linkedProjectV3.value?.minecraft_server) const minecraftServer = computed(() => linkedProjectV3.value?.minecraft_server)
const javaServerPingData = computed(() => linkedProjectV3.value?.minecraft_java_server?.ping?.data) 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( const recentPlays = computed(
() => linkedProjectV3.value?.minecraft_java_server?.verified_plays_2w ?? undefined, () => linkedProjectV3.value?.minecraft_java_server?.verified_plays_2w ?? undefined,
) )
const playersOnline = ref<number | undefined>(undefined) const playersOnline = ref<number | undefined>(undefined)
const ping = ref<number | undefined>(undefined) const ping = ref<number | undefined>(undefined)
const loadingServerPing = ref(false) const loadingServerPing = ref(false)
const activeInstanceId = ref<string>()
let fetchInstanceRequestId = 0
watch( watch(
() => router.currentRoute.value, () => router.currentRoute.value,
@@ -240,24 +247,68 @@ watch(
{ immediate: true }, { 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) return typeof routeName === 'string' && contentSubpageRouteNames.has(routeName)
} }
async function fetchInstance() { function resetServerStatus() {
isServerInstance.value = false
linkedProjectV3.value = undefined
preloadedContent.value = null
ping.value = undefined ping.value = undefined
playersOnline.value = undefined playersOnline.value = undefined
liveServerStatusOnline.value = false
loadingServerPing.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 nextLinkedProjectV3: Labrinth.Projects.v3.Project | undefined
let nextIsServerInstance = false let nextIsServerInstance = false
const contentPreloadPromise = const contentPreloadPromise =
nextInstance && isContentSubpageRoute() nextInstance && isContentSubpageRoute(routeName)
? loadInstanceContentData(nextInstance.id, undefined, handleError) ? loadInstanceContentData(nextInstance.id, undefined, handleError)
: Promise.resolve(null) : Promise.resolve(null)
@@ -275,59 +326,113 @@ async function fetchInstance() {
const nextPreloadedContent = await contentPreloadPromise const nextPreloadedContent = await contentPreloadPromise
instance.value = nextInstance ?? undefined return {
linkedProjectV3.value = nextLinkedProjectV3 instanceId,
isServerInstance.value = nextIsServerInstance instance: nextInstance ?? undefined,
preloadedContent.value = nextPreloadedContent 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({ queryClient.prefetchQuery({
queryKey: ['worlds', nextInstance.id], queryKey: ['worlds', data.instance.id],
queryFn: () => refreshWorlds(nextInstance.id), queryFn: () => refreshWorlds(data.instance!.id),
staleTime: 30_000, 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 const serverAddress = linkedProjectV3.value?.minecraft_java_server?.address
if (isServerInstance.value && serverAddress) { 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) => { .then((status) => {
playersOnline.value = status.players?.online if (
ping.value = status.ping activeInstanceId.value !== instanceId ||
linkedProjectV3.value?.minecraft_java_server?.address !== serverAddress
)
return
applyServerStatus(status)
}) })
.catch((error) => { .catch((error) => {
console.error(`Failed to fetch server status for ${serverAddress}:`, error) console.error(`Failed to fetch server status for ${serverAddress}:`, error)
}) })
.finally(() => { .finally(() => {
if (activeInstanceId.value !== instanceId) return
loadingServerPing.value = true loadingServerPing.value = true
}) })
} else { } else {
loadingServerPing.value = true loadingServerPing.value = true
} }
updatePlayState() updatePlayState(instanceId)
} }
async function updatePlayState() { async function updatePlayState(instanceId = route.params.id as string) {
if (!route.params.id) return if (!instanceId) return
const runningProcesses = await get_by_instance_id(route.params.id as string).catch(handleError) const runningProcesses = await get_by_instance_id(instanceId).catch(handleError)
if (activeInstanceId.value !== instanceId) return
playing.value = Array.isArray(runningProcesses) && runningProcesses.length > 0 playing.value = Array.isArray(runningProcesses) && runningProcesses.length > 0
} }
await fetchInstance() await fetchInstance(route.params.id as string)
watch(
() => route.params.id, onBeforeRouteUpdate(async (to) => {
async () => { if (!to.path.startsWith('/instance')) return
if (route.params.id && route.path.startsWith('/instance')) { const instanceId = Array.isArray(to.params.id) ? to.params.id[0] : to.params.id
await fetchInstance() 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( const basePath = computed(
() => `/instance/${encodeURIComponent(displayedInstanceRoute.value.params.id as string)}`, () => `/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 options = ref<InstanceType<typeof ContextMenu> | null>(null)
const startInstance = async (context: string) => { const startInstance = async (context: string) => {
+33 -9
View File
@@ -47,9 +47,9 @@
<div class="flex flex-col gap-4 p-6"> <div class="flex flex-col gap-4 p-6">
<div <div
v-if="projectInstallContext" 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> </div>
<InstanceIndicator v-if="instance && !projectInstallContext" :instance="instance" /> <InstanceIndicator v-if="instance && !projectInstallContext" :instance="instance" />
<template v-if="data"> <template v-if="data">
@@ -172,6 +172,7 @@ import {
SelectedProjectsFloatingBar, SelectedProjectsFloatingBar,
useVIntl, useVIntl,
} from '@modrinth/ui' } from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core' import { convertFileSrc } from '@tauri-apps/api/core'
import { openUrl } from '@tauri-apps/plugin-opener' import { openUrl } from '@tauri-apps/plugin-opener'
import dayjs from 'dayjs' import dayjs from 'dayjs'
@@ -181,6 +182,10 @@ import { useRoute, useRouter } from 'vue-router'
import ContextMenu from '@/components/ui/ContextMenu.vue' import ContextMenu from '@/components/ui/ContextMenu.vue'
import InstanceIndicator from '@/components/ui/InstanceIndicator.vue' import InstanceIndicator from '@/components/ui/InstanceIndicator.vue'
import {
fetchCachedServerStatus,
getFreshCachedServerStatus,
} from '@/composables/instances/use-server-status-query'
import { import {
get_organization, get_organization,
get_project, get_project,
@@ -199,7 +204,7 @@ import {
import { get_loader_versions as getLoaderManifest } from '@/helpers/metadata' import { get_loader_versions as getLoaderManifest } from '@/helpers/metadata'
import { get_by_instance_id } from '@/helpers/process' import { get_by_instance_id } from '@/helpers/process'
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags' 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 { injectContentInstall } from '@/providers/content-install'
import { injectServerInstall } from '@/providers/server-install' import { injectServerInstall } from '@/providers/server-install'
import { createServerInstallContent } from '@/providers/setup/server-install-content' import { createServerInstallContent } from '@/providers/setup/server-install-content'
@@ -212,6 +217,7 @@ const { handleError } = injectNotificationManager()
const { install: installVersion } = injectContentInstall() const { install: installVersion } = injectContentInstall()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const queryClient = useQueryClient()
const breadcrumbs = useBreadcrumbs() const breadcrumbs = useBreadcrumbs()
const themeStore = useTheming() const themeStore = useTheming()
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
@@ -225,6 +231,10 @@ const messages = defineMessages({
id: 'app.project.install-context.install-content-to-instance', id: 'app.project.install-context.install-content-to-instance',
defaultMessage: 'Install content to instance', defaultMessage: 'Install content to instance',
}, },
worldFallbackName: {
id: 'app.project.install-context.world-fallback-name',
defaultMessage: 'Instance',
},
alreadyInstalled: { alreadyInstalled: {
id: 'app.project.install-button.already-installed', id: 'app.project.install-button.already-installed',
defaultMessage: 'This project is already installed', defaultMessage: 'This project is already installed',
@@ -255,7 +265,10 @@ const serverStatusOnline = ref(false)
const serverInstancePath = ref(null) const serverInstancePath = ref(null)
const serverPlaying = ref(false) const serverPlaying = ref(false)
const serverSetupModalRef = ref(null) const serverSetupModalRef = ref(null)
const serverInstallContent = createServerInstallContent({ serverSetupModalRef }) const serverInstallContent = createServerInstallContent({
serverSetupModalRef,
isRouteInContext: (targetRoute) => targetRoute.path.startsWith('/project/'),
})
serverInstallContent.watchServerContextChanges() serverInstallContent.watchServerContextChanges()
await serverInstallContent.initServerContext() await serverInstallContent.initServerContext()
@@ -322,7 +335,9 @@ const projectInstallContext = computed(() => {
const serverData = serverInstallContent.serverContextServerData.value const serverData = serverInstallContent.serverContextServerData.value
if (serverData) { if (serverData) {
return { return {
name: serverData.name, name:
serverInstallContent.serverContextWorldName.value ??
formatMessage(messages.worldFallbackName),
loader: serverData.loader ?? '', loader: serverData.loader ?? '',
gameVersion: serverData.mc_version ?? '', gameVersion: serverData.mc_version ?? '',
serverId: serverInstallContent.serverIdQuery.value, serverId: serverInstallContent.serverIdQuery.value,
@@ -611,10 +626,19 @@ async function fetchProjectData() {
function fetchDeferredServerData(project) { function fetchDeferredServerData(project) {
const serverAddress = projectV3.value?.minecraft_java_server?.address const serverAddress = projectV3.value?.minecraft_java_server?.address
if (serverAddress) { if (serverAddress) {
serverPing.value = undefined const cachedStatus = getFreshCachedServerStatus(queryClient, serverAddress)
getServerLatency(serverAddress) if (cachedStatus) {
.then((latency) => { serverPing.value = cachedStatus.ping
serverPing.value = latency 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) => { .catch((error) => {
console.error(`Failed to ping server ${serverAddress}:`, error) console.error(`Failed to ping server ${serverAddress}:`, error)
@@ -18,7 +18,18 @@ import {
writeStoredServerInstallQueue, writeStoredServerInstallQueue,
} from '@modrinth/ui' } from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query' 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' import { useRoute, useRouter } from 'vue-router'
type ServerFlowFrom = 'onboarding' | 'reset-server' type ServerFlowFrom = 'onboarding' | 'reset-server'
@@ -204,6 +215,7 @@ async function getQueuedInstallPlaceholders(
export function createServerInstallContent(opts: { export function createServerInstallContent(opts: {
serverSetupModalRef: Ref<ServerSetupModalHandle | null> serverSetupModalRef: Ref<ServerSetupModalHandle | null>
isRouteInContext?: (route: RouteLocationNormalizedLoaded) => boolean
}) { }) {
const { serverSetupModalRef } = opts const { serverSetupModalRef } = opts
const route = useRoute() const route = useRoute()
@@ -212,9 +224,22 @@ export function createServerInstallContent(opts: {
const { handleError } = injectNotificationManager() const { handleError } = injectNotificationManager()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const serverIdQuery = computed(() => readQueryString(route.query.sid)) const routeInContext = computed(() => opts.isRouteInContext?.(route) ?? true)
const worldIdQuery = computed(() => readQueryString(route.query.wid)) const contextQuery = shallowRef(route.query)
const browseFrom = computed(() => readQueryString(route.query.from))
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>(() => const serverFlowFrom = computed<ServerFlowFrom | null>(() =>
browseFrom.value === 'onboarding' || browseFrom.value === 'reset-server' browseFrom.value === 'onboarding' || browseFrom.value === 'reset-server'
? browseFrom.value ? browseFrom.value
@@ -233,6 +258,7 @@ export function createServerInstallContent(opts: {
const queuedServerInstalls = ref<Map<string, BrowseInstallPlan<InstallableSearchResult>>>( const queuedServerInstalls = ref<Map<string, BrowseInstallPlan<InstallableSearchResult>>>(
new Map(), new Map(),
) )
const componentActive = ref(true)
const queuedServerInstallProjectIds = computed(() => new Set(queuedServerInstalls.value.keys())) const queuedServerInstallProjectIds = computed(() => new Set(queuedServerInstalls.value.keys()))
const queuedServerInstallCount = computed(() => queuedServerInstalls.value.size) const queuedServerInstallCount = computed(() => queuedServerInstalls.value.size)
const selectedServerInstallProjects = computed<BrowseSelectedProject[]>(() => const selectedServerInstallProjects = computed<BrowseSelectedProject[]>(() =>
@@ -280,6 +306,14 @@ export function createServerInstallContent(opts: {
return 'Installing content' return 'Installing content'
}) })
onActivated(() => {
componentActive.value = true
})
onDeactivated(() => {
componentActive.value = false
})
async function getServerContextServerFull(serverId: string) { async function getServerContextServerFull(serverId: string) {
if (serverContextServerFull.value?.id === serverId) { if (serverContextServerFull.value?.id === serverId) {
return serverContextServerFull.value return serverContextServerFull.value
@@ -349,53 +383,79 @@ export function createServerInstallContent(opts: {
} }
function watchServerContextChanges() { function watchServerContextChanges() {
watch([serverIdQuery, effectiveServerWorldId], async ([sid, wid], [prevSid, prevWid]) => { watch(
if (!sid) { [componentActive, routeInContext, serverIdQuery, effectiveServerWorldId],
serverContextServerData.value = null async ([active, inContext, sid, wid], [prevActive, prevInContext, prevSid, prevWid]) => {
serverContextServerFull.value = null if (!active || !inContext) return
serverContentProjectIds.value = new Set()
serverContentInstallKeys.value = new Set()
setQueuedServerInstallPlans(new Map())
return
}
if (sid !== prevSid) { if (!sid) {
serverContextWorldId.value = worldIdQuery.value serverContextServerData.value = null
serverContextServerFull.value = null serverContextServerFull.value = null
serverContentProjectIds.value = new Set() serverContentProjectIds.value = new Set()
serverContentInstallKeys.value = new Set() serverContentInstallKeys.value = new Set()
queuedServerInstalls.value = readStoredServerInstallQueue(sid, wid) setQueuedServerInstallPlans(new Map())
try { return
serverContextServerData.value = await client.archon.servers_v0.get(sid)
} catch (err) {
handleError(err as Error)
} }
try {
const serverFull = await getServerContextServerFull(sid) const hasServerDataForRoute = serverContextServerData.value?.server_id === sid
if (!worldIdQuery.value) { const hasServerFullForRoute = serverContextServerFull.value?.id === sid
const activeWorld = serverFull.worlds.find((world) => world.is_active) const didEnterContext = !prevActive || !prevInContext
serverContextWorldId.value = activeWorld?.id ?? serverFull.worlds[0]?.id ?? null const shouldReloadRouteContext =
didEnterContext ||
sid !== prevSid ||
wid !== prevWid ||
!hasServerDataForRoute ||
!hasServerFullForRoute
if (!hasServerDataForRoute || !hasServerFullForRoute) {
serverContextWorldId.value = worldIdQuery.value
if (!hasServerDataForRoute) {
serverContextServerData.value = null
} }
} catch (err) { if (!hasServerFullForRoute) {
handleError(err as Error) serverContextServerFull.value = null
}
serverContentProjectIds.value = new Set()
serverContentInstallKeys.value = new Set()
} }
}
if (wid !== prevWid) { if (!hasServerDataForRoute) {
queuedServerInstalls.value = readStoredServerInstallQueue(sid, wid) try {
} serverContextServerData.value = await client.archon.servers_v0.get(sid)
} catch (err) {
handleError(err as Error)
}
}
if (wid && (sid !== prevSid || wid !== prevWid)) { if (!hasServerFullForRoute) {
await refreshServerInstalledContent(sid, wid) 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) { function enforceSetupModpackRoute(currentProjectType: string | undefined) {
if (!isSetupServerContext.value || currentProjectType === 'modpack') return if (!routeInContext.value || !isSetupServerContext.value || currentProjectType === 'modpack')
return
router.replace({ router.replace({
path: '/browse/modpack', path: '/browse/modpack',
query: route.query, query: contextQuery.value,
}) })
} }
+16
View File
@@ -6,6 +6,11 @@ import * as Instance from '@/pages/instance'
import * as Library from '@/pages/library' import * as Library from '@/pages/library'
import * as Project from '@/pages/project' 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. * 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, component: Pages.Browse,
meta: { meta: {
useContext: true, 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' }], breadcrumb: [{ name: '?BrowseTitle' }],
}, },
}, },
@@ -116,7 +116,7 @@
class="flex flex-1 flex-col gap-3 border-0 border-y bg-surface-2 border-solid border-surface-5 my-auto px-5 py-4" class="flex flex-1 flex-col gap-3 border-0 border-y bg-surface-2 border-solid border-surface-5 my-auto px-5 py-4"
> >
<div <div
class="grid min-h-6 grid-cols-[minmax(0,1fr)_minmax(0,70%)] items-center gap-4 text-secondary [&>*:last-child]:max-w-full [&>*:last-child]:justify-self-end" class="grid min-h-6 grid-cols-[minmax(0,1fr)_minmax(0,70%)] items-center gap-4 text-base text-secondary [&>*:last-child]:max-w-full [&>*:last-child]:justify-self-end"
> >
<span>{{ formatMessage(commonMessages.modpackLabel) }}</span> <span>{{ formatMessage(commonMessages.modpackLabel) }}</span>
<div <div
@@ -146,22 +146,22 @@
</span> </span>
</AutoLink> </AutoLink>
</div> </div>
<span v-else class="font-semibold text-contrast">{{ formatMessage(messages.none) }}</span> <span v-else class="font-semibold text-contrast">{{ formatMessage(messages.noModpack) }}</span>
</div> </div>
<div <div
class="grid min-h-6 grid-cols-[minmax(0,1fr)_minmax(0,25%)] items-center gap-4 text-secondary [&>*:last-child]:max-w-full [&>*:last-child]:justify-self-end" class="grid min-h-6 grid-cols-[minmax(0,1fr)_minmax(0,25%)] items-center gap-4 text-base text-secondary [&>*:last-child]:max-w-full [&>*:last-child]:justify-self-end"
> >
<span>{{ formatMessage(messages.installedContent) }}</span> <span>{{ formatMessage(messages.installedContent) }}</span>
<span class="font-semibold text-contrast">{{ installedContentLabel }}</span> <span class="font-semibold text-contrast">{{ installedContentLabel }}</span>
</div> </div>
<div <div
class="grid min-h-6 grid-cols-[minmax(0,1fr)_minmax(0,45%)] items-center gap-4 text-base font-medium text-secondary [&>*:last-child]:max-w-full [&>*:last-child]:justify-self-end" class="grid min-h-6 grid-cols-[minmax(0,1fr)_minmax(0,45%)] items-center gap-4 text-base text-secondary [&>*:last-child]:max-w-full [&>*:last-child]:justify-self-end"
> >
<span>{{ formatMessage(messages.lastActive) }}</span> <span>{{ formatMessage(messages.lastActive) }}</span>
<span class="font-semibold text-contrast">{{ lastActiveLabel }}</span> <span class="font-semibold text-contrast">{{ lastActiveLabel }}</span>
</div> </div>
<div <div
class="grid min-h-6 grid-cols-[minmax(0,1fr)_minmax(0,45%)] items-center gap-4 text-base font-medium text-secondary [&>*:last-child]:max-w-full [&>*:last-child]:justify-self-end" class="grid min-h-6 grid-cols-[minmax(0,1fr)_minmax(0,45%)] items-center gap-4 text-base text-secondary [&>*:last-child]:max-w-full [&>*:last-child]:justify-self-end"
> >
<span>{{ formatMessage(messages.created) }}</span> <span>{{ formatMessage(messages.created) }}</span>
<span class="font-semibold text-contrast">{{ createdLabel }}</span> <span class="font-semibold text-contrast">{{ createdLabel }}</span>
@@ -191,6 +191,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { PencilIcon, PlusIcon, Settings2Icon } from '@modrinth/assets' import { PencilIcon, PlusIcon, Settings2Icon } from '@modrinth/assets'
import { capitalizeString } from '@modrinth/utils'
import { computed, useId, useTemplateRef } from 'vue' import { computed, useId, useTemplateRef } from 'vue'
import AutoLink from '#ui/components/base/AutoLink.vue' import AutoLink from '#ui/components/base/AutoLink.vue'
@@ -215,9 +216,9 @@ const messages = defineMessages({
id: 'servers.manage.instances.card.active', id: 'servers.manage.instances.card.active',
defaultMessage: 'Active', defaultMessage: 'Active',
}, },
none: { noModpack: {
id: 'servers.manage.instances.card.none', id: 'servers.manage.instances.card.no-modpack',
defaultMessage: 'None', defaultMessage: '',
}, },
installedContent: { installedContent: {
id: 'servers.manage.instances.card.installed-content', id: 'servers.manage.instances.card.installed-content',
@@ -298,7 +299,7 @@ const installedContentLabel = computed(() => {
const lastActiveLabel = computed(() => { const lastActiveLabel = computed(() => {
if (props.world.type === 'empty') return '' if (props.world.type === 'empty') return ''
return props.world.lastActiveAt return props.world.lastActiveAt
? formatRelativeTime(props.world.lastActiveAt) ? capitalizeString(formatRelativeTime(props.world.lastActiveAt))
: formatMessage(messages.notTrackedYet) : formatMessage(messages.notTrackedYet)
}) })
@@ -3,7 +3,6 @@ import type { ComputedRef, Ref, ShallowRef } from 'vue'
import { computed, nextTick, ref, shallowRef, watch } from 'vue' import { computed, nextTick, ref, shallowRef, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useDebugLogger } from '#ui/composables/debug-logger'
import type { FilterType, FilterValue, ProjectType, SortType } from '#ui/utils/search' import type { FilterType, FilterValue, ProjectType, SortType } from '#ui/utils/search'
import { LOADER_FILTER_TYPES, useSearch } from '#ui/utils/search' import { LOADER_FILTER_TYPES, useSearch } from '#ui/utils/search'
import { useServerSearch } from '#ui/utils/server-search' import { useServerSearch } from '#ui/utils/server-search'
@@ -18,6 +17,7 @@ export interface UseBrowseSearchOptions {
categories: Labrinth.Tags.v2.Category[] categories: Labrinth.Tags.v2.Category[]
}> }>
providedFilters?: ComputedRef<FilterValue[]> providedFilters?: ComputedRef<FilterValue[]>
active?: ComputedRef<boolean>
search: (params: string) => Promise<BrowseSearchResponse> search: (params: string) => Promise<BrowseSearchResponse>
persistentQueryParams: string[] persistentQueryParams: string[]
getExtraQueryParams?: () => Record<string, string | undefined> getExtraQueryParams?: () => Record<string, string | undefined>
@@ -61,12 +61,10 @@ export interface BrowseSearchState {
} }
export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchState { export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchState {
const debug = useDebugLogger('BrowseSearch')
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
debug('init, projectType:', options.projectType.value) const active = computed(() => options.active?.value ?? true)
const projectTypes = computed(() => [options.projectType.value] as ProjectType[]) const projectTypes = computed(() => [options.projectType.value] as ProjectType[])
const isServerType = computed(() => options.projectType.value === 'server') const isServerType = computed(() => options.projectType.value === 'server')
@@ -172,6 +170,13 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
let searchVersion = 0 let searchVersion = 0
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
function clearSearchDebounce() {
if (searchDebounceTimer) {
clearTimeout(searchDebounceTimer)
searchDebounceTimer = null
}
}
const providedFiltersOrEmpty = computed(() => options.providedFilters?.value ?? []) const providedFiltersOrEmpty = computed(() => options.providedFilters?.value ?? [])
watch( watch(
@@ -192,24 +197,29 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
{ deep: true }, { deep: true },
) )
watch(effectiveRequestParams, (newVal, oldVal) => { watch(effectiveRequestParams, () => {
debug('effectiveRequestParams changed', { clearSearchDebounce()
from: oldVal?.substring(0, 80), if (!active.value) {
to: newVal?.substring(0, 80), return
}) }
if (searchDebounceTimer) clearTimeout(searchDebounceTimer)
searchDebounceTimer = setTimeout(() => { searchDebounceTimer = setTimeout(() => {
refreshSearch() refreshSearch()
}, 200) }, 200)
}) })
watch(active, (isActive, wasActive) => {
clearSearchDebounce()
if (isActive && wasActive === false) {
void refreshSearch()
}
})
async function refreshSearch() { async function refreshSearch() {
if (!active.value) {
return
}
const version = ++searchVersion const version = ++searchVersion
debug('refreshSearch start', {
version,
projectType: options.projectType.value,
params: effectiveRequestParams.value.substring(0, 100),
})
const currentHitsEmpty = isServerType.value const currentHitsEmpty = isServerType.value
? serverHits.value.length === 0 ? serverHits.value.length === 0
@@ -221,8 +231,11 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
try { try {
const response = await options.search(effectiveRequestParams.value) const response = await options.search(effectiveRequestParams.value)
if (!active.value) {
return
}
if (version !== searchVersion) { if (version !== searchVersion) {
debug('refreshSearch stale, discarding', { version, current: searchVersion })
return return
} }
@@ -232,17 +245,10 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
projectHits.value = response.projectHits projectHits.value = response.projectHits
} }
totalHits.value = response.total_hits totalHits.value = response.total_hits
debug('refreshSearch complete', {
version,
hits: response.total_hits,
projectHits: response.projectHits.length,
serverHits: response.serverHits.length,
})
updateUrlParams() updateUrlParams()
loading.value = false loading.value = false
} catch (err) { } catch (err) {
debug('refreshSearch error', err)
console.error('Browse search error:', err) console.error('Browse search error:', err)
if (version === searchVersion) { if (version === searchVersion) {
loading.value = false loading.value = false
@@ -251,7 +257,9 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
} }
function updateUrlParams() { function updateUrlParams() {
debug('updateUrlParams', { path: route.path }) if (!active.value) {
return
}
const persistentParams: Record<string, string | (string | null)[] | null | undefined> = {} const persistentParams: Record<string, string | (string | null)[] | null | undefined> = {}
for (const [key, value] of Object.entries(route.query)) { for (const [key, value] of Object.entries(route.query)) {
@@ -292,8 +300,7 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
watch( watch(
() => options.projectType.value, () => options.projectType.value,
(newType, oldType) => { () => {
debug('projectType changed', { from: oldType, to: newType })
effectiveCurrentSortType.value = effectiveCurrentSortType.value =
effectiveSortTypes.value.find((sortType) => sortType.name === 'relevance') ?? effectiveSortTypes.value.find((sortType) => sortType.name === 'relevance') ??
effectiveSortTypes.value[0] effectiveSortTypes.value[0]
@@ -4,7 +4,6 @@ import type { Component } from 'vue'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import Admonition from '#ui/components/base/Admonition.vue'
import PageHeader from '#ui/components/base/PageHeader.vue' import PageHeader from '#ui/components/base/PageHeader.vue'
import LoaderIcon from '#ui/components/servers/icons/LoaderIcon.vue' import LoaderIcon from '#ui/components/servers/icons/LoaderIcon.vue'
import { useServerImage } from '#ui/composables/servers/use-server-image.ts' import { useServerImage } from '#ui/composables/servers/use-server-image.ts'
@@ -171,8 +170,5 @@ async function handleSelectedProjectsLeaveResult(
title-class="leading-8" title-class="leading-8"
truncate-title truncate-title
/> />
<Admonition v-if="installContext.warning" type="warning" class="mb-1">
{{ installContext.warning }}
</Admonition>
</template> </template>
</template> </template>
@@ -3,6 +3,7 @@ import type { Labrinth } from '@modrinth/api-client'
import { SearchIcon } from '@modrinth/assets' import { SearchIcon } from '@modrinth/assets'
import { computed, toValue } from 'vue' import { computed, toValue } from 'vue'
import Admonition from '#ui/components/base/Admonition.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue' import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import Combobox, { type ComboboxOption } from '#ui/components/base/Combobox.vue' import Combobox, { type ComboboxOption } from '#ui/components/base/Combobox.vue'
import LoadingIndicator from '#ui/components/base/LoadingIndicator.vue' import LoadingIndicator from '#ui/components/base/LoadingIndicator.vue'
@@ -21,6 +22,7 @@ import { injectBrowseManager } from './providers/browse-manager'
const ctx = injectBrowseManager() const ctx = injectBrowseManager()
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const lockedMessages = computed(() => toValue(ctx.lockedFilterMessages)) const lockedMessages = computed(() => toValue(ctx.lockedFilterMessages))
const installWarning = computed(() => ctx.installContext?.value?.warning)
const sortOptions = computed<ComboboxOption<SortType>[]>(() => const sortOptions = computed<ComboboxOption<SortType>[]>(() =>
ctx.effectiveSortTypes.value.map((st) => ({ ctx.effectiveSortTypes.value.map((st) => ({
@@ -61,6 +63,10 @@ const messages = defineMessages({
</script> </script>
<template> <template>
<Admonition v-if="installWarning" type="warning">
{{ installWarning }}
</Admonition>
<NavTabs v-if="ctx.showProjectTypeTabs.value" :links="ctx.selectableProjectTypes.value" /> <NavTabs v-if="ctx.showProjectTypeTabs.value" :links="ctx.selectableProjectTypes.value" />
<StyledInput <StyledInput
@@ -1,18 +1,29 @@
<template> <template>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<Admonition <div
v-if="!instanceInfoAdmonitionDismissed" v-if="!instanceInfoAdmonitionDismissed"
type="info" class="grid grid-cols-[1.5rem_minmax(0,1fr)_auto] items-start gap-x-3 rounded-2xl border border-solid border-brand-blue bg-bg-blue p-5 pr-4 text-contrast"
:header="formatMessage(messages.instanceInfoHeader)"
dismissible
@dismiss="dismissInstanceInfoAdmonition"
> >
<ul class="m-0 pl-4"> <InfoIcon class="mt-0.5 size-6 text-brand-blue" aria-hidden="true" />
<li>{{ formatMessage(messages.instanceInfoDefinition) }}</li> <div class="flex min-w-0 flex-col gap-1">
<li>{{ formatMessage(messages.instanceInfoSwitching) }}</li> <h2 class="m-0 text-xl font-bold leading-7">
<li>{{ formatMessage(messages.instanceInfoFiles) }}</li> {{ formatMessage(messages.instanceInfoHeader) }}
</ul> </h2>
</Admonition> <p class="m-0 text-lg leading-7 text-contrast/85">
{{ formatMessage(messages.instanceInfoBody) }}
</p>
</div>
<ButtonStyled circular type="transparent" color="blue" hover-color-fill="background">
<button
type="button"
class="mt-0.5"
:aria-label="formatMessage(messages.instanceInfoDismiss)"
@click="dismissInstanceInfoAdmonition"
>
<XIcon aria-hidden="true" />
</button>
</ButtonStyled>
</div>
<div <div
v-if="worldsPending" v-if="worldsPending"
@@ -39,12 +50,13 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Archon } from '@modrinth/api-client' import type { Archon } from '@modrinth/api-client'
import { InfoIcon, XIcon } from '@modrinth/assets'
import { useQuery } from '@tanstack/vue-query' import { useQuery } from '@tanstack/vue-query'
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
import { computed } from 'vue' import { computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import Admonition from '#ui/components/base/Admonition.vue' import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import InstanceCard from '#ui/components/servers/instances/InstanceCard.vue' import InstanceCard from '#ui/components/servers/instances/InstanceCard.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n' import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { import {
@@ -61,20 +73,16 @@ const messages = defineMessages({
}, },
instanceInfoHeader: { instanceInfoHeader: {
id: 'servers.manage.instances.info.header', id: 'servers.manage.instances.info.header',
defaultMessage: 'What is an instance?', defaultMessage: 'What is a server instance?',
}, },
instanceInfoDefinition: { instanceInfoBody: {
id: 'servers.manage.instances.info.definition', id: 'servers.manage.instances.info.body',
defaultMessage: 'An instance is a separate server setup.',
},
instanceInfoSwitching: {
id: 'servers.manage.instances.info.switching',
defaultMessage: 'You can switch which instance your server runs.',
},
instanceInfoFiles: {
id: 'servers.manage.instances.info.files',
defaultMessage: defaultMessage:
'Each instance has its own server files, worlds, installed content, and settings.', 'An instance is a separate setup of your server with its own content, files, worlds, and settings. You can switch which instance your server runs at any time.',
},
instanceInfoDismiss: {
id: 'servers.manage.instances.info.dismiss',
defaultMessage: "Don't show this again",
}, },
}) })