mirror of
https://github.com/modrinth/code.git
synced 2026-08-25 00:55:25 +00:00
feat: exclude project types filter (#6660)
* feat: exclude project types filter * fix merge issue
This commit is contained in:
@@ -49,6 +49,7 @@ import {
|
||||
get_installed_project_ids as getInstalledProjectIds,
|
||||
} from '@/helpers/instance'
|
||||
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_instance_worlds } from '@/helpers/worlds'
|
||||
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({
|
||||
tags,
|
||||
projectType,
|
||||
...searchState,
|
||||
advancedFiltersCollapsed,
|
||||
getProjectLink: (result: Labrinth.Search.v2.ResultSearchProject) => ({
|
||||
path: `/project/${result.project_id ?? result.slug}`,
|
||||
query: getProjectBrowseQuery(),
|
||||
|
||||
@@ -16,6 +16,7 @@ export const DEFAULT_FEATURE_FLAGS = {
|
||||
pride_fundraiser: true,
|
||||
i18n_debug: false,
|
||||
show_instance_play_time: true,
|
||||
advanced_filters_collapsed: true,
|
||||
}
|
||||
|
||||
export const THEME_OPTIONS = ['dark', 'light', 'oled', 'system'] as const
|
||||
|
||||
@@ -58,6 +58,7 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
|
||||
showHostingAccessInstanceAuditLog: false,
|
||||
versionDevInfoCollapsed: true,
|
||||
alwaysShowVersionDevInfo: false,
|
||||
advancedFiltersCollapsed: true,
|
||||
} as const)
|
||||
|
||||
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')
|
||||
|
||||
debug('projectTypeId:', projectTypeId.value)
|
||||
@@ -478,6 +486,7 @@ provideBrowseManager({
|
||||
showServerOnly: showServerOnlyToggle,
|
||||
serverOnlyLabel: computed(() => formatMessage(commonMessages.serverOnlyLabel)),
|
||||
hiddenFilterTypes: computed(() => (showServerOnlyToggle.value ? ['environment'] : [])),
|
||||
advancedFiltersCollapsed,
|
||||
displayMode: resultsDisplayMode,
|
||||
cycleDisplayMode: cycleSearchDisplayMode,
|
||||
maxResultsOptions: currentMaxResultsOptions,
|
||||
|
||||
@@ -64,6 +64,7 @@ pub enum FeatureFlag {
|
||||
I18nDebug,
|
||||
ShowInstancePlayTime,
|
||||
SkipNonEssentialWarnings,
|
||||
AdvancedFiltersCollapsed,
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
|
||||
@@ -71,83 +71,105 @@
|
||||
</template>
|
||||
<template v-else #default>
|
||||
<slot name="prefix" />
|
||||
<StyledInput
|
||||
v-if="filterType.searchable"
|
||||
:id="`search-${filterType.id}`"
|
||||
v-model="query"
|
||||
:icon="SearchIcon"
|
||||
type="text"
|
||||
:placeholder="formatMessage(messages.searchPlaceholder)"
|
||||
autocomplete="off"
|
||||
clearable
|
||||
size="small"
|
||||
input-class="!bg-button-bg"
|
||||
wrapper-class="mx-2 my-1 w-[calc(100%-1rem)]"
|
||||
/>
|
||||
|
||||
<ScrollablePanel :class="{ 'h-[16rem]': scrollable }" :disable-scrolling="!scrollable">
|
||||
<div :class="innerPanelClass ? innerPanelClass : ''" class="flex flex-col gap-1">
|
||||
<template v-if="groupedOptions">
|
||||
<SearchFilterGroup
|
||||
v-for="[groupName, options] in groupedOptions"
|
||||
:key="`${filterType.id}-group-${groupName}`"
|
||||
:group-name="groupName"
|
||||
:options="options"
|
||||
: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"
|
||||
<div
|
||||
v-if="filterType.display === 'toggle'"
|
||||
:class="innerPanelClass ? innerPanelClass : ''"
|
||||
class="flex flex-col gap-3"
|
||||
>
|
||||
<label
|
||||
v-for="option in filterType.options"
|
||||
:key="`${filterType.id}-toggle-${option.id}`"
|
||||
class="flex cursor-pointer items-center justify-between text-secondary gap-3 font-semibold"
|
||||
>
|
||||
<span class="text-sm">{{ option.formatted_name ?? option.id }}</span>
|
||||
<Toggle
|
||||
:model-value="isExcluded(option)"
|
||||
small
|
||||
class="shrink-0"
|
||||
@update:model-value="toggleNegativeFilter(option)"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<template v-if="filterType.display !== 'toggle'">
|
||||
<StyledInput
|
||||
v-if="filterType.searchable"
|
||||
:id="`search-${filterType.id}`"
|
||||
v-model="query"
|
||||
:icon="SearchIcon"
|
||||
type="text"
|
||||
:placeholder="formatMessage(messages.searchPlaceholder)"
|
||||
autocomplete="off"
|
||||
clearable
|
||||
size="small"
|
||||
input-class="!bg-button-bg"
|
||||
wrapper-class="mx-2 my-1 w-[calc(100%-1rem)]"
|
||||
/>
|
||||
<ScrollablePanel :class="{ 'h-[16rem]': scrollable }" :disable-scrolling="!scrollable">
|
||||
<div :class="innerPanelClass ? innerPanelClass : ''" class="flex flex-col gap-1">
|
||||
<template v-if="groupedOptions">
|
||||
<SearchFilterGroup
|
||||
v-for="[groupName, options] in groupedOptions"
|
||||
:key="`${filterType.id}-group-${groupName}`"
|
||||
:group-name="groupName"
|
||||
:options="options"
|
||||
: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">
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
</template>
|
||||
<div :class="innerPanelClass ? innerPanelClass : ''" class="empty:hidden">
|
||||
<Checkbox
|
||||
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 Accordion from '../base/Accordion.vue'
|
||||
import ButtonStyled from '../base/ButtonStyled.vue'
|
||||
import Toggle from '../base/Toggle.vue'
|
||||
import { Checkbox, ScrollablePanel, StyledInput } from '../index'
|
||||
import SearchFilterGroup from './SearchFilterGroup.vue'
|
||||
import SearchFilterOption from './SearchFilterOption.vue'
|
||||
|
||||
@@ -73,6 +73,7 @@ export interface BrowseManagerContext {
|
||||
showServerOnly?: ComputedRef<boolean>
|
||||
serverOnlyLabel?: ComputedRef<string>
|
||||
hiddenFilterTypes?: ComputedRef<string[]>
|
||||
advancedFiltersCollapsed?: Ref<boolean>
|
||||
onInstalled?: (projectId: string) => void
|
||||
|
||||
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 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() {
|
||||
if (ctx.filtersMenuOpen) {
|
||||
ctx.filtersMenuOpen.value = false
|
||||
@@ -49,6 +57,9 @@ function hasProvidedFilter(filterId: string): boolean {
|
||||
}
|
||||
|
||||
function getFilterOpenByDefault(filterId: string): boolean {
|
||||
if (filterId === 'advanced') {
|
||||
return !advancedFiltersCollapsed.value
|
||||
}
|
||||
if (hasProvidedFilter(filterId)) {
|
||||
return true
|
||||
}
|
||||
@@ -188,6 +199,8 @@ function getFilterOpenByDefault(filterId: string): boolean {
|
||||
:content-class="contentClass"
|
||||
:inner-panel-class="innerPanelClass"
|
||||
:open-by-default="getFilterOpenByDefault(filter.id)"
|
||||
@on-open="() => filter.id === 'advanced' && setAdvancedFiltersCollapsed(false)"
|
||||
@on-close="() => filter.id === 'advanced' && setAdvancedFiltersCollapsed(true)"
|
||||
>
|
||||
<template #header>
|
||||
<h3 :class="isApp ? 'text-base m-0' : 'm-0 text-lg font-semibold'">
|
||||
|
||||
@@ -3572,6 +3572,18 @@
|
||||
"search.filter.option.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": {
|
||||
"defaultMessage": "Environment"
|
||||
},
|
||||
|
||||
@@ -46,7 +46,7 @@ export type FilterType = {
|
||||
ordering?: number
|
||||
} & (
|
||||
| {
|
||||
display: 'all' | 'scrollable' | 'none'
|
||||
display: 'all' | 'scrollable' | 'none' | 'toggle'
|
||||
}
|
||||
| {
|
||||
display: 'expandable'
|
||||
@@ -112,6 +112,12 @@ export interface SortType {
|
||||
|
||||
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(
|
||||
projectTypes: Ref<ProjectType[]>,
|
||||
tags: Ref<Tags>,
|
||||
@@ -143,6 +149,34 @@ export function useSearch(
|
||||
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 categoryFilters: Record<string, FilterType> = {}
|
||||
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[] = [
|
||||
...Object.values(categoryFilters),
|
||||
{
|
||||
@@ -429,6 +472,26 @@ export function useSearch(
|
||||
options: [],
|
||||
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
|
||||
@@ -460,6 +523,9 @@ export function useSearch(
|
||||
console.error(`Filter type ${filterValue.type} not found`)
|
||||
continue
|
||||
}
|
||||
if (type.id === 'advanced') {
|
||||
continue
|
||||
}
|
||||
let option = type?.options.find((option) => option.id === filterValue.option)
|
||||
if (!option && type.allows_custom_options) {
|
||||
option = {
|
||||
@@ -550,6 +616,15 @@ export function useSearch(
|
||||
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 ')
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user