Fix Discover URL filter parsing, improve search sidebar (#5104)

* fix category parsing on discover

* Make categories (loader, platform, etc) colored in discover, also add i18n

* fix formatting

* add localized strings

---------

Co-authored-by: Creeperkatze <178587183+Creeperkatze@users.noreply.github.com>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
Creeperkatze
2026-01-16 00:01:09 +01:00
committed by GitHub
parent 169224560b
commit b0ed808745
4 changed files with 108 additions and 23 deletions

View File

@@ -23,7 +23,7 @@
></div> ></div>
<button <button
v-if="supportsNegativeFilter && !excluded" v-if="supportsNegativeFilter && !excluded"
v-tooltip="excluded ? 'Remove exclusion' : 'Exclude'" v-tooltip="formatMessage(messages.excludeTooltip)"
class="flex border-none cursor-pointer items-center justify-center gap-2 rounded-xl bg-transparent px-2 py-1 text-sm font-semibold text-secondary [@media(hover:hover)]:opacity-0 transition-all hover:bg-button-bg hover:text-red focus-visible:bg-button-bg focus-visible:text-red active:scale-[0.96]" class="flex border-none cursor-pointer items-center justify-center gap-2 rounded-xl bg-transparent px-2 py-1 text-sm font-semibold text-secondary [@media(hover:hover)]:opacity-0 transition-all hover:bg-button-bg hover:text-red focus-visible:bg-button-bg focus-visible:text-red active:scale-[0.96]"
@click="() => emit('toggleExclude', option)" @click="() => emit('toggleExclude', option)"
> >
@@ -35,6 +35,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { BanIcon, CheckIcon } from '@modrinth/assets' import { BanIcon, CheckIcon } from '@modrinth/assets'
import { defineMessages, useVIntl } from '../../composables/i18n'
import type { FilterOption } from '../../utils/search' import type { FilterOption } from '../../utils/search'
withDefaults( withDefaults(
@@ -49,10 +50,19 @@ withDefaults(
}, },
) )
const { formatMessage } = useVIntl()
const emit = defineEmits<{ const emit = defineEmits<{
toggle: [option: FilterOption] toggle: [option: FilterOption]
toggleExclude: [option: FilterOption] toggleExclude: [option: FilterOption]
}>() }>()
const messages = defineMessages({
excludeTooltip: {
id: 'search.filter.option.exclusion.add.tooltip',
defaultMessage: 'Exclude',
},
})
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.search-filter-option:hover, .search-filter-option:hover,

View File

@@ -71,10 +71,15 @@
v-model="query" v-model="query"
class="!min-h-9 text-sm" class="!min-h-9 text-sm"
type="text" type="text"
:placeholder="`Search...`" :placeholder="formatMessage(messages.searchPlaceholder)"
autocomplete="off" autocomplete="off"
/> />
<Button v-if="query" class="r-btn" aria-label="Clear search" @click="() => (query = '')"> <Button
v-if="query"
class="r-btn"
:aria-label="formatMessage(messages.clearSearchAriaLabel)"
@click="() => (query = '')"
>
<XIcon aria-hidden="true" /> <XIcon aria-hidden="true" />
</Button> </Button>
</div> </div>
@@ -95,9 +100,17 @@
@toggle-exclude="toggleNegativeFilter" @toggle-exclude="toggleNegativeFilter"
> >
<slot name="option" :filter="filterType" :option="option"> <slot name="option" :filter="filterType" :option="option">
<div v-if="typeof option.icon === 'string'" class="h-4 w-4" v-html="option.icon" /> <span
<component :is="option.icon" v-else-if="option.icon" class="h-4 w-4" /> v-if="option.icon"
<span class="truncate text-sm">{{ option.formatted_name ?? option.id }}</span> class="inline-flex items-center justify-center shrink-0 h-4 w-4"
:style="iconStyle(option)"
>
<div v-if="typeof option.icon === 'string'" class="h-4 w-4" v-html="option.icon" />
<component :is="option.icon" v-else class="h-4 w-4" />
</span>
<span class="truncate text-sm" :style="iconStyle(option)">
{{ option.formatted_name ?? option.id }}
</span>
</slot> </slot>
</SearchFilterOption> </SearchFilterOption>
<button <button
@@ -109,7 +122,9 @@
class="h-4 w-4 transition-transform" class="h-4 w-4 transition-transform"
:class="{ 'rotate-180': showMore }" :class="{ 'rotate-180': showMore }"
/> />
<span class="truncate text-sm">{{ showMore ? 'Show fewer' : 'Show more' }}</span> <span class="truncate text-sm">
{{ showMore ? formatMessage(messages.showFewer) : formatMessage(messages.showMore) }}
</span>
</button> </button>
</div> </div>
</ScrollablePanel> </ScrollablePanel>
@@ -234,6 +249,22 @@ const scrollable = computed(
() => visibleOptions.value.length >= 10 && props.filterType.display === 'scrollable', () => visibleOptions.value.length >= 10 && props.filterType.display === 'scrollable',
) )
function iconStyle(option: FilterOption) {
// Match project page platform coloring (Forge/Fabric/Velocity/etc.) while leaving other
// filter icons unchanged.
if (
props.filterType.id === 'mod_loader' ||
props.filterType.id === 'modpack_loader' ||
props.filterType.id === 'plugin_loader' ||
props.filterType.id === 'plugin_platform' ||
props.filterType.id === 'shader_loader'
) {
return { color: `var(--color-platform-${option.id})` }
}
return undefined
}
function groupEnabled(group: string) { function groupEnabled(group: string) {
return toggledGroups.value.includes(group) return toggledGroups.value.includes(group)
} }
@@ -315,6 +346,22 @@ function clearFilters() {
} }
const messages = defineMessages({ const messages = defineMessages({
searchPlaceholder: {
id: 'search.filter.option.search.placeholder',
defaultMessage: 'Search...',
},
clearSearchAriaLabel: {
id: 'search.filter.option.search.clear.aria_label',
defaultMessage: 'Clear search',
},
showFewer: {
id: 'search.filter.option.show_fewer',
defaultMessage: 'Show fewer',
},
showMore: {
id: 'search.filter.option.show_more',
defaultMessage: 'Show more',
},
unlockFilterButton: { unlockFilterButton: {
id: 'search.filter.locked.default.unlock', id: 'search.filter.locked.default.unlock',
defaultMessage: 'Unlock filter', defaultMessage: 'Unlock filter',

View File

@@ -920,6 +920,21 @@
"search.filter.locked.default.unlock": { "search.filter.locked.default.unlock": {
"defaultMessage": "Unlock filter" "defaultMessage": "Unlock filter"
}, },
"search.filter.option.exclusion.add.tooltip": {
"defaultMessage": "Exclude"
},
"search.filter.option.search.clear.aria_label": {
"defaultMessage": "Clear search"
},
"search.filter.option.search.placeholder": {
"defaultMessage": "Search..."
},
"search.filter.option.show_fewer": {
"defaultMessage": "Show fewer"
},
"search.filter.option.show_more": {
"defaultMessage": "Show more"
},
"search.filter_type.environment": { "search.filter_type.environment": {
"defaultMessage": "Environment" "defaultMessage": "Environment"
}, },

View File

@@ -558,32 +558,45 @@ export function useSearch(
}) })
for (const key of Object.keys(route.query).filter((key) => !readParams.has(key))) { for (const key of Object.keys(route.query).filter((key) => !readParams.has(key))) {
const type = filters.value.find((type) => type.query_param === key) const types = filters.value.filter((type) => type.query_param === key)
if (type) { if (types.length === 0) {
const values = getParamValuesAsArray(route.query[key]) console.error(`Unknown filter type: ${key}`)
continue
}
for (const value of values) { const values = getParamValuesAsArray(route.query[key])
const negative = !value.includes(':') && value.includes('!=')
for (const value of values) {
const negative = !value.includes(':') && value.includes('!=')
let matched = false
for (const type of types) {
const option = type.options.find((option) => getOptionValue(option, negative) === value) const option = type.options.find((option) => getOptionValue(option, negative) === value)
if (!option) {
continue
}
if (!option && type.allows_custom_options) { currentFilters.value.push({
type: type.id,
option: option.id,
negative: negative,
})
matched = true
break
}
if (!matched) {
const customType = types.find((type) => type.allows_custom_options)
if (customType) {
currentFilters.value.push({ currentFilters.value.push({
type: type.id, type: customType.id,
option: value.replace('!=', ':'), option: value.replace('!=', ':'),
negative: negative, negative: negative,
}) })
} else if (option) {
currentFilters.value.push({
type: type.id,
option: option.id,
negative: negative,
})
} else { } else {
console.error(`Unknown filter option: ${value}`) console.error(`Unknown filter option for ${key}: ${value}`)
} }
} }
} else {
console.error(`Unknown filter type: ${key}`)
} }
} }
} }