feat: add modpack "includes content" filter and update depends on

This commit is contained in:
tdgao
2026-07-14 11:51:26 -07:00
parent b574d32af8
commit 94f74b68c8
6 changed files with 405 additions and 104 deletions
@@ -84,6 +84,10 @@ const searchResultsCache = ref<Map<string, SearchHit>>(new Map())
const { labrinth } = injectModrinthClient()
function isAllowedProjectType(projectType: string): boolean {
return !props.projectTypes || props.projectTypes.includes(projectType as ProjectType)
}
const userProjectHits = ref<SearchHit[]>([])
const userProjectsFuse = ref<Fuse<SearchHit> | null>(null)
@@ -164,7 +168,7 @@ watch(
} else {
try {
const project = await labrinth.projects_v2.get(newId)
if (project) {
if (project && isAllowedProjectType(project.project_type)) {
hit = {
project_id: project.id,
title: project.title,
@@ -179,6 +183,9 @@ watch(
return
}
}
if (hit && !isAllowedProjectType(hit.project_type)) {
hit = null
}
selectedProject.value = hit
if (hit && !options.value.some((o) => o.value === hit!.project_id)) {
@@ -221,7 +228,11 @@ const search = async (query: string) => {
const uniqueHits: SearchHit[] = []
for (const hit of allHits) {
if (!seenIds.has(hit.project_id) && !excludeSet.has(hit.project_id)) {
if (
isAllowedProjectType(hit.project_type) &&
!seenIds.has(hit.project_id) &&
!excludeSet.has(hit.project_id)
) {
seenIds.add(hit.project_id)
uniqueHits.push(hit)
searchResultsCache.value.set(hit.project_id, hit)
@@ -0,0 +1,310 @@
<template>
<div v-if="isModpack" :class="innerPanelClass" class="flex flex-col gap-3">
<ProjectCombobox
:model-value="pendingProjectId"
:project-types="selectableProjectTypes"
:exclude-project-ids="dependencyProjectIds"
:search-placeholder="formatMessage(messages.searchContentPlaceholder)"
@update:model-value="addIncludedProject"
/>
<div v-if="dependencyProjectIds.length > 0" class="flex flex-col gap-1">
<div
v-for="projectId in dependencyProjectIds"
:key="projectId"
class="group flex min-w-0 items-center gap-2 rounded-xl px-1 py-1 text-primary"
>
<img
v-if="dependentProjectMap.get(projectId)?.icon_url"
:src="dependentProjectMap.get(projectId)?.icon_url ?? undefined"
:alt="dependentProjectMap.get(projectId)?.title ?? projectId"
class="size-6 shrink-0 rounded-md object-cover"
/>
<PackageIcon v-else class="size-8 shrink-0 text-secondary" />
<span class="min-w-0 flex-1 truncate font-medium text-contrast">
{{ dependentProjectMap.get(projectId)?.title ?? projectId }}
</span>
<button
type="button"
class="shrink-0 cursor-pointer border-0 bg-transparent p-0 text-sm transition-opacity"
:class="
excludedProjectIds.has(projectId)
? 'text-secondary opacity-100'
: 'text-secondary opacity-0 underline decoration-1 underline-offset-2 group-hover:opacity-100 group-focus-within:opacity-100 hover:text-contrast'
"
@click="toggleProjectExcluded(projectId)"
>
{{
excludedProjectIds.has(projectId)
? formatMessage(messages.excluded)
: formatMessage(messages.exclude)
}}
</button>
<button
type="button"
class="flex shrink-0 cursor-pointer items-center border-0 bg-transparent p-0 text-secondary hover:text-contrast"
:aria-label="
formatMessage(messages.removeIncludedProject, {
project: dependentProjectMap.get(projectId)?.title ?? projectId,
})
"
@click="removeIncludedProject(projectId)"
>
<XIcon class="size-5" />
</button>
</div>
</div>
</div>
<div v-else :class="innerPanelClass" class="flex flex-col gap-3">
<ProjectCombobox
v-show="!selectedProjectId || refreshing"
ref="projectCombobox"
:model-value="selectedProjectId"
:project-types="selectableProjectTypes"
:search-placeholder="formatMessage(messages.searchProjectPlaceholder)"
@update:model-value="setSelectedProjectId"
/>
<template v-if="selectedProjectId && !refreshing">
<div class="flex items-center justify-between gap-3 px-2 text-secondary">
<span>{{ formatMessage(messages.dependentCount, { count: resultCount ?? 0 }) }}</span>
<button
class="border-none bg-transparent p-0 text-secondary cursor-pointer hover:text-contrast"
@click="setSelectedProjectId(undefined)"
>
{{ formatMessage(messages.clearFilter) }}
</button>
</div>
<div class="flex items-center gap-3 rounded-2xl bg-surface-1 p-3">
<img
v-if="selectedProject?.icon_url"
:src="selectedProject.icon_url"
:alt="selectedProject.title"
class="size-14 shrink-0 rounded-xl object-cover"
/>
<PackageIcon v-else class="size-14 shrink-0 text-secondary" />
<div class="min-w-0 flex-1">
<div class="truncate text-base font-bold text-contrast">
{{ selectedProject?.title ?? selectedProjectId }}
</div>
<MultiSelect
:model-value="dependencyTypes"
:options="dependencyTypeOptions"
:clearable="false"
fit-content
show-chevron
trigger-class="!rounded-none !bg-transparent !p-0"
checkbox-position="right"
:dropdown-min-width="220"
@update:model-value="setDependencyTypes"
>
<template #input-content="{ isOpen }">
<span class="flex items-center gap-1 text-sm text-secondary">
{{ dependencyTypeLabel }}
<DropdownIcon
class="size-4 transition-transform"
:class="{ 'rotate-180': isOpen }"
/>
</span>
</template>
</MultiSelect>
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { DropdownIcon, PackageIcon, XIcon } from '@modrinth/assets'
import { useQuery } from '@tanstack/vue-query'
import { computed, ref, watch } from 'vue'
import { defineMessages, useVIntl } from '../../composables/i18n'
import { injectModrinthClient } from '../../providers'
import type { FilterValue } from '../../utils/search'
import MultiSelect, { type MultiSelectOption } from '../base/MultiSelect.vue'
import ProjectCombobox, {
type ProjectType as ProjectComboboxProjectType,
type SearchHit,
} from '../project/ProjectCombobox.vue'
const FILTER_TYPE_ID = 'compatible_dependency_project_ids'
const { formatMessage } = useVIntl()
const { labrinth } = injectModrinthClient()
const selectedFilters = defineModel<FilterValue[]>('selectedFilters', { required: true })
const props = defineProps<{
projectType: string
innerPanelClass?: string
resultCount?: number
refreshing?: boolean
}>()
const selectableProjectTypes: ProjectComboboxProjectType[] = [
'mod',
'resourcepack',
'shader',
'datapack',
'plugin',
]
const isModpack = computed(() => props.projectType === 'modpack')
const dependencyProjectIds = computed(() =>
selectedFilters.value
.filter((filter) => filter.type === FILTER_TYPE_ID)
.map((filter) => filter.option),
)
const pendingProjectId = ref<string>()
const excludedProjectIds = ref(new Set<string>())
watch(dependencyProjectIds, (projectIds) => {
const selectedProjectIds = new Set(projectIds)
excludedProjectIds.value = new Set(
[...excludedProjectIds.value].filter((projectId) => selectedProjectIds.has(projectId)),
)
})
const { data: dependentProjects } = useQuery({
queryKey: computed(() => [
'search-depends-on-filter',
'dependent-projects',
dependencyProjectIds.value,
]),
queryFn: () => labrinth.projects_v2.getMultiple(dependencyProjectIds.value),
enabled: computed(() => isModpack.value && dependencyProjectIds.value.length > 0),
placeholderData: [],
refetchOnWindowFocus: false,
})
const dependentProjectMap = computed(
() => new Map(dependentProjects.value?.map((project) => [project.id, project]) ?? []),
)
const projectCombobox = ref<{ selectedProject: SearchHit | null } | null>(null)
const selectedProject = computed(() => projectCombobox.value?.selectedProject ?? null)
const selectedProjectId = computed(
() => selectedFilters.value.find((filter) => filter.type === FILTER_TYPE_ID)?.option,
)
type DependencyType = 'required' | 'optional' | 'embedded'
const dependencyTypes = ref<DependencyType[]>(['required'])
const dependencyTypeOptions = computed<MultiSelectOption<DependencyType>[]>(() => [
{ value: 'required', label: formatMessage(messages.required) },
{ value: 'optional', label: formatMessage(messages.optional) },
{ value: 'embedded', label: formatMessage(messages.embedded) },
])
const dependencyTypeLabel = computed(() => {
const selectedTypes = new Set(dependencyTypes.value)
if (selectedTypes.size === dependencyTypeOptions.value.length) {
return formatMessage(messages.anyDependencyType)
}
if (selectedTypes.has('required') && selectedTypes.has('optional')) {
return formatMessage(messages.requiredOrOptional)
}
if (selectedTypes.has('required') && selectedTypes.has('embedded')) {
return formatMessage(messages.requiredOrEmbedded)
}
if (selectedTypes.has('optional') && selectedTypes.has('embedded')) {
return formatMessage(messages.optionalOrEmbedded)
}
return dependencyTypeOptions.value.find((option) => selectedTypes.has(option.value))?.label ?? ''
})
function setSelectedProjectId(projectId: string | undefined) {
const otherFilters = selectedFilters.value.filter((filter) => filter.type !== FILTER_TYPE_ID)
selectedFilters.value = projectId
? [...otherFilters, { type: FILTER_TYPE_ID, option: projectId }]
: otherFilters
dependencyTypes.value = ['required']
}
function addIncludedProject(projectId: string | undefined) {
if (projectId && !dependencyProjectIds.value.includes(projectId)) {
selectedFilters.value = [...selectedFilters.value, { type: FILTER_TYPE_ID, option: projectId }]
}
pendingProjectId.value = undefined
}
function removeIncludedProject(projectId: string) {
selectedFilters.value = selectedFilters.value.filter(
(filter) => filter.type !== FILTER_TYPE_ID || filter.option !== projectId,
)
const nextExcludedProjectIds = new Set(excludedProjectIds.value)
nextExcludedProjectIds.delete(projectId)
excludedProjectIds.value = nextExcludedProjectIds
}
function toggleProjectExcluded(projectId: string) {
const nextExcludedProjectIds = new Set(excludedProjectIds.value)
if (nextExcludedProjectIds.has(projectId)) {
nextExcludedProjectIds.delete(projectId)
} else {
nextExcludedProjectIds.add(projectId)
}
excludedProjectIds.value = nextExcludedProjectIds
}
function setDependencyTypes(types: DependencyType[]) {
if (types.length > 0) {
dependencyTypes.value = types
}
}
const messages = defineMessages({
clearFilter: {
id: 'search.filter.clear',
defaultMessage: 'Clear',
},
dependentCount: {
id: 'search.filter.dependent_count',
defaultMessage: '{count, plural, one {# dependent} other {# dependents}}',
},
searchContentPlaceholder: {
id: 'search.filter.included_content.search_placeholder',
defaultMessage: 'Search content...',
},
searchProjectPlaceholder: {
id: 'search.filter.dependent_project.search_placeholder',
defaultMessage: 'Search for a project...',
},
exclude: {
id: 'search.filter.included_content.exclude',
defaultMessage: 'Exclude',
},
excluded: {
id: 'search.filter.included_content.excluded',
defaultMessage: 'Excluded',
},
removeIncludedProject: {
id: 'search.filter.included_content.remove_project',
defaultMessage: 'Remove {project}',
},
required: {
id: 'search.filter.dependency_type.required',
defaultMessage: 'Required',
},
optional: {
id: 'search.filter.dependency_type.optional',
defaultMessage: 'Optional',
},
embedded: {
id: 'search.filter.dependency_type.embedded',
defaultMessage: 'Embedded',
},
anyDependencyType: {
id: 'search.filter.dependency_type.any',
defaultMessage: 'Any dependency type',
},
requiredOrOptional: {
id: 'search.filter.dependency_type.required_or_optional',
defaultMessage: 'Required or optional',
},
requiredOrEmbedded: {
id: 'search.filter.dependency_type.required_or_embedded',
defaultMessage: 'Required or embedded',
},
optionalOrEmbedded: {
id: 'search.filter.dependency_type.optional_or_embedded',
defaultMessage: 'Optional or embedded',
},
})
</script>
@@ -71,48 +71,14 @@
</template>
<template v-else #default>
<slot name="prefix" />
<div
v-if="filterType.display === 'project'"
:class="innerPanelClass ? innerPanelClass : ''"
class="flex flex-col gap-3"
>
<ProjectCombobox
v-show="!selectedProjectId || refreshing"
ref="projectCombobox"
search-placeholder="Search for a project..."
:model-value="selectedProjectId"
:project-types="selectableProjectTypes"
@update:model-value="setSelectedProjectId"
/>
<template v-if="selectedProjectId && !refreshing">
<div class="flex items-center justify-between gap-3 px-2 text-secondary">
<span>{{ formatMessage(messages.dependentCount, { count: resultCount ?? 0 }) }}</span>
<button
class="border-none bg-transparent p-0 text-secondary cursor-pointer hover:text-contrast"
@click="setSelectedProjectId(undefined)"
>
{{ formatMessage(messages.clearFilter) }}
</button>
</div>
<div class="flex items-center gap-3 rounded-2xl bg-surface-1 p-3">
<img
v-if="selectedProject?.icon_url"
:src="selectedProject.icon_url"
:alt="selectedProject.title"
class="size-14 shrink-0 rounded-xl object-cover"
/>
<PackageIcon v-else class="size-14 shrink-0 text-secondary" />
<div class="min-w-0">
<div class="truncate text-base font-bold text-contrast">
{{ selectedProject?.title ?? selectedProjectId }}
</div>
<div v-if="selectedProject?.author" class="truncate text-sm text-secondary">
{{ selectedProject.author }}
</div>
</div>
</div>
</template>
</div>
<SearchDependsOnFilter
v-if="filterType.display === 'depends-on-project'"
v-model:selected-filters="selectedFilters"
:project-type="projectType"
:inner-panel-class="innerPanelClass"
:result-count="resultCount"
:refreshing="refreshing"
/>
<div
v-if="filterType.display === 'toggle'"
:class="innerPanelClass ? innerPanelClass : ''"
@@ -132,7 +98,9 @@
/>
</label>
</div>
<template v-if="filterType.display !== 'toggle' && filterType.display !== 'project'">
<template
v-if="filterType.display !== 'toggle' && filterType.display !== 'depends-on-project'"
>
<StyledInput
v-if="filterType.searchable"
:id="`search-${filterType.id}`"
@@ -248,14 +216,7 @@
</template>
<script setup lang="ts">
import {
BanIcon,
DropdownIcon,
LockOpenIcon,
PackageIcon,
SearchIcon,
UpdatedIcon,
} from '@modrinth/assets'
import { BanIcon, DropdownIcon, LockOpenIcon, SearchIcon, UpdatedIcon } from '@modrinth/assets'
import { computed, ref } from 'vue'
import { defineMessages, useVIntl } from '../../composables/i18n'
@@ -264,10 +225,7 @@ 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 ProjectCombobox, {
type ProjectType as ProjectComboboxProjectType,
type SearchHit,
} from '../project/ProjectCombobox.vue'
import SearchDependsOnFilter from './SearchDependsOnFilter.vue'
import SearchFilterGroup from './SearchFilterGroup.vue'
import SearchFilterOption from './SearchFilterOption.vue'
@@ -282,6 +240,7 @@ const overriddenProvidedFilterTypes = defineModel<string[]>('overriddenProvidedF
const props = defineProps<{
filterType: FilterType
projectType: string
buttonClass?: string
contentClass?: string
innerPanelClass?: string
@@ -299,26 +258,6 @@ const query = ref('')
const showMore = ref(false)
const accordion = ref<InstanceType<typeof Accordion> | null>()
const projectCombobox = ref<{ selectedProject: SearchHit | null } | null>(null)
const selectableProjectTypes: ProjectComboboxProjectType[] = [
'mod',
'modpack',
'resourcepack',
'shader',
'datapack',
'plugin',
]
const selectedProject = computed(() => projectCombobox.value?.selectedProject ?? null)
const selectedProjectId = computed(
() => selectedFilters.value.find((filter) => filter.type === props.filterType.id)?.option,
)
function setSelectedProjectId(projectId: string | undefined) {
const otherFilters = selectedFilters.value.filter((filter) => filter.type !== props.filterType.id)
selectedFilters.value = projectId
? [...otherFilters, { type: props.filterType.id, option: projectId }]
: otherFilters
}
const selectedFilterOptions = computed(() =>
props.filterType.options.filter((option) =>
@@ -472,14 +411,6 @@ function clearFilters() {
}
const messages = defineMessages({
clearFilter: {
id: 'search.filter.clear',
defaultMessage: 'Clear',
},
dependentCount: {
id: 'search.filter.dependent_count',
defaultMessage: '{count, plural, one {# dependent} other {# dependents}}',
},
searchPlaceholder: {
id: 'search.filter.option.search.placeholder',
defaultMessage: 'Search...',
@@ -63,6 +63,9 @@ function getFilterOpenByDefault(filterId: string): boolean {
if (hasProvidedFilter(filterId)) {
return true
}
if (filterId === 'compatible_dependency_project_ids') {
return ctx.projectType.value === 'modpack'
}
if (ctx.isServerType.value) {
return ![
'server_category_minecraft_server_meta',
@@ -170,6 +173,7 @@ function getFilterOpenByDefault(filterId: string): boolean {
v-model:toggled-groups="ctx.serverToggledGroups.value"
:provided-filters="[]"
:filter-type="filterType"
:project-type="ctx.projectType.value"
:class="filterClass"
:button-class="buttonClass"
:content-class="contentClass"
@@ -194,6 +198,7 @@ function getFilterOpenByDefault(filterId: string): boolean {
v-model:overridden-provided-filter-types="ctx.overriddenProvidedFilterTypes.value"
:provided-filters="ctx.providedFilters?.value ?? []"
:filter-type="filter"
:project-type="ctx.projectType.value"
:result-count="ctx.totalHits.value"
:refreshing="ctx.refreshing.value"
:class="filterClass"
+39
View File
@@ -3551,12 +3551,48 @@
"search.filter.clear": {
"defaultMessage": "Clear"
},
"search.filter.dependency_type.any": {
"defaultMessage": "Any dependency type"
},
"search.filter.dependency_type.embedded": {
"defaultMessage": "Embedded"
},
"search.filter.dependency_type.optional": {
"defaultMessage": "Optional"
},
"search.filter.dependency_type.optional_or_embedded": {
"defaultMessage": "Optional or embedded"
},
"search.filter.dependency_type.required": {
"defaultMessage": "Required"
},
"search.filter.dependency_type.required_or_embedded": {
"defaultMessage": "Required or embedded"
},
"search.filter.dependency_type.required_or_optional": {
"defaultMessage": "Required or optional"
},
"search.filter.dependent_count": {
"defaultMessage": "{count, plural, one {# dependent} other {# dependents}}"
},
"search.filter.dependent_project": {
"defaultMessage": "Depends on: {project}"
},
"search.filter.dependent_project.search_placeholder": {
"defaultMessage": "Search for a project..."
},
"search.filter.included_content.exclude": {
"defaultMessage": "Exclude"
},
"search.filter.included_content.excluded": {
"defaultMessage": "Excluded"
},
"search.filter.included_content.remove_project": {
"defaultMessage": "Remove {project}"
},
"search.filter.included_content.search_placeholder": {
"defaultMessage": "Search content..."
},
"search.filter.locked.default": {
"defaultMessage": "Filter locked"
},
@@ -3617,6 +3653,9 @@
"search.filter_type.game_version.all_versions": {
"defaultMessage": "Show all versions"
},
"search.filter_type.included_content": {
"defaultMessage": "Included content"
},
"search.filter_type.license": {
"defaultMessage": "License"
},
+24 -19
View File
@@ -47,7 +47,7 @@ export type FilterType = {
ordering?: number
} & (
| {
display: 'all' | 'scrollable' | 'none' | 'project' | 'toggle'
display: 'all' | 'scrollable' | 'none' | 'depends-on-project' | 'toggle'
}
| {
display: 'expandable'
@@ -146,6 +146,14 @@ export function useSearch(
const overriddenProvidedFilterTypes = ref<string[]>([])
const { formatMessage, locale } = useVIntl()
const dependsOnFilterName = defineMessage({
id: 'search.filter_type.compatible_dependency_project_ids',
defaultMessage: 'Depends on',
})
const includedContentFilterName = defineMessage({
id: 'search.filter_type.included_content',
defaultMessage: 'Included content',
})
const formatCategoryName = (categoryName: string) => {
return formatCategory(formatMessage, categoryName)
}
@@ -433,6 +441,21 @@ export function useSearch(
}
}),
},
{
id: 'compatible_dependency_project_ids',
formatted_name: formatMessage(
projectTypes.value.includes('modpack') ? includedContentFilterName : dependsOnFilterName,
),
supported_project_types: ALL_PROJECT_TYPES,
query_param: 'dep',
supports_negative_filter: false,
display: 'depends-on-project',
searchable: false,
options: [],
allows_custom_options: 'and',
custom_option_field: 'compatible_dependency_project_ids',
ordering: projectTypes.value.includes('modpack') ? undefined : -999,
},
{
id: 'license',
formatted_name: formatMessage(
@@ -473,24 +496,6 @@ export function useSearch(
options: [],
allows_custom_options: 'and',
},
{
id: 'compatible_dependency_project_ids',
formatted_name: formatMessage(
defineMessage({
id: 'search.filter_type.compatible_dependency_project_ids',
defaultMessage: 'Depends on',
}),
),
supported_project_types: ALL_PROJECT_TYPES,
query_param: 'dep',
supports_negative_filter: false,
display: 'project',
searchable: false,
options: [],
allows_custom_options: 'and',
custom_option_field: 'compatible_dependency_project_ids',
ordering: -999,
},
{
id: 'advanced',
formatted_name: formatMessage(