forked from didirus/AstralRinth
App redesign (#2946)
* Start of app redesign * format * continue progress * Content page nearly done * Fix recursion issues with content page * Fix update all alignment * Discover page progress * Settings progress * Removed unlocked-size hack that breaks web * Revamp project page, refactor web project page to share code with app, fixed loading bar, misc UI/UX enhancements, update ko-fi logo, update arrow icons, fix web issues caused by floating-vue migration, fix tooltip issues, update web tooltips, clean up web hydration issues * Ads + run prettier * Begin auth refactor, move common messages to ui lib, add i18n extraction to all apps, begin Library refactor * fix ads not hiding when plus log in * rev lockfile changes/conflicts * Fix sign in page * Add generated * (mostly) Data driven search * Fix search mobile issue * profile fixes * Project versions page, fix typescript on UI lib and misc fixes * Remove unused gallery component * Fix linkfunction err * Search filter controls at top, localization for locked filters * Fix provided filter names * Fix navigating from instance browse to main browse * Friends frontend (#2995) * Friends system frontend * (almost) finish frontend * finish friends, fix lint * Fix lint --------- Signed-off-by: Geometrically <18202329+Geometrically@users.noreply.github.com> * Refresh macOS app icon * Update web search UI more * Fix link opens * Fix frontend build --------- Signed-off-by: Geometrically <18202329+Geometrically@users.noreply.github.com> Co-authored-by: Jai A <jaiagr+gpg@pm.me> Co-authored-by: Geometrically <18202329+Geometrically@users.noreply.github.com>
This commit is contained in:
105
packages/ui/src/components/search/BrowseFiltersPanel.vue
Normal file
105
packages/ui/src/components/search/BrowseFiltersPanel.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<div>
|
||||
<Accordion
|
||||
v-for="filter in filters"
|
||||
:key="filter.id"
|
||||
v-model="filters"
|
||||
v-bind="$attrs"
|
||||
:button-class="buttonClass"
|
||||
:content-class="contentClass"
|
||||
open-by-default
|
||||
>
|
||||
<template #title>
|
||||
<slot name="header" :filter="filter">
|
||||
<h2>{{ filter.formatted_name }}</h2>
|
||||
</slot>
|
||||
</template>
|
||||
<template #default>
|
||||
<template v-for="option in filter.options" :key="`${filter.id}-${option}`">
|
||||
<slot name="option" :filter="filter" :option="option">
|
||||
<div>
|
||||
{{ option.formatted_name }}
|
||||
</div>
|
||||
</slot>
|
||||
</template>
|
||||
</template>
|
||||
</Accordion>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Accordion from '../base/Accordion.vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface FilterOption<T> {
|
||||
id: string
|
||||
formatted_name: string
|
||||
data: T
|
||||
}
|
||||
|
||||
interface FilterType<T> {
|
||||
id: string
|
||||
formatted_name: string
|
||||
scrollable?: boolean
|
||||
options: FilterOption<T>[]
|
||||
}
|
||||
|
||||
interface GameVersion {
|
||||
version: string
|
||||
version_type: 'release' | 'snapshot' | 'alpha' | 'beta'
|
||||
date: string
|
||||
major: boolean
|
||||
}
|
||||
|
||||
type ProjectType = 'mod' | 'modpack' | 'resourcepack' | 'shader' | 'datapack' | 'plugin'
|
||||
|
||||
interface Platform {
|
||||
name: string
|
||||
icon: string
|
||||
supported_project_types: ProjectType[]
|
||||
default: boolean
|
||||
formatted_name: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
buttonClass?: string
|
||||
contentClass?: string
|
||||
gameVersions?: GameVersion[]
|
||||
platforms: Platform[]
|
||||
}>()
|
||||
|
||||
const filters = computed(() => {
|
||||
const filters: FilterType<any>[] = [
|
||||
{
|
||||
id: 'platform',
|
||||
formatted_name: 'Platform',
|
||||
options:
|
||||
props.platforms
|
||||
.filter((x) => x.default && x.supported_project_types.includes('modpack'))
|
||||
.map((x) => ({
|
||||
id: x.name,
|
||||
formatted_name: x.formatted_name,
|
||||
data: x,
|
||||
})) || [],
|
||||
},
|
||||
{
|
||||
id: 'gameVersion',
|
||||
formatted_name: 'Game version',
|
||||
options:
|
||||
props.gameVersions
|
||||
?.filter((x) => x.major && x.version_type === 'release')
|
||||
.map((x) => ({
|
||||
id: x.version,
|
||||
formatted_name: x.version,
|
||||
data: x,
|
||||
})) || [],
|
||||
},
|
||||
]
|
||||
|
||||
return filters
|
||||
})
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
</script>
|
||||
97
packages/ui/src/components/search/SearchFilterControl.vue
Normal file
97
packages/ui/src/components/search/SearchFilterControl.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div class="experimental-styles-within flex flex-wrap items-center gap-1 empty:hidden">
|
||||
<TagItem
|
||||
v-if="selectedItems.length > 1"
|
||||
class="transition-transform active:scale-[0.95]"
|
||||
:action="clearFilters"
|
||||
>
|
||||
<XCircleIcon />
|
||||
Clear all filters
|
||||
</TagItem>
|
||||
<TagItem
|
||||
v-for="selectedItem in selectedItems"
|
||||
:key="`remove-filter-${selectedItem.type}-${selectedItem.option}`"
|
||||
:action="() => removeFilter(selectedItem)"
|
||||
>
|
||||
<XIcon />
|
||||
<BanIcon v-if="selectedItem.negative" class="text-brand-red" />
|
||||
{{ selectedItem.formatted_name ?? selectedItem.option }}
|
||||
</TagItem>
|
||||
<TagItem
|
||||
v-for="providedItem in items.filter((x) => x.provided)"
|
||||
:key="`provided-filter-${providedItem.type}-${providedItem.option}`"
|
||||
v-tooltip="formatMessage(providedMessage ?? defaultProvidedMessage)"
|
||||
:style="{ '--_bg-color': `var(--color-raised-bg)` }"
|
||||
>
|
||||
<LockIcon />
|
||||
{{ providedItem.formatted_name ?? providedItem.option }}
|
||||
</TagItem>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { XCircleIcon, XIcon, LockIcon, BanIcon } from '@modrinth/assets'
|
||||
import { computed, type ComputedRef } from 'vue'
|
||||
import TagItem from '../base/TagItem.vue'
|
||||
import type { FilterValue, FilterType, FilterOption } from '../../utils/search'
|
||||
import { defineMessage, type MessageDescriptor, useVIntl } from '@vintl/vintl'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const selectedFilters = defineModel<FilterValue[]>('selectedFilters', { required: true })
|
||||
|
||||
const props = defineProps<{
|
||||
filters: FilterType[]
|
||||
providedFilters: FilterValue[]
|
||||
overriddenProvidedFilterTypes: string[]
|
||||
providedMessage?: MessageDescriptor
|
||||
}>()
|
||||
|
||||
const defaultProvidedMessage = defineMessage({
|
||||
id: 'search.filter.locked.default',
|
||||
defaultMessage: 'Filter locked',
|
||||
})
|
||||
|
||||
type Item = {
|
||||
type: string
|
||||
option: string
|
||||
negative?: boolean
|
||||
formatted_name?: string
|
||||
provided: boolean
|
||||
}
|
||||
|
||||
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) =>
|
||||
type.options
|
||||
.filter(
|
||||
(option) =>
|
||||
filterMatches(type, option, selectedFilters.value) ||
|
||||
filterMatches(type, option, props.providedFilters),
|
||||
)
|
||||
.map((option) => ({
|
||||
type: type.id,
|
||||
option: option.id,
|
||||
negative: selectedFilters.value.find((x) => x.type === type.id && x.option === option.id)
|
||||
?.negative,
|
||||
provided: filterMatches(type, option, props.providedFilters),
|
||||
formatted_name: option.formatted_name,
|
||||
})),
|
||||
)
|
||||
})
|
||||
|
||||
const selectedItems = computed(() => items.value.filter((x) => !x.provided))
|
||||
|
||||
function removeFilter(filter: Item) {
|
||||
selectedFilters.value = selectedFilters.value.filter(
|
||||
(x) => x.type !== filter.type || x.option !== filter.option,
|
||||
)
|
||||
}
|
||||
|
||||
async function clearFilters() {
|
||||
selectedFilters.value = []
|
||||
}
|
||||
</script>
|
||||
59
packages/ui/src/components/search/SearchFilterOption.vue
Normal file
59
packages/ui/src/components/search/SearchFilterOption.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<div class="search-filter-option group flex gap-1 items-center">
|
||||
<button
|
||||
:class="`flex border-none cursor-pointer !w-full items-center gap-2 truncate rounded-xl px-2 py-2 [@media(hover:hover)]:py-1 text-sm font-semibold transition-all hover:text-contrast focus-visible:text-contrast active:scale-[0.98] ${included ? 'bg-brand-highlight text-contrast hover:brightness-125' : excluded ? 'bg-highlight-red text-contrast hover:brightness-125' : 'bg-transparent text-secondary hover:bg-button-bg focus-visible:bg-button-bg [&>svg.check-icon]:hover:text-brand [&>svg.check-icon]:focus-visible:text-brand'}`"
|
||||
@click="() => emit('toggle', option)"
|
||||
>
|
||||
<slot>
|
||||
</slot>
|
||||
<BanIcon
|
||||
v-if="excluded"
|
||||
:class="`filter-action-icon ml-auto h-4 w-4 shrink-0 transition-opacity group-hover:opacity-100 ${excluded ? '' : '[@media(hover:hover)]:opacity-0'}`"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<CheckIcon
|
||||
v-else
|
||||
:class="`filter-action-icon check-icon ml-auto h-4 w-4 shrink-0 transition-opacity group-hover:opacity-100 ${included ? '' : '[@media(hover:hover)]:opacity-0'}`"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<div v-if="supportsNegativeFilter && !excluded" class="w-px h-[1.75rem] bg-button-bg [@media(hover:hover)]:contents" :class="{ 'opacity-0': included }">
|
||||
</div>
|
||||
<button
|
||||
v-if="supportsNegativeFilter && !excluded"
|
||||
v-tooltip="excluded ? 'Remove exclusion' : 'Exclude'"
|
||||
class="flex border-none cursor-pointer items-center justify-center gap-2 rounded-xl bg-transparent px-2 py-1 text-sm font-semibold text-secondary [@media(hover:hover)]:opacity-0 transition-all hover:bg-button-bg hover:text-red focus-visible:bg-button-bg focus-visible:text-red active:scale-[0.96]"
|
||||
@click="() => emit('toggleExclude', option)"
|
||||
>
|
||||
<BanIcon class="filter-action-icon h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { BanIcon, CheckIcon } from '@modrinth/assets'
|
||||
import type { FilterOption } from '../../utils/search'
|
||||
|
||||
withDefaults(defineProps<{
|
||||
option: FilterOption
|
||||
included: boolean
|
||||
excluded: boolean
|
||||
supportsNegativeFilter?: boolean
|
||||
}>(), {
|
||||
supportsNegativeFilter: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggle: [option: FilterOption]
|
||||
toggleExclude: [option: FilterOption]
|
||||
}>()
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.search-filter-option:hover,
|
||||
.search-filter-option:has(button:focus-visible) {
|
||||
button,
|
||||
.filter-action-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
334
packages/ui/src/components/search/SearchSidebarFilter.vue
Normal file
334
packages/ui/src/components/search/SearchSidebarFilter.vue
Normal file
@@ -0,0 +1,334 @@
|
||||
<template>
|
||||
<Accordion
|
||||
v-bind="$attrs"
|
||||
ref="accordion"
|
||||
:button-class="buttonClass ?? 'flex flex-col gap-2 justify-start items-start'"
|
||||
:content-class="contentClass"
|
||||
title-wrapper-class="flex flex-col gap-2 justify-start items-start"
|
||||
:open-by-default="!locked && (openByDefault !== undefined ? openByDefault : true)"
|
||||
>
|
||||
<template #title>
|
||||
<slot name="header" :filter="filterType">
|
||||
<h2>{{ filterType.formatted_name }}</h2>
|
||||
</slot>
|
||||
</template>
|
||||
<template
|
||||
v-if="
|
||||
locked ||
|
||||
(!!accordion &&
|
||||
!accordion.isOpen &&
|
||||
(selectedFilterOptions.length > 0 || selectedNegativeFilterOptions.length > 0))
|
||||
"
|
||||
#summary
|
||||
>
|
||||
<div class="flex gap-1 flex-wrap">
|
||||
<div
|
||||
v-for="option in selectedFilterOptions"
|
||||
:key="`selected-filter-${filterType.id}-${option}`"
|
||||
class="flex gap-1 text-xs bg-button-bg px-2 py-0.5 rounded-full font-bold text-secondary w-fit shrink-0 items-center"
|
||||
>
|
||||
{{ option.formatted_name ?? option.id }}
|
||||
</div>
|
||||
<div
|
||||
v-for="option in selectedNegativeFilterOptions"
|
||||
:key="`excluded-filter-${filterType.id}-${option}`"
|
||||
class="flex gap-1 text-xs bg-button-bg px-2 py-0.5 rounded-full font-bold text-secondary w-fit shrink-0 items-center"
|
||||
>
|
||||
<BanIcon class="text-brand-red" /> {{ option.formatted_name ?? option.id }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="locked" #default>
|
||||
<div class="flex flex-col gap-2 p-3 border-dashed border-2 rounded-2xl border-divider mx-2">
|
||||
<p class="m-0 font-bold items-center">
|
||||
<slot :name="`locked-${filterType.id}`" >
|
||||
{{ formatMessage(messages.lockedTitle, { type: filterType.formatted_name }) }}
|
||||
</slot>
|
||||
</p>
|
||||
<p class="m-0 text-secondary text-sm">
|
||||
{{ formatMessage(messages.lockedDescription) }}
|
||||
</p>
|
||||
<ButtonStyled>
|
||||
<button
|
||||
class="w-fit"
|
||||
@click="
|
||||
() => {
|
||||
overriddenProvidedFilterTypes.push(filterType.id)
|
||||
}
|
||||
"
|
||||
>
|
||||
<LockOpenIcon />
|
||||
{{ formatMessage(messages.unlockFilterButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else #default>
|
||||
<div v-if="filterType.searchable" class="iconified-input mx-2 my-1 !flex">
|
||||
<SearchIcon aria-hidden="true" />
|
||||
<input
|
||||
:id="`search-${filterType.id}`"
|
||||
v-model="query"
|
||||
class="!min-h-9 text-sm"
|
||||
type="text"
|
||||
:placeholder="`Search...`"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<Button v-if="query" class="r-btn" aria-label="Clear search" @click="() => (query = '')">
|
||||
<XIcon aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScrollablePanel :class="{ 'h-[16rem]': scrollable }" :disable-scrolling="!scrollable">
|
||||
<div :class="innerPanelClass ? innerPanelClass : ''" class="flex flex-col gap-1">
|
||||
<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">
|
||||
<div v-if="typeof option.icon === 'string'" class="h-4 w-4" v-html="option.icon" />
|
||||
<component :is="option.icon" v-else-if="option.icon" class="h-4 w-4" />
|
||||
<span class="truncate text-sm">{{ option.formatted_name ?? option.id }}</span>
|
||||
</slot>
|
||||
</SearchFilterOption>
|
||||
<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 ? 'Show fewer' : 'Show more' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</ScrollablePanel>
|
||||
<div :class="innerPanelClass ? innerPanelClass : ''" class="empty:hidden">
|
||||
<Checkbox
|
||||
v-for="group in filterType.toggle_groups"
|
||||
:key="`toggle-group-${group.id}`"
|
||||
class="mx-2"
|
||||
:model-value="groupEnabled(group.id)"
|
||||
:label="`${group.formatted_name}`"
|
||||
@update:model-value="toggleGroup(group.id)"
|
||||
/>
|
||||
<div v-if="hasProvidedFilter" class="mt-2 mx-1">
|
||||
<ButtonStyled>
|
||||
<button
|
||||
class="w-fit"
|
||||
@click="
|
||||
() => {
|
||||
overriddenProvidedFilterTypes = overriddenProvidedFilterTypes.filter(
|
||||
(id) => id !== filterType.id,
|
||||
)
|
||||
accordion?.close()
|
||||
clearFilters()
|
||||
}
|
||||
"
|
||||
>
|
||||
<UpdatedIcon />
|
||||
<slot name="sync-button">
|
||||
{{ formatMessage(messages.syncFilterButton) }}
|
||||
</slot>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Accordion>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Accordion from '../base/Accordion.vue'
|
||||
import type { FilterOption, FilterType, FilterValue } from '../../utils/search'
|
||||
import {
|
||||
BanIcon,
|
||||
SearchIcon,
|
||||
XIcon,
|
||||
UpdatedIcon,
|
||||
LockOpenIcon,
|
||||
DropdownIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { Button, Checkbox, ScrollablePanel } from '../index'
|
||||
import { computed, ref } from 'vue'
|
||||
import ButtonStyled from '../base/ButtonStyled.vue'
|
||||
import SearchFilterOption from './SearchFilterOption.vue'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const selectedFilters = defineModel<FilterValue[]>('selectedFilters', { required: true })
|
||||
const toggledGroups = defineModel<string[]>('toggledGroups', { required: true })
|
||||
const overriddenProvidedFilterTypes = defineModel<string[]>('overriddenProvidedFilterTypes', {
|
||||
required: false,
|
||||
default: [],
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
filterType: FilterType
|
||||
buttonClass?: string
|
||||
contentClass?: string
|
||||
innerPanelClass?: string
|
||||
openByDefault?: boolean
|
||||
providedFilters: FilterValue[]
|
||||
}>()
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const query = ref('')
|
||||
const showMore = ref(false)
|
||||
|
||||
const accordion = ref<InstanceType<typeof Accordion> | null>()
|
||||
|
||||
const selectedFilterOptions = computed(() =>
|
||||
props.filterType.options.filter((option) =>
|
||||
locked.value ? isProvided(option, false) : isIncluded(option),
|
||||
),
|
||||
)
|
||||
const selectedNegativeFilterOptions = computed(() =>
|
||||
props.filterType.options.filter((option) =>
|
||||
locked.value ? isProvided(option, true) : isExcluded(option),
|
||||
),
|
||||
)
|
||||
const visibleOptions = computed(() =>
|
||||
props.filterType.options
|
||||
.filter((option) => isVisible(option) || isIncluded(option) || isExcluded(option))
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
if (props.filterType.display === 'expandable') {
|
||||
const aDefault = props.filterType.default_values.includes(a.id)
|
||||
const bDefault = props.filterType.default_values.includes(b.id)
|
||||
|
||||
if (aDefault && !bDefault) {
|
||||
return -1
|
||||
} else if (!aDefault && bDefault) {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}),
|
||||
)
|
||||
|
||||
const hasProvidedFilter = computed(() =>
|
||||
props.providedFilters.some((filter) => filter.type === props.filterType.id),
|
||||
)
|
||||
const locked = computed(
|
||||
() =>
|
||||
hasProvidedFilter.value && !overriddenProvidedFilterTypes.value.includes(props.filterType.id),
|
||||
)
|
||||
|
||||
const scrollable = computed(
|
||||
() => visibleOptions.value.length >= 10 && props.filterType.display === 'scrollable',
|
||||
)
|
||||
|
||||
function groupEnabled(group: string) {
|
||||
return toggledGroups.value.includes(group)
|
||||
}
|
||||
|
||||
function toggleGroup(group: string) {
|
||||
if (toggledGroups.value.includes(group)) {
|
||||
toggledGroups.value = toggledGroups.value.filter((x) => x !== group)
|
||||
} else {
|
||||
toggledGroups.value.push(group)
|
||||
}
|
||||
}
|
||||
|
||||
function isIncluded(filter: FilterOption) {
|
||||
return selectedFilters.value.some((value) => value.option === filter.id && !value.negative)
|
||||
}
|
||||
|
||||
function isExcluded(filter: FilterOption) {
|
||||
return selectedFilters.value.some((value) => value.option === filter.id && value.negative)
|
||||
}
|
||||
|
||||
function isVisible(filter: FilterOption) {
|
||||
const filterKey = filter.formatted_name?.toLowerCase() ?? filter.id.toLowerCase()
|
||||
const matchesQuery = !query.value || filterKey.includes(query.value.toLowerCase())
|
||||
|
||||
if (props.filterType.display === 'expandable') {
|
||||
return matchesQuery && (showMore.value || props.filterType.default_values.includes(filter.id))
|
||||
}
|
||||
|
||||
if (filter.toggle_group) {
|
||||
return toggledGroups.value.includes(filter.toggle_group) && matchesQuery
|
||||
} else {
|
||||
return matchesQuery
|
||||
}
|
||||
}
|
||||
|
||||
function isProvided(filter: FilterOption, negative: boolean) {
|
||||
return props.providedFilters.some(
|
||||
(x) => x.type === props.filterType.id && x.option === filter.id && !x.negative === !negative,
|
||||
)
|
||||
}
|
||||
|
||||
type FilterState = 'include' | 'exclude' | 'ignore'
|
||||
|
||||
function toggleFilter(filter: FilterOption) {
|
||||
setFilter(filter, isIncluded(filter) || isExcluded(filter) ? 'ignore' : 'include')
|
||||
}
|
||||
|
||||
function toggleNegativeFilter(filter: FilterOption) {
|
||||
setFilter(filter, isExcluded(filter) ? 'ignore' : 'exclude')
|
||||
}
|
||||
|
||||
function setFilter(filter: FilterOption, state: FilterState) {
|
||||
const newFilters = selectedFilters.value.filter((selected) => selected.option !== filter.id)
|
||||
|
||||
const baseValues = {
|
||||
type: props.filterType.id,
|
||||
option: filter.id,
|
||||
}
|
||||
|
||||
if (state === 'include') {
|
||||
newFilters.push({
|
||||
...baseValues,
|
||||
negative: false,
|
||||
})
|
||||
} else if (state === 'exclude') {
|
||||
newFilters.push({
|
||||
...baseValues,
|
||||
negative: true,
|
||||
})
|
||||
}
|
||||
|
||||
selectedFilters.value = newFilters
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
selectedFilters.value = selectedFilters.value.filter(
|
||||
(filter) => filter.type !== props.filterType.id,
|
||||
)
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
unlockFilterButton: {
|
||||
id: 'search.filter.locked.default.unlock',
|
||||
defaultMessage: 'Unlock filter',
|
||||
},
|
||||
syncFilterButton: {
|
||||
id: 'search.filter.locked.default.sync',
|
||||
defaultMessage: 'Sync filter',
|
||||
},
|
||||
lockedTitle: {
|
||||
id: 'search.filter.locked.default.title',
|
||||
defaultMessage: '{type} is locked',
|
||||
},
|
||||
lockedDescription: {
|
||||
id: 'search.filter.locked.default.description',
|
||||
defaultMessage: 'Unlocking this filter may allow you to install incompatible content.',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user