Compare commits

...
Author SHA1 Message Date
Prospector 2298937c59 fix: environment filters 2026-07-04 16:03:36 -07:00
13 changed files with 190 additions and 68 deletions
@@ -180,7 +180,7 @@
"message": "Discover servers"
},
"app.browse.hide-added-servers": {
"message": "Hide already added servers"
"message": "Hide servers already added"
},
"app.browse.project-type.modpacks": {
"message": "Modpacks"
+1 -1
View File
@@ -403,7 +403,7 @@ const messages = defineMessages({
},
hideAddedServers: {
id: 'app.browse.hide-added-servers',
defaultMessage: 'Hide already added servers',
defaultMessage: 'Hide servers already added',
},
installingToServer: {
id: 'app.browse.server.installing',
@@ -4,6 +4,7 @@ import type {
BrowseInstallPlan,
BrowseSearchState,
CreationFlowContextValue,
EnvironmentSearchOverride,
FilterValue,
PendingServerContentInstall,
PendingServerContentInstallType,
@@ -160,6 +161,7 @@ export function useServerInstallContent({
})
const serverHideInstalled = ref(false)
const serverContentServerOnly = ref(false)
const hideSelectedServerInstalls = ref(false)
const installingProjectIds = ref<Set<string>>(new Set())
const optimisticallyInstalledProjectIds = ref<Set<string>>(new Set())
@@ -348,10 +350,6 @@ export function useServerInstallContent({
filters.push({ type: 'plugin_loader', option: platform })
}
if (projectType.value?.id === 'mod') {
filters.push({ type: 'environment', option: 'server' })
}
if (serverHideInstalled.value && hiddenInstalledProjectIds.value.size > 0) {
for (const x of hiddenInstalledProjectIds.value) {
filters.push({
@@ -383,6 +381,28 @@ export function useServerInstallContent({
return filters
})
const showServerOnlyToggle = computed(() => !!serverData.value && projectType.value?.id === 'mod')
const serverEnvironmentOverride = computed<EnvironmentSearchOverride | undefined>(() => {
if (!showServerOnlyToggle.value) return undefined
if (serverContentServerOnly.value) {
return {
mode: 'include',
values: [
'server_only',
'dedicated_server_only',
'server_only_client_optional',
'client_or_server_prefers_both',
'client_or_server',
],
}
}
return {
mode: 'exclude',
values: ['client_only', 'singleplayer_only'],
}
})
function getCurrentServerInstallType(): BrowseInstallContentType {
const type = projectType.value?.id
if (type === 'modpack' || type === 'mod' || type === 'plugin' || type === 'datapack') {
@@ -737,6 +757,10 @@ export function useServerInstallContent({
serverHideInstalled.value = route.query.shi === 'true'
}
if (route.query.so && projectType.value?.id === 'mod') {
serverContentServerOnly.value = route.query.so === 'true'
}
watch(serverHideInstalled, (hideInstalled) => {
if (hideInstalled) {
syncHiddenInstalledProjectIds()
@@ -763,6 +787,9 @@ export function useServerInstallContent({
serverContentData,
serverFilters,
serverHideInstalled,
serverContentServerOnly,
showServerOnlyToggle,
serverEnvironmentOverride,
hideSelectedServerInstalls,
installingProjectIds,
optimisticallyInstalledProjectIds,
@@ -158,6 +158,9 @@ const {
serverContentData,
serverFilters,
serverHideInstalled,
serverContentServerOnly,
showServerOnlyToggle,
serverEnvironmentOverride,
hideSelectedServerInstalls,
installingProjectIds,
optimisticallyInstalledProjectIds,
@@ -378,10 +381,12 @@ const searchState = useBrowseSearch({
projectType: projectTypeId,
tags,
providedFilters: serverFilters,
environmentOverride: serverEnvironmentOverride,
search,
persistentQueryParams: ['sid', 'wid', 'shi', 'from'],
persistentQueryParams: ['sid', 'wid', 'shi', 'so', 'from'],
getExtraQueryParams: () => ({
shi: serverHideInstalled.value ? 'true' : undefined,
so: showServerOnlyToggle.value && serverContentServerOnly.value ? 'true' : undefined,
}),
maxResultsOptions: currentMaxResultsOptions,
displayMode: resultsDisplayMode,
@@ -469,6 +474,10 @@ provideBrowseManager({
queuedServerInstallCount.value > 0,
),
hideSelectedLabel: computed(() => formatMessage(commonMessages.hideSelectedContentLabel)),
serverOnly: serverContentServerOnly,
showServerOnly: showServerOnlyToggle,
serverOnlyLabel: computed(() => formatMessage(commonMessages.serverOnlyLabel)),
hiddenFilterTypeIds: computed(() => (showServerOnlyToggle.value ? ['environment'] : [])),
displayMode: resultsDisplayMode,
cycleDisplayMode: cycleSearchDisplayMode,
maxResultsOptions: currentMaxResultsOptions,
@@ -1,6 +1,6 @@
<template>
<div v-bind="$attrs">
<div v-if="divider && !!slots.title" class="flex items-center gap-4 mb-4">
<div v-if="divider && (!!slots.title || !!slots.button)" class="flex items-center gap-4 mb-4">
<button
:class="
buttonClass ??
@@ -24,7 +24,7 @@
<hr class="h-px w-full border-none bg-divider" aria-hidden="true" />
</div>
<button
v-else-if="!!slots.title"
v-else-if="!!slots.title || !!slots.button"
:class="buttonClass ?? 'flex flex-col gap-2 bg-transparent m-0 p-0 border-none'"
@click="() => (forceOpen ? undefined : toggledOpen ? close() : open())"
>
@@ -7,10 +7,16 @@
title-wrapper-class="flex flex-col gap-2 justify-start items-start"
:open-by-default="openByDefault !== undefined ? openByDefault : true"
>
<template #title>
<slot name="header" :filter="filterType">
<h2 class="font-semibold">{{ filterType.formatted_name }}</h2>
</slot>
<template #button="{ open }">
<div class="flex items-center gap-1 w-full text-contrast">
<slot name="header" :filter="filterType">
<h2 class="text-base font-semibold text-red">{{ filterType.formatted_name }}</h2>
</slot>
<DropdownIcon
class="ml-auto size-5 transition-transform duration-300 shrink-0 text-primary group-hover:text-contrast"
:class="{ 'rotate-180': open }"
/>
</div>
</template>
<template
v-if="
@@ -39,7 +45,7 @@
</div>
</template>
<template v-if="locked" #default>
<div class="flex flex-col gap-2 p-3 border-dashed border-2 rounded-2xl border-divider mx-2">
<div class="flex flex-col gap-2 p-3 border-dashed border-2 rounded-2xl border-divider">
<p class="m-0 font-bold items-center">
<slot :name="`locked-${filterType.id}`">
{{ formatMessage(messages.lockedTitle, { type: filterType.formatted_name }) }}
@@ -4,7 +4,13 @@ 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 type {
EnvironmentSearchOverride,
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 +24,7 @@ export interface UseBrowseSearchOptions {
categories: Labrinth.Tags.v2.Category[]
}>
providedFilters?: ComputedRef<FilterValue[]>
environmentOverride?: ComputedRef<EnvironmentSearchOverride | undefined>
search: (params: string) => Promise<BrowseSearchResponse>
persistentQueryParams: string[]
getExtraQueryParams?: () => Record<string, string | undefined>
@@ -82,7 +89,12 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
sortTypes,
requestParams,
createPageParams,
} = useSearch(projectTypes, options.tags, options.providedFilters ?? computed(() => []))
} = useSearch(
projectTypes,
options.tags,
options.providedFilters ?? computed(() => []),
options.environmentOverride ?? computed(() => undefined),
)
const {
serverCurrentSortType,
@@ -164,7 +164,11 @@ const messages = defineMessages({
<SearchFilterControl
v-else
v-model:selected-filters="ctx.currentFilters.value"
:filters="ctx.filters.value.filter((f) => f.display !== 'none')"
:filters="
ctx.filters.value.filter(
(f) => f.display !== 'none' && !(ctx.hiddenFilterTypes?.value ?? []).includes(f.id),
)
"
:provided-filters="ctx.providedFilters?.value ?? []"
:overridden-provided-filter-types="ctx.overriddenProvidedFilterTypes.value"
:provided-message="lockedMessages?.providedBy"
@@ -69,6 +69,10 @@ export interface BrowseManagerContext {
hideSelected?: Ref<boolean>
showHideSelected?: ComputedRef<boolean>
hideSelectedLabel?: ComputedRef<string>
serverOnly?: Ref<boolean>
showServerOnly?: ComputedRef<boolean>
serverOnlyLabel?: ComputedRef<string>
hiddenFilterTypes?: ComputedRef<string[]>
onInstalled?: (projectId: string) => void
displayMode?: Ref<'list' | 'grid' | 'gallery'> | ComputedRef<'list' | 'grid' | 'gallery'>
@@ -3,7 +3,7 @@ import { InfoIcon, XIcon } from '@modrinth/assets'
import { computed, toValue } from 'vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import Checkbox from '#ui/components/base/Checkbox.vue'
import Toggle from '#ui/components/base/Toggle.vue'
import SearchSidebarFilter from '#ui/components/search/SearchSidebarFilter.vue'
import { useVIntl } from '#ui/composables/i18n'
import { commonMessages } from '#ui/utils/common-messages'
@@ -15,6 +15,7 @@ const { formatMessage } = useVIntl()
const isApp = computed(() => ctx.variant === 'app')
const lockedMessages = computed(() => toValue(ctx.lockedFilterMessages))
const hiddenFilterTypes = computed(() => ctx.hiddenFilterTypes?.value ?? [])
function closeFiltersMenu() {
if (ctx.filtersMenuOpen) {
@@ -37,7 +38,7 @@ const buttonClass = computed(() => {
if (isApp.value) {
return 'button-animation flex flex-col gap-1 px-3 py-3 w-full bg-transparent cursor-pointer border-none hover:bg-button-bg'
}
return 'button-animation flex flex-col gap-1 px-6 py-3 w-full bg-transparent cursor-pointer border-none'
return 'button-animation flex flex-col gap-1 px-4 py-3 w-full bg-transparent cursor-pointer border-none'
})
const contentClass = computed(() => (isApp.value ? 'mt-2 mb-3' : 'mb-4 mx-3'))
@@ -99,38 +100,60 @@ function getFilterOpenByDefault(filterId: string): boolean {
</div>
<div
v-if="ctx.showHideInstalled?.value || ctx.showHideSelected?.value"
v-if="
ctx.showHideInstalled?.value || ctx.showHideSelected?.value || ctx.showServerOnly?.value
"
:class="
isApp
? 'flex flex-col gap-3 border-0 border-b-[1px] p-4 last:border-b-0 border-[--brand-gradient-border] border-solid'
: 'card-shadow flex flex-col gap-3 rounded-2xl bg-bg-raised p-4'
: 'card-shadow flex flex-col gap-3 rounded-2xl bg-bg-raised border-solid border-surface-4 border p-4'
"
>
<Checkbox
<label
v-if="ctx.showServerOnly?.value"
class="flex cursor-pointer items-center justify-between gap-3 text-contrast font-medium"
>
{{ ctx.serverOnlyLabel?.value ?? formatMessage(commonMessages.serverOnlyLabel) }}
<Toggle
v-model="ctx.serverOnly!.value"
small
class="shrink-0"
@update:model-value="ctx.onFilterChange()"
/>
</label>
<label
v-if="ctx.showHideInstalled?.value"
v-model="ctx.hideInstalled!.value"
:label="
class="flex cursor-pointer items-center justify-between gap-3 text-contrast font-medium"
>
{{
ctx.hideInstalledLabel?.value ?? formatMessage(commonMessages.hideInstalledContentLabel)
"
class="filter-checkbox"
@update:model-value="ctx.onFilterChange()"
@click.prevent.stop
/>
<Checkbox
}}
<Toggle
v-model="ctx.hideInstalled!.value"
small
class="shrink-0"
@update:model-value="ctx.onFilterChange()"
/>
</label>
<label
v-if="ctx.showHideSelected?.value"
v-model="ctx.hideSelected!.value"
:label="
ctx.hideSelectedLabel?.value ?? formatMessage(commonMessages.hideSelectedContentLabel)
"
class="filter-checkbox"
@update:model-value="ctx.onFilterChange()"
@click.prevent.stop
/>
class="flex cursor-pointer items-center justify-between gap-3 text-contrast font-medium"
>
{{ ctx.hideSelectedLabel?.value ?? formatMessage(commonMessages.hideSelectedContentLabel) }}
<Toggle
v-model="ctx.hideSelected!.value"
small
class="shrink-0"
@update:model-value="ctx.onFilterChange()"
/>
</label>
</div>
<template v-if="ctx.isServerType.value">
<SearchSidebarFilter
v-for="filterType in ctx.serverFilterTypes.value.filter((f) => f.options.length > 0)"
v-for="filterType in ctx.serverFilterTypes.value.filter(
(f) => f.options.length > 0 && !hiddenFilterTypes.includes(f.id),
)"
:key="`server-filter-${filterType.id}`"
v-model:selected-filters="ctx.serverCurrentFilters.value"
v-model:toggled-groups="ctx.serverToggledGroups.value"
@@ -143,7 +166,7 @@ function getFilterOpenByDefault(filterId: string): boolean {
:open-by-default="getFilterOpenByDefault(filterType.id)"
>
<template #header>
<h3 :class="isApp ? 'text-base m-0' : 'm-0 text-lg font-semibold'">
<h3 :class="isApp ? 'text-base m-0' : 'm-0 text-base font-semibold'">
{{ filterType.formatted_name }}
</h3>
</template>
@@ -151,7 +174,9 @@ function getFilterOpenByDefault(filterId: string): boolean {
</template>
<template v-else>
<SearchSidebarFilter
v-for="filter in ctx.filters.value.filter((f) => f.display !== 'none')"
v-for="filter in ctx.filters.value.filter(
(f) => f.display !== 'none' && !hiddenFilterTypes.includes(f.id),
)"
:key="`filter-${filter.id}`"
v-model:selected-filters="ctx.currentFilters.value"
v-model:toggled-groups="ctx.toggledGroups.value"
+4 -1
View File
@@ -2190,7 +2190,7 @@
"defaultMessage": "Game version"
},
"label.hide-installed-content": {
"defaultMessage": "Hide already installed content"
"defaultMessage": "Hide content already installed"
},
"label.hide-selected-content": {
"defaultMessage": "Hide selected content"
@@ -2294,6 +2294,9 @@
"label.server": {
"defaultMessage": "Server"
},
"label.server-only": {
"defaultMessage": "Server only"
},
"label.servers": {
"defaultMessage": "Servers"
},
+5 -1
View File
@@ -429,12 +429,16 @@ export const commonMessages = defineMessages({
},
hideInstalledContentLabel: {
id: 'label.hide-installed-content',
defaultMessage: 'Hide already installed content',
defaultMessage: 'Hide content already installed',
},
hideSelectedContentLabel: {
id: 'label.hide-selected-content',
defaultMessage: 'Hide selected content',
},
serverOnlyLabel: {
id: 'label.server-only',
defaultMessage: 'Server only',
},
installedModpackTitle: {
id: 'label.installed-modpack',
defaultMessage: 'Installed modpack',
+53 -25
View File
@@ -60,6 +60,10 @@ export type FilterValue = {
negative?: boolean
}
export type EnvironmentSearchOverride =
| { mode: 'include'; values: string[] }
| { mode: 'exclude'; values: string[] }
export const LOADER_FILTER_TYPES = [
'mod_loader',
'plugin_loader',
@@ -112,6 +116,7 @@ export function useSearch(
projectTypes: Ref<ProjectType[]>,
tags: Ref<Tags>,
providedFilters: Ref<FilterValue[]>,
environmentOverride: Ref<EnvironmentSearchOverride | undefined> = ref(undefined),
) {
const query = ref('')
const maxResults = ref(20)
@@ -505,22 +510,34 @@ export function useSearch(
}
// Environment facets
const client = filterValues.some(
(filter) => filter.type === 'environment' && filter.option === 'client',
)
const server = filterValues.some(
(filter) => filter.type === 'environment' && filter.option === 'server',
)
for (const envGroup of getEnvironmentFilterGroups(client, server)) {
if (envGroup.length === 1) {
const [field, val] = envGroup[0].split(':')
parts.push(`${field} = ${formatSearchFilterValue(val)}`)
} else if (envGroup.length > 1) {
const conditions = envGroup.map((f) => {
const [field, val] = f.split(':')
return `${field} = ${formatSearchFilterValue(val)}`
})
parts.push(`(${conditions.join(' OR ')})`)
const override = environmentOverride.value
if (override) {
if (override.values.length === 1) {
const operator = override.mode === 'include' ? '=' : '!='
parts.push(`environment ${operator} ${formatSearchFilterValue(override.values[0])}`)
} else if (override.values.length > 1) {
const operator = override.mode === 'include' ? 'IN' : 'NOT IN'
const quoted = override.values.map(formatSearchFilterValue).join(', ')
parts.push(`environment ${operator} [${quoted}]`)
}
} else {
const client = filterValues.some(
(filter) => filter.type === 'environment' && filter.option === 'client',
)
const server = filterValues.some(
(filter) => filter.type === 'environment' && filter.option === 'server',
)
for (const envGroup of getEnvironmentFilterGroups(client, server)) {
if (envGroup.length === 1) {
const [field, val] = envGroup[0].split(':')
parts.push(`${field} = ${formatSearchFilterValue(val)}`)
} else if (envGroup.length > 1) {
const conditions = envGroup.map((f) => {
const [field, val] = f.split(':')
return `${field} = ${formatSearchFilterValue(val)}`
})
parts.push(`(${conditions.join(' OR ')})`)
}
}
}
@@ -777,17 +794,28 @@ function mapProjectTypeToSearch(projectType: ProjectType): string {
function getEnvironmentFilterGroups(client: boolean, server: boolean): string[][] {
const groups: string[][] = []
if (client && server) {
groups.push(
['client_side:required', 'client_side:optional', 'client_side:unsupported'],
['server_side:required', 'server_side:optional'],
)
groups.push([
'environment:client_only_server_optional',
'environment:server_only_client_optional',
'environment:client_and_server',
'environment:client_or_server',
'environment:client_or_server_prefers_both',
])
} else if (client) {
groups.push(
['client_side:optional', 'client_side:required'],
['server_side:optional', 'server_side:unsupported'],
)
groups.push([
'environment:client_only',
'environment:client_only_server_optional',
'environment:client_or_server_prefers_both',
'environment:client_or_server',
])
} else if (server) {
groups.push(['server_side:optional', 'server_side:required'])
groups.push([
'environment:server_only',
'environment:dedicated_server_only',
'environment:server_only_client_optional',
'environment:client_or_server_prefers_both',
'environment:client_or_server',
])
}
return groups
}