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
@@ -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"
>
<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>
<div
@@ -146,22 +146,22 @@
</span>
</AutoLink>
</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
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 class="font-semibold text-contrast">{{ installedContentLabel }}</span>
</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 class="font-semibold text-contrast">{{ lastActiveLabel }}</span>
</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 class="font-semibold text-contrast">{{ createdLabel }}</span>
@@ -191,6 +191,7 @@
<script setup lang="ts">
import { PencilIcon, PlusIcon, Settings2Icon } from '@modrinth/assets'
import { capitalizeString } from '@modrinth/utils'
import { computed, useId, useTemplateRef } from 'vue'
import AutoLink from '#ui/components/base/AutoLink.vue'
@@ -215,9 +216,9 @@ const messages = defineMessages({
id: 'servers.manage.instances.card.active',
defaultMessage: 'Active',
},
none: {
id: 'servers.manage.instances.card.none',
defaultMessage: 'None',
noModpack: {
id: 'servers.manage.instances.card.no-modpack',
defaultMessage: '',
},
installedContent: {
id: 'servers.manage.instances.card.installed-content',
@@ -298,7 +299,7 @@ const installedContentLabel = computed(() => {
const lastActiveLabel = computed(() => {
if (props.world.type === 'empty') return ''
return props.world.lastActiveAt
? formatRelativeTime(props.world.lastActiveAt)
? capitalizeString(formatRelativeTime(props.world.lastActiveAt))
: formatMessage(messages.notTrackedYet)
})
@@ -3,7 +3,6 @@ import type { ComputedRef, Ref, ShallowRef } from 'vue'
import { computed, nextTick, ref, shallowRef, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useDebugLogger } from '#ui/composables/debug-logger'
import type { FilterType, FilterValue, ProjectType, SortType } from '#ui/utils/search'
import { LOADER_FILTER_TYPES, useSearch } from '#ui/utils/search'
import { useServerSearch } from '#ui/utils/server-search'
@@ -18,6 +17,7 @@ export interface UseBrowseSearchOptions {
categories: Labrinth.Tags.v2.Category[]
}>
providedFilters?: ComputedRef<FilterValue[]>
active?: ComputedRef<boolean>
search: (params: string) => Promise<BrowseSearchResponse>
persistentQueryParams: string[]
getExtraQueryParams?: () => Record<string, string | undefined>
@@ -61,12 +61,10 @@ export interface BrowseSearchState {
}
export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchState {
const debug = useDebugLogger('BrowseSearch')
const route = useRoute()
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 isServerType = computed(() => options.projectType.value === 'server')
@@ -172,6 +170,13 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
let searchVersion = 0
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
function clearSearchDebounce() {
if (searchDebounceTimer) {
clearTimeout(searchDebounceTimer)
searchDebounceTimer = null
}
}
const providedFiltersOrEmpty = computed(() => options.providedFilters?.value ?? [])
watch(
@@ -192,24 +197,29 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
{ deep: true },
)
watch(effectiveRequestParams, (newVal, oldVal) => {
debug('effectiveRequestParams changed', {
from: oldVal?.substring(0, 80),
to: newVal?.substring(0, 80),
})
if (searchDebounceTimer) clearTimeout(searchDebounceTimer)
watch(effectiveRequestParams, () => {
clearSearchDebounce()
if (!active.value) {
return
}
searchDebounceTimer = setTimeout(() => {
refreshSearch()
}, 200)
})
watch(active, (isActive, wasActive) => {
clearSearchDebounce()
if (isActive && wasActive === false) {
void refreshSearch()
}
})
async function refreshSearch() {
if (!active.value) {
return
}
const version = ++searchVersion
debug('refreshSearch start', {
version,
projectType: options.projectType.value,
params: effectiveRequestParams.value.substring(0, 100),
})
const currentHitsEmpty = isServerType.value
? serverHits.value.length === 0
@@ -221,8 +231,11 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
try {
const response = await options.search(effectiveRequestParams.value)
if (!active.value) {
return
}
if (version !== searchVersion) {
debug('refreshSearch stale, discarding', { version, current: searchVersion })
return
}
@@ -232,17 +245,10 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
projectHits.value = response.projectHits
}
totalHits.value = response.total_hits
debug('refreshSearch complete', {
version,
hits: response.total_hits,
projectHits: response.projectHits.length,
serverHits: response.serverHits.length,
})
updateUrlParams()
loading.value = false
} catch (err) {
debug('refreshSearch error', err)
console.error('Browse search error:', err)
if (version === searchVersion) {
loading.value = false
@@ -251,7 +257,9 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
}
function updateUrlParams() {
debug('updateUrlParams', { path: route.path })
if (!active.value) {
return
}
const persistentParams: Record<string, string | (string | null)[] | null | undefined> = {}
for (const [key, value] of Object.entries(route.query)) {
@@ -292,8 +300,7 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
watch(
() => options.projectType.value,
(newType, oldType) => {
debug('projectType changed', { from: oldType, to: newType })
() => {
effectiveCurrentSortType.value =
effectiveSortTypes.value.find((sortType) => sortType.name === 'relevance') ??
effectiveSortTypes.value[0]
@@ -4,7 +4,6 @@ import type { Component } from 'vue'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import Admonition from '#ui/components/base/Admonition.vue'
import PageHeader from '#ui/components/base/PageHeader.vue'
import LoaderIcon from '#ui/components/servers/icons/LoaderIcon.vue'
import { useServerImage } from '#ui/composables/servers/use-server-image.ts'
@@ -171,8 +170,5 @@ async function handleSelectedProjectsLeaveResult(
title-class="leading-8"
truncate-title
/>
<Admonition v-if="installContext.warning" type="warning" class="mb-1">
{{ installContext.warning }}
</Admonition>
</template>
</template>
@@ -3,6 +3,7 @@ import type { Labrinth } from '@modrinth/api-client'
import { SearchIcon } from '@modrinth/assets'
import { computed, toValue } from 'vue'
import Admonition from '#ui/components/base/Admonition.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import Combobox, { type ComboboxOption } from '#ui/components/base/Combobox.vue'
import LoadingIndicator from '#ui/components/base/LoadingIndicator.vue'
@@ -21,6 +22,7 @@ import { injectBrowseManager } from './providers/browse-manager'
const ctx = injectBrowseManager()
const { formatMessage } = useVIntl()
const lockedMessages = computed(() => toValue(ctx.lockedFilterMessages))
const installWarning = computed(() => ctx.installContext?.value?.warning)
const sortOptions = computed<ComboboxOption<SortType>[]>(() =>
ctx.effectiveSortTypes.value.map((st) => ({
@@ -61,6 +63,10 @@ const messages = defineMessages({
</script>
<template>
<Admonition v-if="installWarning" type="warning">
{{ installWarning }}
</Admonition>
<NavTabs v-if="ctx.showProjectTypeTabs.value" :links="ctx.selectableProjectTypes.value" />
<StyledInput
@@ -1,18 +1,29 @@
<template>
<div class="flex flex-col gap-4">
<Admonition
<div
v-if="!instanceInfoAdmonitionDismissed"
type="info"
:header="formatMessage(messages.instanceInfoHeader)"
dismissible
@dismiss="dismissInstanceInfoAdmonition"
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"
>
<ul class="m-0 pl-4">
<li>{{ formatMessage(messages.instanceInfoDefinition) }}</li>
<li>{{ formatMessage(messages.instanceInfoSwitching) }}</li>
<li>{{ formatMessage(messages.instanceInfoFiles) }}</li>
</ul>
</Admonition>
<InfoIcon class="mt-0.5 size-6 text-brand-blue" aria-hidden="true" />
<div class="flex min-w-0 flex-col gap-1">
<h2 class="m-0 text-xl font-bold leading-7">
{{ formatMessage(messages.instanceInfoHeader) }}
</h2>
<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
v-if="worldsPending"
@@ -39,12 +50,13 @@
<script setup lang="ts">
import type { Archon } from '@modrinth/api-client'
import { InfoIcon, XIcon } from '@modrinth/assets'
import { useQuery } from '@tanstack/vue-query'
import { useStorage } from '@vueuse/core'
import { computed } from 'vue'
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 { defineMessages, useVIntl } from '#ui/composables/i18n'
import {
@@ -61,20 +73,16 @@ const messages = defineMessages({
},
instanceInfoHeader: {
id: 'servers.manage.instances.info.header',
defaultMessage: 'What is an instance?',
defaultMessage: 'What is a server instance?',
},
instanceInfoDefinition: {
id: 'servers.manage.instances.info.definition',
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',
instanceInfoBody: {
id: 'servers.manage.instances.info.body',
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",
},
})