mirror of
https://github.com/modrinth/code.git
synced 2026-08-25 00:55:25 +00:00
feat: depends on search filter (#6713)
* feat: add norisk client source support (#6707)
Signed-off-by: Robin <me@orb1n.dev>
* changelog
* feat: implement depends on search filter
* feat: add modpack "includes content" filter and update depends on
* feat: style pass
* feat: show included modpack projects filter tag better
* pnpm prepr
* feat: polish depends on filter
* fix: clear button, page scroll, and metadata loading state
* feat: hook up excludes modpack
* fix: combobox closing race
* Revert "fix: combobox closing race"
This reverts commit e51529ca65.
* fix scrolling after applying depends on
* feedback
* pnpm prepr
* slightly make buttons larger for touch devices
* feat: add view dependents/view modpacks in project overflow menu
* pnpm prepr
* fix: divider
* add icon
* fix: project search combobox background in app
---------
Signed-off-by: Robin <me@orb1n.dev>
Co-authored-by: Robin <robin.murer11@gmail.com>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
co-authored by
Robin
Prospector
parent
5d54cfa0dc
commit
5181370293
@@ -3494,6 +3494,15 @@
|
||||
"project.actions.servers-promo.title": {
|
||||
"message": "Create a server"
|
||||
},
|
||||
"project.actions.view-dependents": {
|
||||
"message": "View dependents"
|
||||
},
|
||||
"project.actions.view-modpacks": {
|
||||
"message": "View modpacks"
|
||||
},
|
||||
"project.actions.view-project-type-dependents": {
|
||||
"message": "View {projectType} dependents"
|
||||
},
|
||||
"project.ai-image-warning-modal.body.1": {
|
||||
"message": "Using AI-generated images to represent your project is not allowed. Attempting to work around detection may lead to your Modrinth account being suspended."
|
||||
},
|
||||
|
||||
@@ -564,10 +564,12 @@ import {
|
||||
HeartIcon,
|
||||
LeftArrowIcon,
|
||||
MoreVerticalIcon,
|
||||
PackageSearchIcon,
|
||||
PlayIcon,
|
||||
ReportIcon,
|
||||
ScaleIcon,
|
||||
ScanEyeIcon,
|
||||
SearchIcon,
|
||||
ServerPlusIcon,
|
||||
SettingsIcon,
|
||||
XIcon,
|
||||
@@ -582,6 +584,8 @@ import {
|
||||
ButtonLink,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
formatDependencyProjectFilterOption,
|
||||
formatProjectTypeSentence,
|
||||
getActiveDisclosures,
|
||||
IconButton,
|
||||
injectModrinthClient,
|
||||
@@ -846,6 +850,18 @@ const messages = defineMessages({
|
||||
id: 'project.actions.review-project',
|
||||
defaultMessage: 'Review project',
|
||||
},
|
||||
viewDependents: {
|
||||
id: 'project.actions.view-dependents',
|
||||
defaultMessage: 'View dependents',
|
||||
},
|
||||
viewProjectTypeDependents: {
|
||||
id: 'project.actions.view-project-type-dependents',
|
||||
defaultMessage: 'View {projectType} dependents',
|
||||
},
|
||||
viewModpacks: {
|
||||
id: 'project.actions.view-modpacks',
|
||||
defaultMessage: 'View modpacks',
|
||||
},
|
||||
rescanModpack: {
|
||||
id: 'project.actions.rescan-modpack',
|
||||
defaultMessage: 'Rescan modpack',
|
||||
@@ -1904,6 +1920,30 @@ async function checkModpackArchives() {
|
||||
|
||||
const projectHeaderMoreActions = computed(() => {
|
||||
const isStaff = !!(auth.value.user && tags.value.staffRoles.includes(auth.value.user.role))
|
||||
const projectId = project.value?.id
|
||||
const dependentSearchTypes = getDependentSearchTypes()
|
||||
const dependentSearchActions = dependentSearchTypes
|
||||
.filter((projectType) => projectType !== 'modpack')
|
||||
.map((projectType) => ({
|
||||
id: `view-${projectType}-dependents`,
|
||||
label: formatMessage(
|
||||
dependentSearchTypes.length === 1
|
||||
? messages.viewDependents
|
||||
: messages.viewProjectTypeDependents,
|
||||
{
|
||||
projectType: formatProjectTypeSentence(formatMessage, projectType),
|
||||
},
|
||||
),
|
||||
icon: SearchIcon,
|
||||
type: 'link',
|
||||
to: {
|
||||
path: `/discover/${projectType}s`,
|
||||
query: {
|
||||
dep: formatDependencyProjectFilterOption(projectId, ['required']),
|
||||
},
|
||||
},
|
||||
}))
|
||||
const isPluginOnly = dependentSearchTypes.length === 1 && dependentSearchTypes[0] === 'plugin'
|
||||
|
||||
return [
|
||||
{
|
||||
@@ -1914,7 +1954,21 @@ const projectHeaderMoreActions = computed(() => {
|
||||
to: `${projectPath.value}/settings/analytics`,
|
||||
shown: !!auth.value.user && !!currentMember.value,
|
||||
},
|
||||
{ type: 'divider', shown: !!auth.value.user && !!currentMember.value },
|
||||
...dependentSearchActions,
|
||||
{
|
||||
id: 'view-modpacks',
|
||||
label: formatMessage(messages.viewModpacks),
|
||||
icon: PackageSearchIcon,
|
||||
type: 'link',
|
||||
to: {
|
||||
path: '/discover/modpacks',
|
||||
query: {
|
||||
dep: formatDependencyProjectFilterOption(projectId, ['required']),
|
||||
},
|
||||
},
|
||||
shown: !isPluginOnly && project.value?.actualProjectType !== 'modpack',
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
id: 'moderation-checklist',
|
||||
label: formatMessage(messages.reviewProject),
|
||||
@@ -1977,6 +2031,29 @@ const projectHeaderMoreActions = computed(() => {
|
||||
]
|
||||
})
|
||||
|
||||
function getDependentSearchTypes() {
|
||||
if (!project.value) return []
|
||||
|
||||
if (project.value.actualProjectType !== 'mod') {
|
||||
return [isServerProject.value ? 'server' : project.value.actualProjectType]
|
||||
}
|
||||
|
||||
const loaders = project.value.loaders ?? []
|
||||
const projectTypes = []
|
||||
|
||||
if (loaders.some((loader) => tags.value.loaderData.modLoaders.includes(loader))) {
|
||||
projectTypes.push('mod')
|
||||
}
|
||||
if (loaders.some((loader) => tags.value.loaderData.allPluginLoaders.includes(loader))) {
|
||||
projectTypes.push('plugin')
|
||||
}
|
||||
if (loaders.some((loader) => tags.value.loaderData.dataPackLoaders.includes(loader))) {
|
||||
projectTypes.push('datapack')
|
||||
}
|
||||
|
||||
return projectTypes.length > 0 ? projectTypes : ['mod']
|
||||
}
|
||||
|
||||
const createCanonicalUrl = () =>
|
||||
project.value ? `https://modrinth.com/project/${project.value.id}` : undefined
|
||||
|
||||
|
||||
@@ -213,6 +213,7 @@ import _PackageIcon from './icons/package.svg?component'
|
||||
import _PackageClosedIcon from './icons/package-closed.svg?component'
|
||||
import _PackageOpenIcon from './icons/package-open.svg?component'
|
||||
import _PackagePlusIcon from './icons/package-plus.svg?component'
|
||||
import _PackageSearchIcon from './icons/package-search.svg?component'
|
||||
import _PageRoundIcon from './icons/page-round.svg?component'
|
||||
import _PaintbrushIcon from './icons/paintbrush.svg?component'
|
||||
import _PaletteIcon from './icons/palette.svg?component'
|
||||
@@ -656,6 +657,7 @@ export const PackageIcon = _PackageIcon
|
||||
export const PackageClosedIcon = _PackageClosedIcon
|
||||
export const PackageOpenIcon = _PackageOpenIcon
|
||||
export const PackagePlusIcon = _PackagePlusIcon
|
||||
export const PackageSearchIcon = _PackageSearchIcon
|
||||
export const PageRoundIcon = _PageRoundIcon
|
||||
export const PaintbrushIcon = _PaintbrushIcon
|
||||
export const PaletteIcon = _PaletteIcon
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<!-- @license lucide-static v0.562.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-package-search"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M21 10V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l2-1.14" />
|
||||
<path d="m7.5 4.27 9 5.15" />
|
||||
<polyline points="3.29 7 12 12 20.71 7" />
|
||||
<line x1="12" x2="12" y1="22" y2="12" />
|
||||
<circle cx="18.5" cy="15.5" r="2.5" />
|
||||
<path d="M20.27 17.27 22 19" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 612 B |
@@ -1,10 +1,14 @@
|
||||
<template>
|
||||
<div ref="containerRef" class="relative inline-block w-full">
|
||||
<!-- Searchable mode: input trigger -->
|
||||
<div v-if="searchable" class="relative w-full rounded-xl bg-surface-4">
|
||||
<div
|
||||
v-if="searchable"
|
||||
class="relative w-full rounded-xl"
|
||||
:class="{ 'bg-surface-4': searchInputVariant === 'surface' }"
|
||||
>
|
||||
<!--
|
||||
Selection mirror: horizontal padding must match StyledInput (filled + left icon uses `pl-10`,
|
||||
else `pl-3`) and `searchableInputClass` when the chevron is shown (`!pr-9`), or the overlay
|
||||
else `pl-3`) and `resolvedSearchInputClass` when the chevron is shown (`!pr-9`), or the overlay
|
||||
text will not line up with the transparent input text / caret.
|
||||
-->
|
||||
<div
|
||||
@@ -24,6 +28,7 @@
|
||||
:name="searchName"
|
||||
:placeholder="searchPlaceholder || placeholder"
|
||||
:disabled="disabled"
|
||||
:clearable="clearable"
|
||||
:autocomplete="searchAutocomplete"
|
||||
:autocorrect="searchAutocorrect"
|
||||
:autocapitalize="searchAutocapitalize"
|
||||
@@ -31,13 +36,14 @@
|
||||
:inputmode="searchInputmode"
|
||||
:input-attrs="searchInputAttrs"
|
||||
wrapper-class="w-full !bg-transparent"
|
||||
:input-class="searchableInputClass"
|
||||
:input-class="resolvedSearchInputClass"
|
||||
class="relative z-[1]"
|
||||
@input="handleSearchInput"
|
||||
@keydown="handleSearchKeydown"
|
||||
@focusin="handleSearchFocus"
|
||||
@focusout="handleSearchFocusout"
|
||||
@click="handleSearchClick"
|
||||
@clear="handleSearchClear"
|
||||
>
|
||||
<template v-if="showChevron" #right>
|
||||
<ChevronLeftIcon
|
||||
@@ -234,6 +240,8 @@ export interface ComboboxOption<T> {
|
||||
searchTerms?: string[]
|
||||
}
|
||||
|
||||
export type ComboboxSearchInputVariant = 'surface' | 'button'
|
||||
|
||||
type OverlayScrollbarsInstance = NonNullable<ReturnType<typeof OverlayScrollbars>>
|
||||
type ViewportRect = {
|
||||
width: number
|
||||
@@ -274,6 +282,7 @@ const props = withDefaults(
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
searchable?: boolean
|
||||
clearable?: boolean
|
||||
searchPlaceholder?: string
|
||||
listbox?: boolean
|
||||
showChevron?: boolean
|
||||
@@ -311,11 +320,13 @@ const props = withDefaults(
|
||||
searchAutocapitalize?: 'none' | 'off' | 'sentences' | 'words' | 'characters'
|
||||
searchSpellcheck?: boolean
|
||||
searchInputAttrs?: Record<string, string | number | boolean | undefined>
|
||||
searchInputVariant?: ComboboxSearchInputVariant
|
||||
}>(),
|
||||
{
|
||||
placeholder: 'Select an option',
|
||||
disabled: false,
|
||||
searchable: false,
|
||||
clearable: false,
|
||||
searchPlaceholder: 'Search...',
|
||||
listbox: true,
|
||||
showChevron: true,
|
||||
@@ -327,6 +338,7 @@ const props = withDefaults(
|
||||
selectSearchTextOnFocus: false,
|
||||
showSearchIcon: false,
|
||||
searchType: 'text',
|
||||
searchInputVariant: 'surface',
|
||||
triggerType: 'base',
|
||||
triggerSize: 'md',
|
||||
triggerInteraction: 'surface',
|
||||
@@ -397,8 +409,8 @@ const searchSelectionOverlayVisible = computed(() => {
|
||||
return true
|
||||
})
|
||||
|
||||
const searchableInputClass = computed(() => {
|
||||
const parts = ['!bg-transparent']
|
||||
const resolvedSearchInputClass = computed(() => {
|
||||
const parts = [props.searchInputVariant === 'button' ? '!bg-button-bg' : '!bg-transparent']
|
||||
if (props.showChevron) parts.push('!pr-9')
|
||||
if (searchSelectionOverlayVisible.value) {
|
||||
parts.push('!text-transparent [caret-color:var(--color-text-primary)] selection:bg-transparent')
|
||||
@@ -841,6 +853,12 @@ function handleSearchInput() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearchClear() {
|
||||
userHasTyped.value = true
|
||||
emit('searchInput', searchQuery.value)
|
||||
closeDropdown()
|
||||
}
|
||||
|
||||
function handleSearchFocus(event: FocusEvent) {
|
||||
const target = event.target
|
||||
if (props.selectSearchTextOnFocus && target instanceof HTMLInputElement) {
|
||||
|
||||
@@ -38,7 +38,7 @@ export { default as Collapsible } from './Collapsible.vue'
|
||||
export type { CollapsibleAdmonitionItem } from './CollapsibleAdmonition.vue'
|
||||
export { default as CollapsibleAdmonition } from './CollapsibleAdmonition.vue'
|
||||
export { default as CollapsibleRegion } from './CollapsibleRegion.vue'
|
||||
export type { ComboboxOption } from './Combobox.vue'
|
||||
export type { ComboboxOption, ComboboxSearchInputVariant } from './Combobox.vue'
|
||||
export { default as Combobox } from './Combobox.vue'
|
||||
export { default as CopyCode } from './CopyCode.vue'
|
||||
export { default as DatePicker } from './DatePicker.vue'
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
:no-options-message="searchLoading ? loadingMessage : noResultsMessage"
|
||||
:disable-search-filter="true"
|
||||
:disabled="disabled"
|
||||
:clearable="clearable"
|
||||
:search-input-variant="searchInputVariant"
|
||||
:sync-with-selection="syncWithSelection"
|
||||
:select-search-text-on-focus="selectSearchTextOnFocus"
|
||||
show-icon-in-selected
|
||||
@search-input="(query) => handleSearch(query)"
|
||||
/>
|
||||
@@ -20,7 +24,7 @@ import Fuse from 'fuse.js'
|
||||
import { defineAsyncComponent, h, markRaw, ref, watch } from 'vue'
|
||||
|
||||
import { injectModrinthClient, injectNotificationManager } from '../../providers'
|
||||
import type { ComboboxOption } from '../base/Combobox.vue'
|
||||
import type { ComboboxOption, ComboboxSearchInputVariant } from '../base/Combobox.vue'
|
||||
import Combobox from '../base/Combobox.vue'
|
||||
|
||||
export type ProjectType =
|
||||
@@ -32,12 +36,13 @@ export type ProjectType =
|
||||
| 'plugin'
|
||||
| 'server'
|
||||
|
||||
interface SearchHit {
|
||||
export interface SearchHit {
|
||||
project_id: string
|
||||
title: string
|
||||
icon_url?: string
|
||||
project_type: string
|
||||
slug: string
|
||||
author?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -54,6 +59,14 @@ const props = withDefaults(
|
||||
noResultsMessage?: string
|
||||
/** Whether the combobox is disabled */
|
||||
disabled?: boolean
|
||||
/** Whether to show a button for clearing the search input */
|
||||
clearable?: boolean
|
||||
/** Visual treatment for the search input */
|
||||
searchInputVariant?: ComboboxSearchInputVariant
|
||||
/** Keep the selected project's label in the search input */
|
||||
syncWithSelection?: boolean
|
||||
/** Select all search text when the input receives focus */
|
||||
selectSearchTextOnFocus?: boolean
|
||||
/** Maximum number of results to show */
|
||||
limit?: number
|
||||
/** Project IDs to exclude from results */
|
||||
@@ -69,6 +82,8 @@ const props = withDefaults(
|
||||
loadingMessage: 'Loading...',
|
||||
noResultsMessage: 'No results found',
|
||||
disabled: false,
|
||||
searchInputVariant: 'surface',
|
||||
syncWithSelection: true,
|
||||
limit: 20,
|
||||
},
|
||||
)
|
||||
@@ -83,6 +98,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)
|
||||
|
||||
@@ -163,7 +182,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,
|
||||
@@ -178,6 +197,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)) {
|
||||
@@ -220,7 +242,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,401 @@
|
||||
<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)"
|
||||
:show-chevron="false"
|
||||
:sync-with-selection="false"
|
||||
clearable
|
||||
search-input-variant="button"
|
||||
show-search-icon
|
||||
@update:model-value="addIncludedProject"
|
||||
/>
|
||||
<div v-if="loadedDependentProjects.length > 0" class="flex flex-col gap-1">
|
||||
<div
|
||||
v-for="{ projectId, project } in loadedDependentProjects"
|
||||
:key="projectId"
|
||||
class="group flex min-w-0 items-center gap-2 rounded-xl px-1 pr-0 py-1 text-primary"
|
||||
>
|
||||
<img
|
||||
v-if="project.icon_url"
|
||||
:src="project.icon_url"
|
||||
:alt="project.title"
|
||||
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">
|
||||
{{ project.title }}
|
||||
</span>
|
||||
<div class="flex items-center gap-0.5">
|
||||
<button
|
||||
v-tooltip="
|
||||
excludedProjectIds.has(projectId)
|
||||
? formatMessage(messages.excluded)
|
||||
: formatMessage(messages.exclude)
|
||||
"
|
||||
type="button"
|
||||
class="flex shrink-0 cursor-pointer items-center justify-center rounded-xl border-0 px-2 py-2 transition-all [@media(hover:hover)]:py-1"
|
||||
:class="
|
||||
excludedProjectIds.has(projectId)
|
||||
? 'text-red opacity-100 bg-highlight-red'
|
||||
: 'text-secondary [@media(hover:hover)]:opacity-0 group-hover:opacity-100 bg-transparent hover:bg-button-bg hover:text-red active:scale-[0.96]'
|
||||
"
|
||||
:aria-label="
|
||||
excludedProjectIds.has(projectId)
|
||||
? formatMessage(messages.excluded)
|
||||
: formatMessage(messages.exclude)
|
||||
"
|
||||
@click="toggleProjectExcluded(projectId)"
|
||||
>
|
||||
<BanIcon class="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.removeIncludedProjectTooltip)"
|
||||
type="button"
|
||||
class="flex shrink-0 cursor-pointer items-center justify-center rounded-xl border-0 bg-transparent px-2 py-2 text-secondary transition-all [@media(hover:hover)]:py-1 [@media(hover:hover)]:opacity-0 group-hover:opacity-100 hover:bg-button-bg hover:text-contrast active:scale-[0.96]"
|
||||
:aria-label="
|
||||
formatMessage(messages.removeIncludedProject, {
|
||||
project: project.title,
|
||||
})
|
||||
"
|
||||
@click="removeIncludedProject(projectId)"
|
||||
>
|
||||
<XIcon class="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else :class="innerPanelClass" class="flex flex-col gap-3">
|
||||
<ProjectCombobox
|
||||
v-show="!showSelectedProject"
|
||||
ref="projectCombobox"
|
||||
:model-value="selectedProjectId"
|
||||
:project-types="selectableProjectTypes"
|
||||
:search-placeholder="formatMessage(messages.searchProjectPlaceholder)"
|
||||
:show-chevron="false"
|
||||
clearable
|
||||
search-input-variant="button"
|
||||
select-search-text-on-focus
|
||||
show-search-icon
|
||||
@update:model-value="setSelectedProjectId"
|
||||
/>
|
||||
<template v-if="showSelectedProject">
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-2xl p-2.5"
|
||||
:class="selectedProjectClass ?? 'bg-surface-2'"
|
||||
>
|
||||
<img
|
||||
v-if="selectedProject?.icon_url"
|
||||
:src="selectedProject.icon_url"
|
||||
:alt="selectedProject.title"
|
||||
class="size-12 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>
|
||||
<div class="relative right-2.5">
|
||||
<MultiSelect
|
||||
:model-value="draftDependencyTypes"
|
||||
:options="dependencyTypeOptions"
|
||||
:clearable="false"
|
||||
fit-content
|
||||
show-chevron
|
||||
trigger-class="!rounded-none !bg-transparent !p-0"
|
||||
checkbox-position="right"
|
||||
:dropdown-min-width="220"
|
||||
@open="resetDraftDependencyTypes"
|
||||
@close="commitDependencyTypes"
|
||||
@update:model-value="setDraftDependencyTypes"
|
||||
>
|
||||
<template #input-content="{ isOpen }">
|
||||
<span class="flex items-center gap-0.5 text-sm text-secondary ml-2.5">
|
||||
{{ dependencyTypeLabel }}
|
||||
<DropdownIcon
|
||||
class="size-4 transition-transform"
|
||||
:class="{ 'rotate-180': isOpen }"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
</MultiSelect>
|
||||
</div>
|
||||
</div>
|
||||
<IconButton
|
||||
v-tooltip="formatMessage(messages.clearDependencyFilter)"
|
||||
type="quiet"
|
||||
size="sm"
|
||||
class="ml-auto"
|
||||
:label="formatMessage(messages.clearDependencyFilter)"
|
||||
@click="setSelectedProjectId(undefined)"
|
||||
>
|
||||
<XIcon aria-hidden="true" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { BanIcon, 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 DependencyType,
|
||||
type FilterValue,
|
||||
formatDependencyProjectFilterOption,
|
||||
parseDependencyProjectFilterOption,
|
||||
} from '../../utils/search'
|
||||
import { IconButton } from '../base/buttons'
|
||||
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
|
||||
selectedProjectClass?: string
|
||||
resultCount?: number
|
||||
refreshing?: boolean
|
||||
}>()
|
||||
|
||||
const selectableProjectTypes: ProjectComboboxProjectType[] = [
|
||||
'mod',
|
||||
'resourcepack',
|
||||
'shader',
|
||||
'datapack',
|
||||
'plugin',
|
||||
]
|
||||
const isModpack = computed(() => props.projectType === 'modpack')
|
||||
const dependencyProjectIds = computed(() => [
|
||||
...new Set(
|
||||
selectedFilters.value
|
||||
.filter((filter) => filter.type === FILTER_TYPE_ID)
|
||||
.map((filter) => parseDependencyProjectFilterOption(filter.option).projectId),
|
||||
),
|
||||
])
|
||||
const pendingProjectId = ref<string>()
|
||||
const excludedProjectIds = computed(
|
||||
() =>
|
||||
new Set(
|
||||
selectedFilters.value
|
||||
.filter((filter) => filter.type === FILTER_TYPE_ID && filter.negative)
|
||||
.map((filter) => parseDependencyProjectFilterOption(filter.option).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: (previousData) => previousData,
|
||||
refetchOnWindowFocus: false,
|
||||
})
|
||||
|
||||
const dependentProjectMap = computed(
|
||||
() => new Map(dependentProjects.value?.map((project) => [project.id, project]) ?? []),
|
||||
)
|
||||
const loadedDependentProjects = computed(() =>
|
||||
dependencyProjectIds.value.flatMap((projectId) => {
|
||||
const project = dependentProjectMap.value.get(projectId)
|
||||
return project ? [{ projectId, project }] : []
|
||||
}),
|
||||
)
|
||||
const projectCombobox = ref<{ selectedProject: SearchHit | null } | null>(null)
|
||||
const selectedProject = computed(() => projectCombobox.value?.selectedProject ?? null)
|
||||
const selectedDependencyFilter = computed(() =>
|
||||
selectedFilters.value.find((filter) => filter.type === FILTER_TYPE_ID),
|
||||
)
|
||||
const selectedProjectId = computed(() =>
|
||||
selectedDependencyFilter.value
|
||||
? parseDependencyProjectFilterOption(selectedDependencyFilter.value.option).projectId
|
||||
: undefined,
|
||||
)
|
||||
const showSelectedProject = computed(() =>
|
||||
Boolean(selectedProjectId.value && selectedProject.value),
|
||||
)
|
||||
|
||||
const dependencyTypes = computed<DependencyType[]>(() =>
|
||||
selectedDependencyFilter.value
|
||||
? parseDependencyProjectFilterOption(selectedDependencyFilter.value.option).dependencyTypes
|
||||
: ['required'],
|
||||
)
|
||||
const draftDependencyTypes = ref<DependencyType[]>([...dependencyTypes.value])
|
||||
|
||||
watch(
|
||||
dependencyTypes,
|
||||
(types) => {
|
||||
draftDependencyTypes.value = [...types]
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
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(draftDependencyTypes.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 ??
|
||||
'Select type'
|
||||
)
|
||||
})
|
||||
|
||||
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: formatDependencyProjectFilterOption(projectId, ['required']),
|
||||
},
|
||||
]
|
||||
: otherFilters
|
||||
}
|
||||
|
||||
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 ||
|
||||
parseDependencyProjectFilterOption(filter.option).projectId !== projectId,
|
||||
)
|
||||
}
|
||||
|
||||
function toggleProjectExcluded(projectId: string) {
|
||||
selectedFilters.value = selectedFilters.value.map((filter) =>
|
||||
filter.type === FILTER_TYPE_ID &&
|
||||
parseDependencyProjectFilterOption(filter.option).projectId === projectId
|
||||
? { ...filter, negative: !filter.negative }
|
||||
: filter,
|
||||
)
|
||||
}
|
||||
|
||||
function resetDraftDependencyTypes() {
|
||||
draftDependencyTypes.value = [...dependencyTypes.value]
|
||||
}
|
||||
|
||||
function setDraftDependencyTypes(types: DependencyType[]) {
|
||||
draftDependencyTypes.value = types
|
||||
}
|
||||
|
||||
function commitDependencyTypes() {
|
||||
const projectId = selectedProjectId.value
|
||||
const types: DependencyType[] =
|
||||
draftDependencyTypes.value.length > 0 ? draftDependencyTypes.value : ['required']
|
||||
draftDependencyTypes.value = types
|
||||
const selectedTypes = new Set(dependencyTypes.value)
|
||||
const changed =
|
||||
types.length !== selectedTypes.size || types.some((type) => !selectedTypes.has(type))
|
||||
|
||||
if (changed && projectId) {
|
||||
selectedFilters.value = selectedFilters.value.map((filter) =>
|
||||
filter.type === FILTER_TYPE_ID
|
||||
? {
|
||||
...filter,
|
||||
option: formatDependencyProjectFilterOption(projectId, types),
|
||||
}
|
||||
: filter,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
clearDependencyFilter: {
|
||||
id: 'search.filter.dependent_project.clear',
|
||||
defaultMessage: 'Clear dependency filter',
|
||||
},
|
||||
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}',
|
||||
},
|
||||
removeIncludedProjectTooltip: {
|
||||
id: 'search.filter.included_content.remove_project.tooltip',
|
||||
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>
|
||||
@@ -1,15 +1,32 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center gap-1 empty:hidden">
|
||||
<TagItem
|
||||
v-if="selectedItems.length > 1"
|
||||
v-if="selectedTagCount > 1"
|
||||
class="transition-transform active:scale-[0.95]"
|
||||
:action="clearFilters"
|
||||
>
|
||||
<XCircleIcon />
|
||||
Clear all filters
|
||||
</TagItem>
|
||||
<TagItem v-if="selectedIncludedProjectItems.length > 0" :action="removeIncludedProjectFilters">
|
||||
<XIcon />
|
||||
{{
|
||||
formatMessage(includedProjectsMessage, {
|
||||
projects: formatProjectNames(selectedIncludedProjectItems),
|
||||
})
|
||||
}}
|
||||
</TagItem>
|
||||
<TagItem v-if="selectedExcludedProjectItems.length > 0" :action="removeExcludedProjectFilters">
|
||||
<XIcon />
|
||||
<BanIcon class="text-brand-red" />
|
||||
{{
|
||||
formatMessage(excludedProjectsMessage, {
|
||||
projects: formatProjectNames(selectedExcludedProjectItems),
|
||||
})
|
||||
}}
|
||||
</TagItem>
|
||||
<TagItem
|
||||
v-for="selectedItem in selectedItems"
|
||||
v-for="selectedItem in otherSelectedItems"
|
||||
:key="`remove-filter-${selectedItem.type}-${selectedItem.option}`"
|
||||
:action="() => removeFilter(selectedItem)"
|
||||
>
|
||||
@@ -37,23 +54,28 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { BanIcon, LockIcon, XCircleIcon, XIcon } from '@modrinth/assets'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { computed, type ComputedRef } from 'vue'
|
||||
|
||||
import { defineMessage, type MessageDescriptor, useVIntl } from '../../composables/i18n'
|
||||
import { injectModrinthClient } from '../../providers'
|
||||
import {
|
||||
type FilterOption,
|
||||
type FilterType,
|
||||
type FilterValue,
|
||||
flattenFilterOptions,
|
||||
parseDependencyProjectFilterOption,
|
||||
} from '../../utils/search'
|
||||
import TagItem from '../base/TagItem.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { labrinth } = injectModrinthClient()
|
||||
|
||||
const selectedFilters = defineModel<FilterValue[]>('selectedFilters', { required: true })
|
||||
|
||||
const props = defineProps<{
|
||||
filters: FilterType[]
|
||||
projectType: string
|
||||
providedFilters: FilterValue[]
|
||||
overriddenProvidedFilterTypes: string[]
|
||||
providedMessage?: MessageDescriptor | string
|
||||
@@ -63,6 +85,18 @@ const defaultProvidedMessage = defineMessage({
|
||||
id: 'search.filter.locked.default',
|
||||
defaultMessage: 'Filter locked',
|
||||
})
|
||||
const dependentProjectMessage = defineMessage({
|
||||
id: 'search.filter.dependent_project',
|
||||
defaultMessage: 'Depends on: {project}',
|
||||
})
|
||||
const includedProjectsMessage = defineMessage({
|
||||
id: 'search.filter.included_projects',
|
||||
defaultMessage: 'Includes: {projects}',
|
||||
})
|
||||
const excludedProjectsMessage = defineMessage({
|
||||
id: 'search.filter.excluded_projects',
|
||||
defaultMessage: 'Excludes: {projects}',
|
||||
})
|
||||
|
||||
type Item = {
|
||||
type: string
|
||||
@@ -72,13 +106,39 @@ type Item = {
|
||||
provided: boolean
|
||||
}
|
||||
|
||||
const dependentProjectIds = computed(() =>
|
||||
[
|
||||
...new Set(
|
||||
[...selectedFilters.value, ...props.providedFilters]
|
||||
.filter((filter) => filter.type === 'compatible_dependency_project_ids')
|
||||
.map((filter) => parseDependencyProjectFilterOption(filter.option).projectId),
|
||||
),
|
||||
].sort(),
|
||||
)
|
||||
|
||||
const { data: dependentProjects } = useQuery({
|
||||
queryKey: computed(() => [
|
||||
'search-filter-control',
|
||||
'dependent-projects',
|
||||
dependentProjectIds.value,
|
||||
]),
|
||||
queryFn: () => labrinth.projects_v2.getMultiple(dependentProjectIds.value),
|
||||
enabled: computed(() => dependentProjectIds.value.length > 0),
|
||||
placeholderData: [],
|
||||
refetchOnWindowFocus: false,
|
||||
})
|
||||
|
||||
const dependentProjectNames = computed(
|
||||
() => new Map(dependentProjects.value?.map((project) => [project.id, project.title]) ?? []),
|
||||
)
|
||||
|
||||
function filterMatches(type: FilterType, option: FilterOption, list: FilterValue[]) {
|
||||
return list.some((provided) => provided.type === type.id && provided.option === option.id)
|
||||
}
|
||||
|
||||
const items: ComputedRef<Item[]> = computed(() => {
|
||||
return props.filters.flatMap((type) =>
|
||||
flattenFilterOptions(type.options)
|
||||
return props.filters.flatMap((type) => {
|
||||
const optionItems = flattenFilterOptions(type.options)
|
||||
.filter(
|
||||
(option) =>
|
||||
filterMatches(type, option, selectedFilters.value) ||
|
||||
@@ -91,11 +151,70 @@ const items: ComputedRef<Item[]> = computed(() => {
|
||||
?.negative,
|
||||
provided: filterMatches(type, option, props.providedFilters),
|
||||
formatted_name: option.formatted_name,
|
||||
})),
|
||||
)
|
||||
}))
|
||||
|
||||
if (type.id !== 'compatible_dependency_project_ids') {
|
||||
return optionItems
|
||||
}
|
||||
|
||||
const customValues = [...selectedFilters.value, ...props.providedFilters].filter(
|
||||
(filter) => filter.type === type.id,
|
||||
)
|
||||
return [
|
||||
...optionItems,
|
||||
...customValues.map((filter) => {
|
||||
const projectId = parseDependencyProjectFilterOption(filter.option).projectId
|
||||
return {
|
||||
type: type.id,
|
||||
option: filter.option,
|
||||
negative: filter.negative,
|
||||
provided: props.providedFilters.some(
|
||||
(provided) => provided.type === type.id && provided.option === filter.option,
|
||||
),
|
||||
formatted_name: formatMessage(dependentProjectMessage, {
|
||||
project: dependentProjectNames.value.get(projectId) ?? projectId,
|
||||
}),
|
||||
}
|
||||
}),
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
const selectedItems = computed(() => items.value.filter((x) => !x.provided))
|
||||
const selectedIncludedProjectItems = computed(() =>
|
||||
props.projectType === 'modpack'
|
||||
? selectedItems.value.filter(
|
||||
(item) => item.type === 'compatible_dependency_project_ids' && !item.negative,
|
||||
)
|
||||
: [],
|
||||
)
|
||||
const selectedExcludedProjectItems = computed(() =>
|
||||
props.projectType === 'modpack'
|
||||
? selectedItems.value.filter(
|
||||
(item) => item.type === 'compatible_dependency_project_ids' && item.negative,
|
||||
)
|
||||
: [],
|
||||
)
|
||||
const otherSelectedItems = computed(() =>
|
||||
props.projectType === 'modpack'
|
||||
? selectedItems.value.filter((item) => item.type !== 'compatible_dependency_project_ids')
|
||||
: selectedItems.value,
|
||||
)
|
||||
const selectedTagCount = computed(
|
||||
() =>
|
||||
otherSelectedItems.value.length +
|
||||
(selectedIncludedProjectItems.value.length > 0 ? 1 : 0) +
|
||||
(selectedExcludedProjectItems.value.length > 0 ? 1 : 0),
|
||||
)
|
||||
|
||||
function formatProjectNames(items: Item[]) {
|
||||
return items
|
||||
.map((item) => {
|
||||
const projectId = parseDependencyProjectFilterOption(item.option).projectId
|
||||
return dependentProjectNames.value.get(projectId) ?? projectId
|
||||
})
|
||||
.join(', ')
|
||||
}
|
||||
|
||||
function removeFilter(filter: Item) {
|
||||
selectedFilters.value = selectedFilters.value.filter(
|
||||
@@ -103,6 +222,18 @@ function removeFilter(filter: Item) {
|
||||
)
|
||||
}
|
||||
|
||||
function removeIncludedProjectFilters() {
|
||||
selectedFilters.value = selectedFilters.value.filter(
|
||||
(filter) => filter.type !== 'compatible_dependency_project_ids' || filter.negative,
|
||||
)
|
||||
}
|
||||
|
||||
function removeExcludedProjectFilters() {
|
||||
selectedFilters.value = selectedFilters.value.filter(
|
||||
(filter) => filter.type !== 'compatible_dependency_project_ids' || !filter.negative,
|
||||
)
|
||||
}
|
||||
|
||||
async function clearFilters() {
|
||||
selectedFilters.value = []
|
||||
}
|
||||
|
||||
@@ -69,107 +69,120 @@
|
||||
</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)]"
|
||||
<SearchDependsOnFilter
|
||||
v-if="filterType.display === 'depends-on-project'"
|
||||
v-model:selected-filters="selectedFilters"
|
||||
:project-type="projectType"
|
||||
:inner-panel-class="innerPanelClass"
|
||||
:selected-project-class="selectedProjectClass"
|
||||
:result-count="resultCount"
|
||||
:loading="loading"
|
||||
:refreshing="refreshing"
|
||||
/>
|
||||
<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="filterType.supports"
|
||||
:included="isIncluded"
|
||||
:excluded="isExcluded"
|
||||
@toggle="toggleFilter"
|
||||
@toggle-exclude="toggleNegativeFilter"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-for="option in visibleOptions" :key="`${filterType.id}-${option.id}`">
|
||||
<SearchFilterOption
|
||||
:option="option"
|
||||
:included="isIncluded(option)"
|
||||
:excluded="isExcluded(option)"
|
||||
<template v-if="filterType.display !== 'depends-on-project'">
|
||||
<StyledInput
|
||||
v-if="filterType.searchable"
|
||||
:id="`search-${filterType.id}`"
|
||||
v-model="query"
|
||||
:icon="SearchIcon"
|
||||
type="text"
|
||||
:placeholder="formatMessage(messages.searchPlaceholder)"
|
||||
autocomplete="off"
|
||||
clearable
|
||||
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="filterType.supports"
|
||||
:has-sub-options="!!option.sub_options?.length"
|
||||
:expanded="isExpanded(option)"
|
||||
:class="{
|
||||
'mr-3': scrollable,
|
||||
}"
|
||||
:included="isIncluded"
|
||||
:excluded="isExcluded"
|
||||
@toggle="toggleFilter"
|
||||
@toggle-exclude="toggleNegativeFilter"
|
||||
@toggle-expand="toggleExpand(option)"
|
||||
>
|
||||
<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>
|
||||
<div
|
||||
v-if="option.sub_options?.length && isExpanded(option)"
|
||||
class="ml-4 flex flex-col gap-1"
|
||||
:class="{ 'mr-3': scrollable }"
|
||||
>
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-for="option in visibleOptions" :key="`${filterType.id}-${option.id}`">
|
||||
<SearchFilterOption
|
||||
v-for="subOption in option.sub_options"
|
||||
:key="`${filterType.id}-${subOption.id}`"
|
||||
:option="subOption"
|
||||
:included="isIncluded(subOption)"
|
||||
:excluded="isExcluded(subOption)"
|
||||
:option="option"
|
||||
:included="isIncluded(option)"
|
||||
:excluded="isExcluded(option)"
|
||||
:supports="filterType.supports"
|
||||
:has-sub-options="!!option.sub_options?.length"
|
||||
:expanded="isExpanded(option)"
|
||||
:class="{
|
||||
'mr-3': scrollable,
|
||||
}"
|
||||
@toggle="toggleFilter"
|
||||
@toggle-exclude="toggleNegativeFilter"
|
||||
@toggle-expand="toggleExpand(option)"
|
||||
>
|
||||
<slot name="option" :filter="filterType" :option="subOption">
|
||||
<span class="truncate text-sm">
|
||||
{{ subOption.formatted_name ?? subOption.id }}
|
||||
<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>
|
||||
</div>
|
||||
<div
|
||||
v-if="option.sub_options?.length && isExpanded(option)"
|
||||
class="ml-4 flex flex-col gap-1"
|
||||
:class="{ 'mr-3': scrollable }"
|
||||
>
|
||||
<SearchFilterOption
|
||||
v-for="subOption in option.sub_options"
|
||||
:key="`${filterType.id}-${subOption.id}`"
|
||||
:option="subOption"
|
||||
:included="isIncluded(subOption)"
|
||||
:excluded="isExcluded(subOption)"
|
||||
:supports="filterType.supports"
|
||||
@toggle="toggleFilter"
|
||||
@toggle-exclude="toggleNegativeFilter"
|
||||
>
|
||||
<slot name="option" :filter="filterType" :option="subOption">
|
||||
<span class="truncate text-sm">
|
||||
{{ subOption.formatted_name ?? subOption.id }}
|
||||
</span>
|
||||
</slot>
|
||||
</SearchFilterOption>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</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>
|
||||
<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>
|
||||
</template>
|
||||
<div :class="innerPanelClass ? innerPanelClass : ''" class="empty:hidden">
|
||||
<Checkbox
|
||||
v-for="group in filterType.toggle_groups"
|
||||
@@ -219,6 +232,7 @@ import {
|
||||
} from '../../utils/search'
|
||||
import Accordion from '../base/Accordion.vue'
|
||||
import { Checkbox, ScrollablePanel, StyledInput } from '../index'
|
||||
import SearchDependsOnFilter from './SearchDependsOnFilter.vue'
|
||||
import SearchFilterGroup from './SearchFilterGroup.vue'
|
||||
import SearchFilterOption from './SearchFilterOption.vue'
|
||||
|
||||
@@ -233,11 +247,16 @@ const overriddenProvidedFilterTypes = defineModel<string[]>('overriddenProvidedF
|
||||
|
||||
const props = defineProps<{
|
||||
filterType: FilterType
|
||||
projectType: string
|
||||
buttonClass?: string
|
||||
contentClass?: string
|
||||
innerPanelClass?: string
|
||||
selectedProjectClass?: string
|
||||
openByDefault?: boolean
|
||||
providedFilters: FilterValue[]
|
||||
resultCount?: number
|
||||
loading?: boolean
|
||||
refreshing?: boolean
|
||||
}>()
|
||||
|
||||
defineOptions({
|
||||
|
||||
@@ -58,6 +58,7 @@ export interface BrowseSearchState {
|
||||
effectiveCurrentSortType: Ref<SortType>
|
||||
|
||||
loading: Ref<boolean>
|
||||
refreshing: Ref<boolean>
|
||||
projectHits: ShallowRef<BrowseSearchResponse['projectHits']>
|
||||
serverHits: ShallowRef<BrowseSearchResponse['serverHits']>
|
||||
totalHits: Ref<number>
|
||||
@@ -186,6 +187,7 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
|
||||
])
|
||||
|
||||
const loading = ref(true)
|
||||
const refreshing = ref(false)
|
||||
const projectHits = shallowRef<BrowseSearchResponse['projectHits']>([])
|
||||
const serverHits = shallowRef<BrowseSearchResponse['serverHits']>([])
|
||||
const totalHits = ref(0)
|
||||
@@ -297,6 +299,7 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
|
||||
)
|
||||
|
||||
watch(effectiveRequestParams, (newVal, oldVal) => {
|
||||
refreshing.value = true
|
||||
debug('effectiveRequestParams changed', {
|
||||
from: oldVal?.substring(0, 80),
|
||||
to: newVal?.substring(0, 80),
|
||||
@@ -323,6 +326,7 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
|
||||
}
|
||||
|
||||
const version = ++searchVersion
|
||||
refreshing.value = true
|
||||
debug('refreshSearch start', {
|
||||
version,
|
||||
projectType: options.projectType.value,
|
||||
@@ -363,11 +367,13 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
|
||||
|
||||
updateUrlParams()
|
||||
loading.value = false
|
||||
refreshing.value = false
|
||||
} catch (err) {
|
||||
debug('refreshSearch error', err)
|
||||
console.error('Browse search error:', err)
|
||||
if (version === searchVersion) {
|
||||
loading.value = false
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -441,6 +447,7 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
|
||||
effectiveSortTypes,
|
||||
effectiveCurrentSortType,
|
||||
loading,
|
||||
refreshing,
|
||||
projectHits,
|
||||
serverHits,
|
||||
totalHits,
|
||||
|
||||
@@ -237,6 +237,7 @@ function getProjectCardTags(result: Labrinth.Search.v3.ResultSearchProject, disp
|
||||
v-if="ctx.isServerType.value"
|
||||
v-model:selected-filters="ctx.serverCurrentFilters.value"
|
||||
:filters="ctx.serverFilterTypes.value"
|
||||
:project-type="ctx.projectType.value"
|
||||
:provided-filters="[]"
|
||||
:overridden-provided-filter-types="[]"
|
||||
/>
|
||||
@@ -248,12 +249,13 @@ function getProjectCardTags(result: Labrinth.Search.v3.ResultSearchProject, disp
|
||||
(f) => f.display !== 'none' && !(ctx.hiddenFilterTypes?.value ?? []).includes(f.id),
|
||||
)
|
||||
"
|
||||
:project-type="ctx.projectType.value"
|
||||
:provided-filters="ctx.providedFilters?.value ?? []"
|
||||
:overridden-provided-filter-types="ctx.overriddenProvidedFilterTypes.value"
|
||||
:provided-message="lockedMessages?.providedBy"
|
||||
/>
|
||||
|
||||
<div class="search">
|
||||
<div class="search [overflow-anchor:none]">
|
||||
<section v-if="ctx.loading.value" class="offline">
|
||||
<component :is="ctx.loadingComponent ?? LoadingIndicator" />
|
||||
</section>
|
||||
|
||||
@@ -31,6 +31,7 @@ export interface BrowseManagerContext {
|
||||
effectiveSortTypes: ComputedRef<readonly SortType[]>
|
||||
effectiveCurrentSortType: Ref<SortType>
|
||||
loading: Ref<boolean>
|
||||
refreshing: Ref<boolean>
|
||||
projectHits: ShallowRef<BrowseSearchResponse['projectHits']>
|
||||
serverHits: ShallowRef<BrowseSearchResponse['serverHits']>
|
||||
totalHits: Ref<number>
|
||||
|
||||
@@ -92,6 +92,9 @@ const buttonClass = computed(() => {
|
||||
|
||||
const contentClass = computed(() => (isApp.value ? 'mt-2 mb-3' : 'mb-4 mx-3'))
|
||||
const innerPanelClass = computed(() => (isApp.value ? 'ml-2 mr-3' : 'p-1'))
|
||||
const selectedProjectClass = computed(() =>
|
||||
isApp.value ? 'bg-surface-1 border border-solid border-surface-3' : 'bg-surface-2',
|
||||
)
|
||||
|
||||
function hasProvidedFilter(filterId: string): boolean {
|
||||
return (ctx.providedFilters?.value ?? []).some((filter) => filter.type === filterId)
|
||||
@@ -104,6 +107,9 @@ function getFilterOpenByDefault(filterId: string): boolean {
|
||||
if (hasProvidedFilter(filterId)) {
|
||||
return true
|
||||
}
|
||||
if (filterId === 'compatible_dependency_project_ids') {
|
||||
return true
|
||||
}
|
||||
if (ctx.isServerType.value) {
|
||||
return ![
|
||||
'server_category_minecraft_server_meta',
|
||||
@@ -214,10 +220,12 @@ 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"
|
||||
:inner-panel-class="innerPanelClass"
|
||||
:selected-project-class="selectedProjectClass"
|
||||
:open-by-default="getFilterOpenByDefault(filterType.id)"
|
||||
@on-open="() => filterType.id === 'advanced' && setAdvancedFiltersCollapsed(false)"
|
||||
@on-close="() => filterType.id === 'advanced' && setAdvancedFiltersCollapsed(true)"
|
||||
@@ -243,10 +251,15 @@ 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"
|
||||
:loading="ctx.loading.value"
|
||||
:refreshing="ctx.refreshing.value"
|
||||
:class="filterClass"
|
||||
:button-class="buttonClass"
|
||||
:content-class="contentClass"
|
||||
:inner-panel-class="innerPanelClass"
|
||||
:selected-project-class="selectedProjectClass"
|
||||
:open-by-default="getFilterOpenByDefault(filter.id)"
|
||||
@on-open="() => filter.id === 'advanced' && setAdvancedFiltersCollapsed(false)"
|
||||
@on-close="() => filter.id === 'advanced' && setAdvancedFiltersCollapsed(true)"
|
||||
|
||||
@@ -4106,6 +4106,57 @@
|
||||
"s.bg": {
|
||||
"defaultMessage": "Background task running"
|
||||
},
|
||||
"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_project": {
|
||||
"defaultMessage": "Depends on: {project}"
|
||||
},
|
||||
"search.filter.dependent_project.clear": {
|
||||
"defaultMessage": "Clear dependency filter"
|
||||
},
|
||||
"search.filter.dependent_project.search_placeholder": {
|
||||
"defaultMessage": "Search for a project..."
|
||||
},
|
||||
"search.filter.excluded_projects": {
|
||||
"defaultMessage": "Excludes: {projects}"
|
||||
},
|
||||
"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.remove_project.tooltip": {
|
||||
"defaultMessage": "Remove project"
|
||||
},
|
||||
"search.filter.included_content.search_placeholder": {
|
||||
"defaultMessage": "Search content..."
|
||||
},
|
||||
"search.filter.included_projects": {
|
||||
"defaultMessage": "Includes: {projects}"
|
||||
},
|
||||
"search.filter.locked.default": {
|
||||
"defaultMessage": "Filter locked"
|
||||
},
|
||||
@@ -4172,6 +4223,9 @@
|
||||
"search.filter_type.advanced.disclosure.telemetry": {
|
||||
"defaultMessage": "Telemetry"
|
||||
},
|
||||
"search.filter_type.compatible_dependency_project_ids": {
|
||||
"defaultMessage": "Depends on"
|
||||
},
|
||||
"search.filter_type.environment": {
|
||||
"defaultMessage": "Environment"
|
||||
},
|
||||
@@ -4187,6 +4241,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"
|
||||
},
|
||||
|
||||
@@ -58,6 +58,19 @@ export const Searchable: Story = {
|
||||
},
|
||||
}
|
||||
|
||||
export const SearchableButtonVariant: Story = {
|
||||
args: {
|
||||
options: [
|
||||
{ value: '1', label: 'Minecraft' },
|
||||
{ value: '2', label: 'Fabric' },
|
||||
{ value: '3', label: 'Forge' },
|
||||
],
|
||||
searchable: true,
|
||||
searchInputVariant: 'button',
|
||||
searchPlaceholder: 'Search projects...',
|
||||
},
|
||||
}
|
||||
|
||||
export const SearchableEmpty: Story = {
|
||||
args: {
|
||||
options: [],
|
||||
|
||||
@@ -105,10 +105,11 @@ export type FilterType = {
|
||||
}[]
|
||||
searchable: boolean
|
||||
allows_custom_options?: 'and' | 'or'
|
||||
custom_option_field?: string
|
||||
ordering?: number
|
||||
} & (
|
||||
| {
|
||||
display: 'all' | 'scrollable' | 'none'
|
||||
display: 'all' | 'scrollable' | 'none' | 'depends-on-project'
|
||||
}
|
||||
| {
|
||||
display: 'expandable'
|
||||
@@ -122,6 +123,45 @@ export type FilterValue = {
|
||||
negative?: boolean
|
||||
}
|
||||
|
||||
export type DependencyType = 'required' | 'optional' | 'embedded'
|
||||
|
||||
const DEPENDENCY_TYPE_FIELDS: Record<DependencyType, string> = {
|
||||
required: 'required_dependency_project_ids',
|
||||
optional: 'optional_dependency_project_ids',
|
||||
embedded: 'embedded_dependency_project_ids',
|
||||
}
|
||||
|
||||
const DEPENDENCY_TYPES = Object.keys(DEPENDENCY_TYPE_FIELDS) as DependencyType[]
|
||||
|
||||
export function formatDependencyProjectFilterOption(
|
||||
projectId: string,
|
||||
dependencyTypes: readonly DependencyType[],
|
||||
): string {
|
||||
return `${dependencyTypes.join(',')}:${projectId}`
|
||||
}
|
||||
|
||||
export function parseDependencyProjectFilterOption(option: string): {
|
||||
projectId: string
|
||||
dependencyTypes: DependencyType[]
|
||||
} {
|
||||
const separatorIndex = option.indexOf(':')
|
||||
if (separatorIndex === -1) {
|
||||
return { projectId: option, dependencyTypes: ['required'] }
|
||||
}
|
||||
|
||||
const dependencyTypes = option
|
||||
.slice(0, separatorIndex)
|
||||
.split(',')
|
||||
.filter((type): type is DependencyType => DEPENDENCY_TYPES.includes(type as DependencyType))
|
||||
const projectId = option.slice(separatorIndex + 1)
|
||||
|
||||
if (dependencyTypes.length === 0 || !projectId) {
|
||||
return { projectId: option, dependencyTypes: ['required'] }
|
||||
}
|
||||
|
||||
return { projectId, dependencyTypes }
|
||||
}
|
||||
|
||||
export type EnvironmentSearchOverride =
|
||||
| { mode: 'include'; values: string[] }
|
||||
| { mode: 'exclude'; values: string[] }
|
||||
@@ -353,6 +393,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)
|
||||
}
|
||||
@@ -396,6 +444,21 @@ export function useSearch(
|
||||
|
||||
const filterTypes: FilterType[] = [
|
||||
...Object.values(categoryFilters),
|
||||
{
|
||||
id: 'compatible_dependency_project_ids',
|
||||
formatted_name: formatMessage(
|
||||
projectTypes.value.includes('modpack') ? includedContentFilterName : dependsOnFilterName,
|
||||
),
|
||||
supported_project_types: ALL_PROJECT_TYPES,
|
||||
query_param: 'dep',
|
||||
supports: ['include'],
|
||||
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: 'environment',
|
||||
formatted_name: formatMessage(
|
||||
@@ -704,6 +767,26 @@ export function useSearch(
|
||||
const negativeByType: Record<string, string[]> = {}
|
||||
|
||||
for (const filterValue of filterValues) {
|
||||
if (filterValue.type === 'compatible_dependency_project_ids') {
|
||||
const { projectId, dependencyTypes } = parseDependencyProjectFilterOption(
|
||||
filterValue.option,
|
||||
)
|
||||
const fields = projectTypes.value.includes('modpack')
|
||||
? ['compatible_dependency_project_ids']
|
||||
: dependencyTypes.map((type) => DEPENDENCY_TYPE_FIELDS[type])
|
||||
const operator = filterValue.negative ? '!=' : '='
|
||||
const conditions = fields.map(
|
||||
(field) => `${field} ${operator} ${formatSearchFilterValue(projectId)}`,
|
||||
)
|
||||
|
||||
if (conditions.length === 1) {
|
||||
parts.push(conditions[0])
|
||||
} else {
|
||||
parts.push(`(${conditions.join(filterValue.negative ? ' AND ' : ' OR ')})`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const type = filters.value.find((type) => type.id === filterValue.type)
|
||||
if (!type) {
|
||||
console.error(`Filter type ${filterValue.type} not found`)
|
||||
@@ -719,7 +802,9 @@ export function useSearch(
|
||||
formatted_name: filterValue.option,
|
||||
icon: undefined,
|
||||
method: type.allows_custom_options,
|
||||
value: filterValue.option,
|
||||
value: type.custom_option_field
|
||||
? `${type.custom_option_field}:${filterValue.option}`
|
||||
: filterValue.option,
|
||||
}
|
||||
} else if (!option) {
|
||||
console.error(`Filter option ${filterValue.option} not found`)
|
||||
@@ -962,8 +1047,8 @@ export function useSearch(
|
||||
currentFilters.value.forEach((filterValue) => {
|
||||
const type = filters.value.find((type) => type.id === filterValue.type)
|
||||
const option = type ? findFilterOption(type.options, filterValue.option) : undefined
|
||||
if (type && option) {
|
||||
const value = getOptionValue(option, filterValue.negative)
|
||||
if (type && (option || type.allows_custom_options)) {
|
||||
const value = option ? getOptionValue(option, filterValue.negative) : filterValue.option
|
||||
if (items[type.query_param]) {
|
||||
items[type.query_param].push(value)
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user