refactor: migrate to common eslint+prettier configs (#4168)

* refactor: migrate to common eslint+prettier configs

* fix: prettier frontend

* feat: config changes

* fix: lint issues

* fix: lint

* fix: type imports

* fix: cyclical import issue

* fix: lockfile

* fix: missing dep

* fix: switch to tabs

* fix: continue switch to tabs

* fix: rustfmt parity

* fix: moderation lint issue

* fix: lint issues

* fix: ui intl

* fix: lint issues

* Revert "fix: rustfmt parity"

This reverts commit cb99d2376c321d813d4b7fc7e2a213bb30a54711.

* feat: revert last rs
This commit is contained in:
Cal H.
2025-08-14 21:48:38 +01:00
committed by GitHub
parent 82697278dc
commit 2aabcf36ee
702 changed files with 101360 additions and 102020 deletions

View File

@@ -1,106 +1,107 @@
<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>
<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'
import Accordion from '../base/Accordion.vue'
interface FilterOption<T> {
id: string
formatted_name: string
data: T
id: string
formatted_name: string
data: T
}
interface FilterType<T> {
id: string
formatted_name: string
scrollable?: boolean
options: FilterOption<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
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
name: string
icon: string
supported_project_types: ProjectType[]
default: boolean
formatted_name: string
}
const props = defineProps<{
buttonClass?: string
contentClass?: string
gameVersions?: GameVersion[]
platforms: Platform[]
buttonClass?: string
contentClass?: string
gameVersions?: GameVersion[]
platforms: Platform[]
}>()
const filters = computed(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
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,
})) || [],
},
]
// eslint-disable-next-line @typescript-eslint/no-explicit-any
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
return filters
})
defineOptions({
inheritAttrs: false,
inheritAttrs: false,
})
</script>

View File

@@ -1,46 +1,46 @@
<template>
<div class="categories">
<slot />
<span
v-for="category in categories"
:key="category.name"
v-html="category.icon + formatCategory(category.name)"
/>
</div>
<div class="categories">
<slot />
<span
v-for="category in categories"
:key="category.name"
v-html="category.icon + formatCategory(category.name)"
/>
</div>
</template>
<script setup>
import { formatCategory } from '@modrinth/utils'
defineProps({
categories: {
type: Array,
default() {
return []
},
},
categories: {
type: Array,
default() {
return []
},
},
})
</script>
<style lang="scss" scoped>
.categories {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: var(--gap-sm);
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: var(--gap-sm);
:deep(span) {
display: flex;
flex-direction: row;
align-items: center;
:deep(span) {
display: flex;
flex-direction: row;
align-items: center;
&:not(.version-badge) {
color: var(--color-gray);
}
&:not(.version-badge) {
color: var(--color-gray);
}
svg {
width: 1rem;
margin-right: 0.2rem;
}
}
svg {
width: 1rem;
margin-right: 0.2rem;
}
}
}
</style>

View File

@@ -1,113 +1,114 @@
<template>
<div
ref="dropdown"
tabindex="0"
role="combobox"
:aria-expanded="dropdownVisible"
class="animated-dropdown"
@keydown.up.prevent="focusPreviousOption"
@keydown.down.prevent="focusNextOptionOrOpen"
>
<div class="iconified-input">
<SearchIcon />
<input
:value="modelValue"
type="text"
:name="name"
:disabled="disabled"
class="text-input"
autocomplete="off"
autocapitalize="off"
:placeholder="placeholder"
:class="{ down: !renderUp, up: renderUp }"
@input="$emit('update:modelValue', $event.target.value)"
@focus="onFocus"
@blur="onBlur"
@focusout="onBlur"
@keydown.enter.prevent="$emit('enter')"
/>
<Button :disabled="disabled" class="r-btn" @click="() => $emit('update:modelValue', '')">
<XIcon />
</Button>
</div>
<div ref="dropdownOptions" class="options-wrapper" :class="{ down: !renderUp, up: renderUp }">
<transition name="options">
<div
v-show="dropdownVisible"
class="options"
role="listbox"
:class="{ down: !renderUp, up: renderUp }"
>
<div
v-for="(option, index) in options"
:key="index"
ref="optionElements"
tabindex="-1"
role="option"
class="option"
@click="selectOption(option)"
>
<div class="project-label">
<Avatar :src="option.icon" :circle="circledIcons" />
<div class="text">
<div class="title">
{{ getOptionLabel(option.title) }}
</div>
<div class="author">
{{ getOptionLabel(option.subtitle) }}
</div>
</div>
</div>
</div>
</div>
</transition>
</div>
</div>
<div
ref="dropdown"
tabindex="0"
role="combobox"
:aria-expanded="dropdownVisible"
class="animated-dropdown"
@keydown.up.prevent="focusPreviousOption"
@keydown.down.prevent="focusNextOptionOrOpen"
>
<div class="iconified-input">
<SearchIcon />
<input
:value="modelValue"
type="text"
:name="name"
:disabled="disabled"
class="text-input"
autocomplete="off"
autocapitalize="off"
:placeholder="placeholder"
:class="{ down: !renderUp, up: renderUp }"
@input="$emit('update:modelValue', $event.target.value)"
@focus="onFocus"
@blur="onBlur"
@focusout="onBlur"
@keydown.enter.prevent="$emit('enter')"
/>
<Button :disabled="disabled" class="r-btn" @click="() => $emit('update:modelValue', '')">
<XIcon />
</Button>
</div>
<div ref="dropdownOptions" class="options-wrapper" :class="{ down: !renderUp, up: renderUp }">
<transition name="options">
<div
v-show="dropdownVisible"
class="options"
role="listbox"
:class="{ down: !renderUp, up: renderUp }"
>
<div
v-for="(option, index) in options"
:key="index"
ref="optionElements"
tabindex="-1"
role="option"
class="option"
@click="selectOption(option)"
>
<div class="project-label">
<Avatar :src="option.icon" :circle="circledIcons" />
<div class="text">
<div class="title">
{{ getOptionLabel(option.title) }}
</div>
<div class="author">
{{ getOptionLabel(option.subtitle) }}
</div>
</div>
</div>
</div>
</div>
</transition>
</div>
</div>
</template>
<script setup>
import { SearchIcon, XIcon } from '@modrinth/assets'
import { ref } from 'vue'
import { XIcon, SearchIcon } from '@modrinth/assets'
import Avatar from '../base/Avatar.vue'
import Button from '../base/Button.vue'
const props = defineProps({
options: {
type: Array,
required: true,
},
name: {
type: String,
required: true,
},
placeholder: {
type: [String, Number],
default: null,
},
modelValue: {
type: [String, Number, Object],
default: null,
},
renderUp: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
displayName: {
type: Function,
default: undefined,
},
circledIcons: {
type: Boolean,
default: false,
},
options: {
type: Array,
required: true,
},
name: {
type: String,
required: true,
},
placeholder: {
type: [String, Number],
default: null,
},
modelValue: {
type: [String, Number, Object],
default: null,
},
renderUp: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
displayName: {
type: Function,
default: undefined,
},
circledIcons: {
type: Boolean,
default: false,
},
})
function getOptionLabel(option) {
return props.displayName?.(option) ?? option
return props.displayName?.(option) ?? option
}
const emit = defineEmits(['input', 'onSelected', 'update:modelValue', 'enter'])
@@ -119,227 +120,227 @@ const optionElements = ref(null)
const dropdownOptions = ref(null)
const toggleDropdown = () => {
if (!props.disabled) {
dropdownVisible.value = !dropdownVisible.value
dropdown.value.focus()
}
if (!props.disabled) {
dropdownVisible.value = !dropdownVisible.value
dropdown.value.focus()
}
}
const selectOption = (option) => {
emit('onSelected', option)
console.log('onSelected', option)
dropdownVisible.value = false
emit('onSelected', option)
console.log('onSelected', option)
dropdownVisible.value = false
}
const onFocus = () => {
if (!props.disabled) {
focusedOptionIndex.value = props.options.findIndex(
(option) => option === props.modelValue.value,
)
dropdownVisible.value = true
}
if (!props.disabled) {
focusedOptionIndex.value = props.options.findIndex(
(option) => option === props.modelValue.value,
)
dropdownVisible.value = true
}
}
const onBlur = (event) => {
console.log(event)
if (!isChildOfDropdown(event.relatedTarget)) {
dropdownVisible.value = false
}
console.log(event)
if (!isChildOfDropdown(event.relatedTarget)) {
dropdownVisible.value = false
}
}
const focusPreviousOption = () => {
if (!props.disabled) {
if (!dropdownVisible.value) {
toggleDropdown()
}
focusedOptionIndex.value =
(focusedOptionIndex.value + props.options.length - 1) % props.options.length
optionElements.value[focusedOptionIndex.value].focus()
}
if (!props.disabled) {
if (!dropdownVisible.value) {
toggleDropdown()
}
focusedOptionIndex.value =
(focusedOptionIndex.value + props.options.length - 1) % props.options.length
optionElements.value[focusedOptionIndex.value].focus()
}
}
const focusNextOptionOrOpen = () => {
if (!props.disabled) {
if (!dropdownVisible.value) {
toggleDropdown()
}
focusedOptionIndex.value = (focusedOptionIndex.value + 1) % props.options.length
optionElements.value[focusedOptionIndex.value].focus()
}
if (!props.disabled) {
if (!dropdownVisible.value) {
toggleDropdown()
}
focusedOptionIndex.value = (focusedOptionIndex.value + 1) % props.options.length
optionElements.value[focusedOptionIndex.value].focus()
}
}
const isChildOfDropdown = (element) => {
let currentNode = element
while (currentNode) {
if (currentNode === dropdownOptions.value) {
return true
}
currentNode = currentNode.parentNode
}
return false
let currentNode = element
while (currentNode) {
if (currentNode === dropdownOptions.value) {
return true
}
currentNode = currentNode.parentNode
}
return false
}
</script>
<style lang="scss" scoped>
.animated-dropdown {
width: 20rem;
height: 2.5rem;
position: relative;
display: inline-block;
width: 20rem;
height: 2.5rem;
position: relative;
display: inline-block;
&:focus {
outline: 0;
}
&:focus {
outline: 0;
}
.selected {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--gap-sm) var(--gap-lg);
background-color: var(--color-button-bg);
gap: var(--gap-md);
cursor: pointer;
user-select: none;
border-radius: var(--radius-md);
box-shadow:
var(--shadow-inset-sm),
0 0 0 0 transparent;
.selected {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--gap-sm) var(--gap-lg);
background-color: var(--color-button-bg);
gap: var(--gap-md);
cursor: pointer;
user-select: none;
border-radius: var(--radius-md);
box-shadow:
var(--shadow-inset-sm),
0 0 0 0 transparent;
&.disabled {
cursor: not-allowed;
filter: grayscale(50%);
opacity: 0.5;
}
&.disabled {
cursor: not-allowed;
filter: grayscale(50%);
opacity: 0.5;
}
&.render-up {
border-radius: 0 0 var(--radius-md) var(--radius-md);
}
&.render-up {
border-radius: 0 0 var(--radius-md) var(--radius-md);
}
&.render-down {
border-radius: var(--radius-md) var(--radius-md) 0 0;
}
&.render-down {
border-radius: var(--radius-md) var(--radius-md) 0 0;
}
&:focus {
outline: 0;
filter: brightness(1.25);
transition: filter 0.1s ease-in-out;
}
}
&:focus {
outline: 0;
filter: brightness(1.25);
transition: filter 0.1s ease-in-out;
}
}
.options {
z-index: 10;
max-height: 18rem;
overflow-y: auto;
.options {
z-index: 10;
max-height: 18rem;
overflow-y: auto;
.option {
background-color: var(--color-button-bg);
display: flex;
align-items: center;
padding: var(--gap-md);
cursor: pointer;
user-select: none;
.option {
background-color: var(--color-button-bg);
display: flex;
align-items: center;
padding: var(--gap-md);
cursor: pointer;
user-select: none;
&:hover {
filter: brightness(0.85);
transition: filter 0.2s ease-in-out;
}
&:hover {
filter: brightness(0.85);
transition: filter 0.2s ease-in-out;
}
&:focus {
outline: 0;
filter: brightness(0.85);
transition: filter 0.2s ease-in-out;
}
&:focus {
outline: 0;
filter: brightness(0.85);
transition: filter 0.2s ease-in-out;
}
&.selected-option {
background-color: var(--color-brand);
color: var(--color-accent-contrast);
font-weight: bolder;
}
&.selected-option {
background-color: var(--color-brand);
color: var(--color-accent-contrast);
font-weight: bolder;
}
input {
display: none;
}
}
}
input {
display: none;
}
}
}
}
.options-enter-active,
.options-leave-active {
transition: transform 0.2s ease;
transition: transform 0.2s ease;
}
.options-enter-from,
.options-leave-to {
// this is not 100% due to a safari bug
&.up {
transform: translateY(99.999%);
}
// this is not 100% due to a safari bug
&.up {
transform: translateY(99.999%);
}
&.down {
transform: translateY(-99.999%);
}
&.down {
transform: translateY(-99.999%);
}
}
.options-enter-to,
.options-leave-from {
&.up {
transform: translateY(0%);
}
&.up {
transform: translateY(0%);
}
}
.options-wrapper {
position: absolute;
width: 100%;
overflow: auto;
z-index: 9;
position: absolute;
width: 100%;
overflow: auto;
z-index: 9;
&.up {
top: 0;
transform: translateY(-99.999%);
border-radius: var(--radius-md) var(--radius-md) 0 0;
}
&.up {
top: 0;
transform: translateY(-99.999%);
border-radius: var(--radius-md) var(--radius-md) 0 0;
}
&.down {
border-radius: 0 0 var(--radius-md) var(--radius-md);
}
&.down {
border-radius: 0 0 var(--radius-md) var(--radius-md);
}
}
.project-label {
display: flex;
align-items: center;
flex-direction: row;
gap: var(--gap-md);
color: var(--color-contrast);
display: flex;
align-items: center;
flex-direction: row;
gap: var(--gap-md);
color: var(--color-contrast);
.title {
font-weight: bold;
}
.title {
font-weight: bold;
}
}
.iconified-input {
width: 100%;
width: 100%;
}
.text-input {
box-shadow:
var(--shadow-inset-sm),
0 0 0 0 transparent !important;
width: 100%;
box-shadow:
var(--shadow-inset-sm),
0 0 0 0 transparent !important;
width: 100%;
transition: 0.05s;
transition: 0.05s;
&:focus {
&.down {
border-radius: var(--radius-md) var(--radius-md) 0 0 !important;
}
&:focus {
&.down {
border-radius: var(--radius-md) var(--radius-md) 0 0 !important;
}
&.up {
border-radius: 0 0 var(--radius-md) var(--radius-md) !important;
}
}
&.up {
border-radius: 0 0 var(--radius-md) var(--radius-md) !important;
}
}
&:not(:focus) {
transition-delay: 0.2s;
}
&:not(:focus) {
transition-delay: 0.2s;
}
}
</style>

View File

@@ -1,74 +1,75 @@
<template>
<Checkbox
class="filter"
:model-value="isActive"
:description="displayName"
@update:model-value="toggle"
>
<div class="filter-text">
<div v-if="props.icon" aria-hidden="true" class="icon" v-html="props.icon" />
<div v-else class="icon">
<slot />
</div>
<span aria-hidden="true"> {{ props.displayName }}</span>
</div>
</Checkbox>
<Checkbox
class="filter"
:model-value="isActive"
:description="displayName"
@update:model-value="toggle"
>
<div class="filter-text">
<div v-if="props.icon" aria-hidden="true" class="icon" v-html="props.icon" />
<div v-else class="icon">
<slot />
</div>
<span aria-hidden="true"> {{ props.displayName }}</span>
</div>
</Checkbox>
</template>
<script setup>
import { computed } from 'vue'
import Checkbox from '../base/Checkbox.vue'
const props = defineProps({
facetName: {
type: String,
default: '',
},
displayName: {
type: String,
default: '',
},
icon: {
type: String,
default: '',
},
activeFilters: {
type: Array,
default() {
return []
},
},
facetName: {
type: String,
default: '',
},
displayName: {
type: String,
default: '',
},
icon: {
type: String,
default: '',
},
activeFilters: {
type: Array,
default() {
return []
},
},
})
const isActive = computed(() => props.activeFilters.includes(props.facetName))
const emit = defineEmits(['toggle'])
const toggle = () => {
emit('toggle', props.facetName)
emit('toggle', props.facetName)
}
</script>
<style lang="scss" scoped>
.filter {
margin-bottom: 0.5rem;
margin-bottom: 0.5rem;
:deep(.filter-text) {
display: flex;
align-items: center;
:deep(.filter-text) {
display: flex;
align-items: center;
.icon {
height: 1rem;
.icon {
height: 1rem;
svg {
margin-right: 0.25rem;
width: 1rem;
height: 1rem;
}
}
}
svg {
margin-right: 0.25rem;
width: 1rem;
height: 1rem;
}
}
}
span {
user-select: none;
}
span {
user-select: none;
}
}
</style>

View File

@@ -1,97 +1,98 @@
<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>
<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 { BanIcon, LockIcon, XCircleIcon, XIcon } from '@modrinth/assets'
import { defineMessage, type MessageDescriptor, useVIntl } from '@vintl/vintl'
import { computed, type ComputedRef } from 'vue'
import type { FilterOption, FilterType, FilterValue } from '../../utils/search'
import TagItem from '../base/TagItem.vue'
const { formatMessage } = useVIntl()
const selectedFilters = defineModel<FilterValue[]>('selectedFilters', { required: true })
const props = defineProps<{
filters: FilterType[]
providedFilters: FilterValue[]
overriddenProvidedFilterTypes: string[]
providedMessage?: MessageDescriptor
filters: FilterType[]
providedFilters: FilterValue[]
overriddenProvidedFilterTypes: string[]
providedMessage?: MessageDescriptor
}>()
const defaultProvidedMessage = defineMessage({
id: 'search.filter.locked.default',
defaultMessage: 'Filter locked',
id: 'search.filter.locked.default',
defaultMessage: 'Filter locked',
})
type Item = {
type: string
option: string
negative?: boolean
formatted_name?: string
provided: boolean
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)
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,
})),
)
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,
)
selectedFilters.value = selectedFilters.value.filter(
(x) => x.type !== filter.type || x.option !== filter.option,
)
}
async function clearFilters() {
selectedFilters.value = []
selectedFilters.value = []
}
</script>

View File

@@ -1,64 +1,65 @@
<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>
<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,
},
defineProps<{
option: FilterOption
included: boolean
excluded: boolean
supportsNegativeFilter?: boolean
}>(),
{
supportsNegativeFilter: false,
},
)
const emit = defineEmits<{
toggle: [option: FilterOption]
toggleExclude: [option: FilterOption]
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;
}
button,
.filter-action-icon {
opacity: 1;
}
}
</style>

View File

@@ -1,190 +1,191 @@
<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>
<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>
<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,
BanIcon,
DropdownIcon,
LockOpenIcon,
SearchIcon,
UpdatedIcon,
XIcon,
} 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'
import { computed, ref } from 'vue'
import type { FilterOption, FilterType, FilterValue } from '../../utils/search'
import Accordion from '../base/Accordion.vue'
import ButtonStyled from '../base/ButtonStyled.vue'
import { Button, Checkbox, ScrollablePanel } from '../index'
import SearchFilterOption from './SearchFilterOption.vue'
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: [],
required: false,
default: [],
})
const props = defineProps<{
filterType: FilterType
buttonClass?: string
contentClass?: string
innerPanelClass?: string
openByDefault?: boolean
providedFilters: FilterValue[]
filterType: FilterType
buttonClass?: string
contentClass?: string
innerPanelClass?: string
openByDefault?: boolean
providedFilters: FilterValue[]
}>()
defineOptions({
inheritAttrs: false,
inheritAttrs: false,
})
const query = ref('')
@@ -193,142 +194,142 @@ 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),
),
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),
),
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)
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
}),
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),
props.providedFilters.some((filter) => filter.type === props.filterType.id),
)
const locked = computed(
() =>
hasProvidedFilter.value && !overriddenProvidedFilterTypes.value.includes(props.filterType.id),
() =>
hasProvidedFilter.value && !overriddenProvidedFilterTypes.value.includes(props.filterType.id),
)
const scrollable = computed(
() => visibleOptions.value.length >= 10 && props.filterType.display === 'scrollable',
() => visibleOptions.value.length >= 10 && props.filterType.display === 'scrollable',
)
function groupEnabled(group: string) {
return toggledGroups.value.includes(group)
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)
}
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)
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)
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())
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 (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
}
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,
)
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')
setFilter(filter, isIncluded(filter) || isExcluded(filter) ? 'ignore' : 'include')
}
function toggleNegativeFilter(filter: FilterOption) {
setFilter(filter, isExcluded(filter) ? 'ignore' : 'exclude')
setFilter(filter, isExcluded(filter) ? 'ignore' : 'exclude')
}
function setFilter(filter: FilterOption, state: FilterState) {
const newFilters = selectedFilters.value.filter((selected) => selected.option !== filter.id)
const newFilters = selectedFilters.value.filter((selected) => selected.option !== filter.id)
const baseValues = {
type: props.filterType.id,
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,
})
}
if (state === 'include') {
newFilters.push({
...baseValues,
negative: false,
})
} else if (state === 'exclude') {
newFilters.push({
...baseValues,
negative: true,
})
}
selectedFilters.value = newFilters
selectedFilters.value = newFilters
}
function clearFilters() {
selectedFilters.value = selectedFilters.value.filter(
(filter) => filter.type !== props.filterType.id,
)
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.',
},
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>