feat: exclude project types filter (#6660)

* feat: exclude project types filter

* fix merge issue
This commit is contained in:
Prospector
2026-07-08 14:30:47 +00:00
committed by GitHub
parent ddefc28ca6
commit 79cc5fef72
10 changed files with 228 additions and 77 deletions
+15
View File
@@ -49,6 +49,7 @@ import {
get_installed_project_ids as getInstalledProjectIds, get_installed_project_ids as getInstalledProjectIds,
} from '@/helpers/instance' } from '@/helpers/instance'
import { get_loader_versions as getLoaderManifest } from '@/helpers/metadata' import { get_loader_versions as getLoaderManifest } from '@/helpers/metadata'
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags' import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
import { get_instance_worlds } from '@/helpers/worlds' import { get_instance_worlds } from '@/helpers/worlds'
import { injectContentInstall } from '@/providers/content-install' import { injectContentInstall } from '@/providers/content-install'
@@ -1041,10 +1042,24 @@ function getProjectBrowseQuery() {
} }
} }
const advancedFiltersCollapsed = computed({
get: () => themeStore.getFeatureFlag('advanced_filters_collapsed'),
set: (value) => {
themeStore.featureFlags['advanced_filters_collapsed'] = value
getSettings()
.then((settings) => {
settings.feature_flags['advanced_filters_collapsed'] = value
return setSettings(settings)
})
.catch(handleError)
},
})
provideBrowseManager({ provideBrowseManager({
tags, tags,
projectType, projectType,
...searchState, ...searchState,
advancedFiltersCollapsed,
getProjectLink: (result: Labrinth.Search.v2.ResultSearchProject) => ({ getProjectLink: (result: Labrinth.Search.v2.ResultSearchProject) => ({
path: `/project/${result.project_id ?? result.slug}`, path: `/project/${result.project_id ?? result.slug}`,
query: getProjectBrowseQuery(), query: getProjectBrowseQuery(),
+1
View File
@@ -16,6 +16,7 @@ export const DEFAULT_FEATURE_FLAGS = {
pride_fundraiser: true, pride_fundraiser: true,
i18n_debug: false, i18n_debug: false,
show_instance_play_time: true, show_instance_play_time: true,
advanced_filters_collapsed: true,
} }
export const THEME_OPTIONS = ['dark', 'light', 'oled', 'system'] as const export const THEME_OPTIONS = ['dark', 'light', 'oled', 'system'] as const
@@ -58,6 +58,7 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
showHostingAccessInstanceAuditLog: false, showHostingAccessInstanceAuditLog: false,
versionDevInfoCollapsed: true, versionDevInfoCollapsed: true,
alwaysShowVersionDevInfo: false, alwaysShowVersionDevInfo: false,
advancedFiltersCollapsed: true,
} as const) } as const)
export type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS export type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS
@@ -372,6 +372,14 @@ const messages = defineMessages({
}, },
}) })
const advancedFiltersCollapsed = computed({
get: () => flags.value.advancedFiltersCollapsed,
set: (value) => {
flags.value.advancedFiltersCollapsed = value
saveFeatureFlags()
},
})
const projectTypeId = computed(() => projectType.value?.id ?? 'mod') const projectTypeId = computed(() => projectType.value?.id ?? 'mod')
debug('projectTypeId:', projectTypeId.value) debug('projectTypeId:', projectTypeId.value)
@@ -478,6 +486,7 @@ provideBrowseManager({
showServerOnly: showServerOnlyToggle, showServerOnly: showServerOnlyToggle,
serverOnlyLabel: computed(() => formatMessage(commonMessages.serverOnlyLabel)), serverOnlyLabel: computed(() => formatMessage(commonMessages.serverOnlyLabel)),
hiddenFilterTypes: computed(() => (showServerOnlyToggle.value ? ['environment'] : [])), hiddenFilterTypes: computed(() => (showServerOnlyToggle.value ? ['environment'] : [])),
advancedFiltersCollapsed,
displayMode: resultsDisplayMode, displayMode: resultsDisplayMode,
cycleDisplayMode: cycleSearchDisplayMode, cycleDisplayMode: cycleSearchDisplayMode,
maxResultsOptions: currentMaxResultsOptions, maxResultsOptions: currentMaxResultsOptions,
+1
View File
@@ -64,6 +64,7 @@ pub enum FeatureFlag {
I18nDebug, I18nDebug,
ShowInstancePlayTime, ShowInstancePlayTime,
SkipNonEssentialWarnings, SkipNonEssentialWarnings,
AdvancedFiltersCollapsed,
} }
impl Settings { impl Settings {
@@ -71,83 +71,105 @@
</template> </template>
<template v-else #default> <template v-else #default>
<slot name="prefix" /> <slot name="prefix" />
<StyledInput <div
v-if="filterType.searchable" v-if="filterType.display === 'toggle'"
:id="`search-${filterType.id}`" :class="innerPanelClass ? innerPanelClass : ''"
v-model="query" class="flex flex-col gap-3"
:icon="SearchIcon" >
type="text" <label
:placeholder="formatMessage(messages.searchPlaceholder)" v-for="option in filterType.options"
autocomplete="off" :key="`${filterType.id}-toggle-${option.id}`"
clearable class="flex cursor-pointer items-center justify-between text-secondary gap-3 font-semibold"
size="small" >
input-class="!bg-button-bg" <span class="text-sm">{{ option.formatted_name ?? option.id }}</span>
wrapper-class="mx-2 my-1 w-[calc(100%-1rem)]" <Toggle
/> :model-value="isExcluded(option)"
small
<ScrollablePanel :class="{ 'h-[16rem]': scrollable }" :disable-scrolling="!scrollable"> class="shrink-0"
<div :class="innerPanelClass ? innerPanelClass : ''" class="flex flex-col gap-1"> @update:model-value="toggleNegativeFilter(option)"
<template v-if="groupedOptions"> />
<SearchFilterGroup </label>
v-for="[groupName, options] in groupedOptions" </div>
:key="`${filterType.id}-group-${groupName}`" <template v-if="filterType.display !== 'toggle'">
:group-name="groupName" <StyledInput
:options="options" v-if="filterType.searchable"
:supports-negative-filter="filterType.supports_negative_filter" :id="`search-${filterType.id}`"
:included="isIncluded" v-model="query"
:excluded="isExcluded" :icon="SearchIcon"
@toggle="toggleFilter" type="text"
@toggle-exclude="toggleNegativeFilter" :placeholder="formatMessage(messages.searchPlaceholder)"
/> autocomplete="off"
</template> clearable
<template v-else> size="small"
<SearchFilterOption input-class="!bg-button-bg"
v-for="option in visibleOptions" wrapper-class="mx-2 my-1 w-[calc(100%-1rem)]"
:key="`${filterType.id}-${option}`" />
:option="option" <ScrollablePanel :class="{ 'h-[16rem]': scrollable }" :disable-scrolling="!scrollable">
:included="isIncluded(option)" <div :class="innerPanelClass ? innerPanelClass : ''" class="flex flex-col gap-1">
:excluded="isExcluded(option)" <template v-if="groupedOptions">
:supports-negative-filter="filterType.supports_negative_filter" <SearchFilterGroup
:class="{ v-for="[groupName, options] in groupedOptions"
'mr-3': scrollable, :key="`${filterType.id}-group-${groupName}`"
}" :group-name="groupName"
@toggle="toggleFilter" :options="options"
@toggle-exclude="toggleNegativeFilter" :supports-negative-filter="filterType.supports_negative_filter"
:included="isIncluded"
:excluded="isExcluded"
@toggle="toggleFilter"
@toggle-exclude="toggleNegativeFilter"
/>
</template>
<template v-else>
<SearchFilterOption
v-for="option in visibleOptions"
:key="`${filterType.id}-${option}`"
:option="option"
:included="isIncluded(option)"
:excluded="isExcluded(option)"
:supports-negative-filter="filterType.supports_negative_filter"
:class="{
'mr-3': scrollable,
}"
@toggle="toggleFilter"
@toggle-exclude="toggleNegativeFilter"
>
<slot name="option" :filter="filterType" :option="option">
<span
v-if="option.icon"
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>
</SearchFilterOption>
</template>
<button
v-if="filterType.display === 'expandable'"
class="flex bg-transparent text-secondary border-none cursor-pointer !w-full items-center gap-2 truncate rounded-xl px-2 py-1 text-sm font-semibold transition-all hover:text-contrast focus-visible:text-contrast active:scale-[0.98]"
@click="showMore = !showMore"
> >
<slot name="option" :filter="filterType" :option="option"> <DropdownIcon
<span class="h-4 w-4 transition-transform"
v-if="option.icon" :class="{ 'rotate-180': showMore }"
class="inline-flex items-center justify-center shrink-0 h-4 w-4" />
:style="iconStyle(option)" <span class="truncate text-sm">
> {{
<div showMore ? formatMessage(messages.showFewer) : formatMessage(messages.showMore)
v-if="typeof option.icon === 'string'" }}
class="h-4 w-4" </span>
v-html="option.icon" </button>
/> </div>
<component :is="option.icon" v-else class="h-4 w-4" /> </ScrollablePanel>
</span> </template>
<span class="truncate text-sm" :style="iconStyle(option)">
{{ option.formatted_name ?? option.id }}
</span>
</slot>
</SearchFilterOption>
</template>
<button
v-if="filterType.display === 'expandable'"
class="flex bg-transparent text-secondary border-none cursor-pointer !w-full items-center gap-2 truncate rounded-xl px-2 py-1 text-sm font-semibold transition-all hover:text-contrast focus-visible:text-contrast active:scale-[0.98]"
@click="showMore = !showMore"
>
<DropdownIcon
class="h-4 w-4 transition-transform"
:class="{ 'rotate-180': showMore }"
/>
<span class="truncate text-sm">
{{ showMore ? formatMessage(messages.showFewer) : formatMessage(messages.showMore) }}
</span>
</button>
</div>
</ScrollablePanel>
<div :class="innerPanelClass ? innerPanelClass : ''" class="empty:hidden"> <div :class="innerPanelClass ? innerPanelClass : ''" class="empty:hidden">
<Checkbox <Checkbox
v-for="group in filterType.toggle_groups" v-for="group in filterType.toggle_groups"
@@ -191,6 +213,7 @@ import { defineMessages, useVIntl } from '../../composables/i18n'
import type { FilterOption, FilterType, FilterValue } from '../../utils/search' import type { FilterOption, FilterType, FilterValue } from '../../utils/search'
import Accordion from '../base/Accordion.vue' import Accordion from '../base/Accordion.vue'
import ButtonStyled from '../base/ButtonStyled.vue' import ButtonStyled from '../base/ButtonStyled.vue'
import Toggle from '../base/Toggle.vue'
import { Checkbox, ScrollablePanel, StyledInput } from '../index' import { Checkbox, ScrollablePanel, StyledInput } from '../index'
import SearchFilterGroup from './SearchFilterGroup.vue' import SearchFilterGroup from './SearchFilterGroup.vue'
import SearchFilterOption from './SearchFilterOption.vue' import SearchFilterOption from './SearchFilterOption.vue'
@@ -73,6 +73,7 @@ export interface BrowseManagerContext {
showServerOnly?: ComputedRef<boolean> showServerOnly?: ComputedRef<boolean>
serverOnlyLabel?: ComputedRef<string> serverOnlyLabel?: ComputedRef<string>
hiddenFilterTypes?: ComputedRef<string[]> hiddenFilterTypes?: ComputedRef<string[]>
advancedFiltersCollapsed?: Ref<boolean>
onInstalled?: (projectId: string) => void onInstalled?: (projectId: string) => void
displayMode?: Ref<'list' | 'grid' | 'gallery'> | ComputedRef<'list' | 'grid' | 'gallery'> displayMode?: Ref<'list' | 'grid' | 'gallery'> | ComputedRef<'list' | 'grid' | 'gallery'>
@@ -17,6 +17,14 @@ const isApp = computed(() => ctx.variant === 'app')
const lockedMessages = computed(() => toValue(ctx.lockedFilterMessages)) const lockedMessages = computed(() => toValue(ctx.lockedFilterMessages))
const hiddenFilterTypes = computed(() => ctx.hiddenFilterTypes?.value ?? []) const hiddenFilterTypes = computed(() => ctx.hiddenFilterTypes?.value ?? [])
const advancedFiltersCollapsed = computed(() => ctx.advancedFiltersCollapsed?.value ?? true)
function setAdvancedFiltersCollapsed(collapsed: boolean) {
if (ctx.advancedFiltersCollapsed) {
ctx.advancedFiltersCollapsed.value = collapsed
}
}
function closeFiltersMenu() { function closeFiltersMenu() {
if (ctx.filtersMenuOpen) { if (ctx.filtersMenuOpen) {
ctx.filtersMenuOpen.value = false ctx.filtersMenuOpen.value = false
@@ -49,6 +57,9 @@ function hasProvidedFilter(filterId: string): boolean {
} }
function getFilterOpenByDefault(filterId: string): boolean { function getFilterOpenByDefault(filterId: string): boolean {
if (filterId === 'advanced') {
return !advancedFiltersCollapsed.value
}
if (hasProvidedFilter(filterId)) { if (hasProvidedFilter(filterId)) {
return true return true
} }
@@ -188,6 +199,8 @@ function getFilterOpenByDefault(filterId: string): boolean {
:content-class="contentClass" :content-class="contentClass"
:inner-panel-class="innerPanelClass" :inner-panel-class="innerPanelClass"
:open-by-default="getFilterOpenByDefault(filter.id)" :open-by-default="getFilterOpenByDefault(filter.id)"
@on-open="() => filter.id === 'advanced' && setAdvancedFiltersCollapsed(false)"
@on-close="() => filter.id === 'advanced' && setAdvancedFiltersCollapsed(true)"
> >
<template #header> <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-lg font-semibold'">
+12
View File
@@ -3572,6 +3572,18 @@
"search.filter.option.show_more": { "search.filter.option.show_more": {
"defaultMessage": "Show more" "defaultMessage": "Show more"
}, },
"search.filter_type.advanced": {
"defaultMessage": "Advanced"
},
"search.filter_type.advanced.exclude_datapack": {
"defaultMessage": "Exclude data packs"
},
"search.filter_type.advanced.exclude_mod": {
"defaultMessage": "Exclude mods"
},
"search.filter_type.advanced.exclude_plugin": {
"defaultMessage": "Exclude plugins"
},
"search.filter_type.environment": { "search.filter_type.environment": {
"defaultMessage": "Environment" "defaultMessage": "Environment"
}, },
+76 -1
View File
@@ -46,7 +46,7 @@ export type FilterType = {
ordering?: number ordering?: number
} & ( } & (
| { | {
display: 'all' | 'scrollable' | 'none' display: 'all' | 'scrollable' | 'none' | 'toggle'
} }
| { | {
display: 'expandable' display: 'expandable'
@@ -112,6 +112,12 @@ export interface SortType {
const PLUGIN_PLATFORMS = ['bungeecord', 'waterfall', 'velocity', 'geyser'] const PLUGIN_PLATFORMS = ['bungeecord', 'waterfall', 'velocity', 'geyser']
const PROJECT_TYPE_EXCLUSION_FILTERS: Partial<Record<ProjectType, ProjectType[]>> = {
mod: ['plugin', 'datapack'],
plugin: ['mod', 'datapack'],
datapack: ['mod', 'plugin'],
}
export function useSearch( export function useSearch(
projectTypes: Ref<ProjectType[]>, projectTypes: Ref<ProjectType[]>,
tags: Ref<Tags>, tags: Ref<Tags>,
@@ -143,6 +149,34 @@ export function useSearch(
return formatCategory(formatMessage, categoryName) return formatCategory(formatMessage, categoryName)
} }
const formatExcludeProjectTypeLabel = (projectType: ProjectType): string => {
switch (projectType) {
case 'mod':
return formatMessage(
defineMessage({
id: 'search.filter_type.advanced.exclude_mod',
defaultMessage: 'Exclude mods',
}),
)
case 'plugin':
return formatMessage(
defineMessage({
id: 'search.filter_type.advanced.exclude_plugin',
defaultMessage: 'Exclude plugins',
}),
)
case 'datapack':
return formatMessage(
defineMessage({
id: 'search.filter_type.advanced.exclude_datapack',
defaultMessage: 'Exclude data packs',
}),
)
default:
return projectType
}
}
const filters = computed(() => { const filters = computed(() => {
const categoryFilters: Record<string, FilterType> = {} const categoryFilters: Record<string, FilterType> = {}
for (const category of sortedCategories(tags.value, formatCategoryName, locale.value)) { for (const category of sortedCategories(tags.value, formatCategoryName, locale.value)) {
@@ -171,6 +205,15 @@ export function useSearch(
}) })
} }
const excludeableProjectTypes: ProjectType[] = []
for (const projectType of projectTypes.value) {
for (const target of PROJECT_TYPE_EXCLUSION_FILTERS[projectType] ?? []) {
if (!excludeableProjectTypes.includes(target)) {
excludeableProjectTypes.push(target)
}
}
}
const filterTypes: FilterType[] = [ const filterTypes: FilterType[] = [
...Object.values(categoryFilters), ...Object.values(categoryFilters),
{ {
@@ -429,6 +472,26 @@ export function useSearch(
options: [], options: [],
allows_custom_options: 'and', allows_custom_options: 'and',
}, },
{
id: 'advanced',
formatted_name: formatMessage(
defineMessage({
id: 'search.filter_type.advanced',
defaultMessage: 'Advanced',
}),
),
supported_project_types: ['mod', 'plugin', 'datapack'],
display: 'toggle',
query_param: 'a',
searchable: false,
ordering: -1000,
options: excludeableProjectTypes.map((target) => ({
id: target,
formatted_name: formatExcludeProjectTypeLabel(target),
method: 'and',
value: `all_project_types:${mapProjectTypeToSearch(target)}`,
})),
},
] ]
return filterTypes return filterTypes
@@ -460,6 +523,9 @@ export function useSearch(
console.error(`Filter type ${filterValue.type} not found`) console.error(`Filter type ${filterValue.type} not found`)
continue continue
} }
if (type.id === 'advanced') {
continue
}
let option = type?.options.find((option) => option.id === filterValue.option) let option = type?.options.find((option) => option.id === filterValue.option)
if (!option && type.allows_custom_options) { if (!option && type.allows_custom_options) {
option = { option = {
@@ -550,6 +616,15 @@ export function useSearch(
parts.push(`project_types IN [${quoted}]`) parts.push(`project_types IN [${quoted}]`)
} }
const excludedProjectTypes = filterValues
.filter((filterValue) => filterValue.type === 'advanced')
.map((filterValue) =>
formatSearchFilterValue(mapProjectTypeToSearch(filterValue.option as ProjectType)),
)
if (excludedProjectTypes.length > 0) {
parts.push(`all_project_types NOT IN [${excludedProjectTypes.join(', ')}]`)
}
return parts.join(' AND ') return parts.join(' AND ')
}) })