project disclosures frontend (#6955)

* begin project disclosures

* new project settings header

* begin disclosure settings, edit content rules

* togglecards

* update phrasing of the rules, prepr

* more disclosure settings structuring

* add functionality toggle

* implement functionality with staging api

* improve project settings head titles

* feat(labrinth): project disclosures model

* feat(labrinth): project disclosures database model

* feat(labrinth): project disclosures get endpoint

* feat(labrinth): censor user ids if set by moderator

* feat(labrinth): wrap disclosures in struct

* feat(labrinth): edit project disclosures endpoint

* style(labrinth): cargo fmt

* style(labrinth): fix typo

* feat(labrinth): add fields for ai content disclosure

* fix(labrinth): field typo

* chore(labrinth): update query cache

* feat(labrinth): index disclosures in search

* refactor(labrinth): use enum instead of bools

* feat(labrinth): trigger incremental index on disclosure change

* feat(labrinth): change archived status to disclosure

* feat(labrinth): use disclosure for archival status

* fix(labrinth): error type for deserialization

* fix(labrinth): migration timestamp

* fix(labrinth): add disclosures to elasticsearch schema

* disclosures in search

* update photosensitivity warning copy

* update archiving, move general project settings to v3 (start redesign there), use project id lookups for stable cache keys, advanced in server search

* blue archive banner

* display AI use types

* improve labels

* add modrinth to link

* add placeholder missing disclosure report type

* add AI metadata checking to block image uploads with notice

* update copy

* update section 4 of rules

* Update rule 6 layout

* fix(labrinth): don't remove archival disclosure when changing status via v3 api

* feat(labrinth): derive str for ai usages and telemtry consent

* feat(labrinth): include ai usages and telemetry consent in disclosure types

* Update moderation checklist (#7056)

* fix: missing delphi severity (#7054)

* Utils nav, new disclosures stage, ai button in rules

* add prefix to collect

* Finish up new disclosures stage

* update r4 messages

* new r4 msg, update showcase clarity message

* fix: version upload failing to detect mrpack loader (#7063)

* fix: mrpack exporting with zip64 (#7064)

* fix: action bar max width (#7048)

* fix: action bar max width

* fix: width

* changelog

* Rule placeholders with subsections & anchors, update messages.

* update new messages to account for new rule and placeholder layout

* Add nag for content disclosures

---------

Co-authored-by: ThatGravyBoat <gravy@thatgravyboat.tech>
Co-authored-by: chyzman <chyzalt@gmail.com>
Co-authored-by: Truman Gao <106889354+tdgao@users.noreply.github.com>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>

* prepr

* refactor(labrinth): move disclosure parsing to document creation

* fix(labrinth): prevent removing moderator added archival disclosures

* fix(labrinth): dedupe ai usages

* fix(labrinth): auth logic on archived disclosure removal

* feat(labrinth): add interactions field

* style(labrinth): cargo fmt

* feat(labrinth): granular disclosure locking

* add 5.8, move 5.9, update messages and placeholders accordingly.

* move disclosures stage earlier

* include consent model info in telemetry disclosure msgs

* prepr + update date

* typo

* qa pass

* search suboptions

* add empty state

* checkboxes in column

* persistent advanced filters

* blog draft

* prepr

* support soft deletion

* reload instead of optimistically updating

* update blog

* TOS update + minor verbiage adjustment

* 45 day grace

---------

Co-authored-by: sychic <47618543+Sychic@users.noreply.github.com>
Co-authored-by: coolbot <76798835+coolbot100s@users.noreply.github.com>
Co-authored-by: ThatGravyBoat <gravy@thatgravyboat.tech>
Co-authored-by: chyzman <chyzalt@gmail.com>
Co-authored-by: Truman Gao <106889354+tdgao@users.noreply.github.com>
This commit is contained in:
Prospector
2026-08-13 09:01:18 -07:00
committed by GitHub
co-authored by ThatGravyBoat chyzman Truman Gao sychic coolbot
parent 06b7c44dcc
commit 755825b09a
230 changed files with 5802 additions and 1268 deletions
@@ -0,0 +1,75 @@
import { useStorage } from '@vueuse/core'
import type { Ref } from 'vue'
import { type FilterType, type FilterValue, findFilterOption, flattenFilterOptions } from './search'
export const ADVANCED_PREFS_KEY = 'modrinth-advanced-exclusion-filters'
export function useAdvancedPrefs(): Ref<string[]> {
return useStorage<string[]>(ADVANCED_PREFS_KEY, [])
}
export function getAdvancedOptionIds(filters: readonly FilterValue[]): string[] {
return filters
.filter((filter) => filter.type === 'advanced')
.map((filter) => filter.option)
.sort((a, b) => a.localeCompare(b))
}
export function sameOptionIds(a: readonly string[], b: readonly string[]): boolean {
if (a.length !== b.length) {
return false
}
const other = new Set(b)
return a.every((id) => other.has(id))
}
export function getAvailableAdvancedIds(filterTypes: readonly FilterType[]): ReadonlySet<string> {
const advancedType = filterTypes.find((filterType) => filterType.id === 'advanced')
if (!advancedType) {
return new Set()
}
return new Set(flattenFilterOptions(advancedType.options).map((option) => option.id))
}
export function compatibleAdvancedFilters(
prefs: readonly string[],
filterTypes: readonly FilterType[],
): FilterValue[] {
const advancedType = filterTypes.find((filterType) => filterType.id === 'advanced')
if (!advancedType) {
return []
}
return prefs
.filter((id) => findFilterOption(advancedType.options, id))
.map((id) => ({
type: 'advanced',
option: id,
negative: true,
}))
}
export function replaceAdvancedFilters(
filters: readonly FilterValue[],
advancedFilters: readonly FilterValue[],
): FilterValue[] {
return [...filters.filter((filter) => filter.type !== 'advanced'), ...advancedFilters]
}
export function mergeAdvancedPrefs(
prefs: readonly string[],
selected: readonly string[],
available: ReadonlySet<string>,
): string[] {
const selectedSet = new Set(selected)
const next = prefs.filter((id) => !available.has(id) || selectedSet.has(id))
for (const id of selectedSet) {
if (!next.includes(id)) {
next.push(id)
}
}
return next.sort((a, b) => a.localeCompare(b))
}
+92
View File
@@ -1,4 +1,5 @@
import type { Labrinth } from '@modrinth/api-client'
import { capitalizeString } from '@modrinth/utils'
import { defineMessage, defineMessages, type MessageDescriptor } from '../composables/i18n'
@@ -35,6 +36,10 @@ export const commonMessages = defineMessages({
id: 'project-type.all',
defaultMessage: 'All',
},
addAnotherButton: {
id: 'button.add-another',
defaultMessage: 'Add another',
},
addServerToInstanceButton: {
id: 'button.add-server-to-instance',
defaultMessage: 'Add server to instance',
@@ -143,6 +148,10 @@ export const commonMessages = defineMessages({
id: 'notification.error.title',
defaultMessage: 'An error occurred',
},
explanationLabel: {
id: 'label.explanation',
defaultMessage: 'Explanation',
},
filterByLabel: {
id: 'label.filter-by',
defaultMessage: 'Filter by',
@@ -167,6 +176,10 @@ export const commonMessages = defineMessages({
id: 'input.view.grid',
defaultMessage: 'Grid view',
},
iUnderstandButton: {
id: 'button.i-understand',
defaultMessage: 'I understand',
},
listInputView: {
id: 'input.view.list',
defaultMessage: 'Rows view',
@@ -577,6 +590,10 @@ export const commonMessages = defineMessages({
id: 'label.upload-failed',
defaultMessage: 'Upload failed',
},
uploadVersionsEmptyStateDescription: {
id: 'empty-state.upload-versions.description',
defaultMessage: `Come back once you've uploaded your versions.`,
},
renameFailedLabel: {
id: 'label.rename-failed',
defaultMessage: 'Rename failed',
@@ -1007,6 +1024,43 @@ export function formatReportItemType(
return formatMessage(reportItemTypeMessages[key])
}
export const reportTypeMessages = defineMessages({
spam: {
id: 'report.type.spam',
defaultMessage: 'Spam',
},
copyright: {
id: 'report.type.copyright',
defaultMessage: 'Reuploaded work',
},
inappropriate: {
id: 'report.type.inappropriate',
defaultMessage: 'Inappropriate',
},
malicious: {
id: 'report.type.malicious',
defaultMessage: 'Malicious',
},
'name-squatting': {
id: 'report.type.name-squatting',
defaultMessage: 'Name squatting',
},
'missing-disclosure': {
id: 'report.type.missing-disclosure',
defaultMessage: 'Missing or incorrect disclosure',
},
})
export function formatReportType(formatMessage: FormatMessage, type: string | undefined): string {
if (!type) return ''
if (type in reportTypeMessages) {
return formatMessage(reportTypeMessages[type as keyof typeof reportTypeMessages])
}
return capitalizeString(type.replace('-', ' '))
}
export const fileItemTypeMessages = defineMessages({
file: {
id: 'files.item-type.file',
@@ -1094,6 +1148,10 @@ export const commonProjectSettingsMessages = defineMessages({
id: 'project.settings.content.title',
defaultMessage: 'Content',
},
disclosures: {
id: 'project.settings.disclosures.title',
defaultMessage: 'Disclosures',
},
description: {
id: 'project.settings.description.title',
defaultMessage: 'Description',
@@ -1160,6 +1218,40 @@ export const commonProjectSettingsMessages = defineMessages({
},
})
export const disclosureAiUsageMessages = defineMessages({
code: {
id: 'project.settings.disclosures.ai.types-code',
defaultMessage: 'Code',
},
assets: {
id: 'project.settings.disclosures.ai.types-assets',
defaultMessage: 'Assets',
},
text: {
id: 'project.settings.disclosures.ai.types-text',
defaultMessage: 'Text',
},
functionality: {
id: 'project.settings.disclosures.ai.types-functionality',
defaultMessage: 'Functionality',
},
})
export const disclosureTelemetryConsentMessages = defineMessages({
opt_in: {
id: 'project.settings.disclosures.telemetry.consent-opt-in',
defaultMessage: 'Opt-in',
},
opt_out: {
id: 'project.settings.disclosures.telemetry.consent-opt-out',
defaultMessage: 'Opt-out',
},
always_active: {
id: 'project.settings.disclosures.telemetry.consent-always-active',
defaultMessage: 'Always active',
},
})
export const languageSelectorMessages = defineMessages({
platformApp: {
id: 'settings.language.platform.app',
+73
View File
@@ -0,0 +1,73 @@
import type { Labrinth } from '@modrinth/api-client'
export const PROJECT_DISCLOSURE_TYPES = [
'ai_content',
'advertisements',
'epilepsy_triggers',
'system_interactions',
'telemetry',
'derivative_work',
'paid_features',
'archived',
] as const satisfies readonly Labrinth.Projects.v3.ProjectDisclosureType[]
export const AI_USAGE_TYPES = [
'code',
'assets',
'text',
'functionality',
] as const satisfies readonly Labrinth.Projects.v3.AiUsage[]
export const TELEMETRY_CONSENT_TYPES = [
'opt_in',
'opt_out',
'always_active',
] as const satisfies readonly Labrinth.Projects.v3.TelemetryConsent[]
const ALL_CONTENT_PROJECT_TYPES = [
'mod',
'resourcepack',
'datapack',
'shader',
'modpack',
'plugin',
'server',
] as const
export const DISCLOSURE_SUPPORTED_PROJECT_TYPES: Record<
Labrinth.Projects.v3.ProjectDisclosureType,
readonly (typeof ALL_CONTENT_PROJECT_TYPES)[number][]
> = {
ai_content: ALL_CONTENT_PROJECT_TYPES,
advertisements: ALL_CONTENT_PROJECT_TYPES,
epilepsy_triggers: ALL_CONTENT_PROJECT_TYPES,
derivative_work: ALL_CONTENT_PROJECT_TYPES,
paid_features: ALL_CONTENT_PROJECT_TYPES,
archived: ALL_CONTENT_PROJECT_TYPES,
telemetry: ['mod', 'plugin', 'modpack', 'server'],
system_interactions: ['mod', 'plugin', 'modpack'],
}
function normalizeProjectType(type: string): string {
return type === 'minecraft_java_server' ? 'server' : type
}
export function isDisclosureCompatibleWithProjectTypes(
disclosureType: Labrinth.Projects.v3.ProjectDisclosureType,
projectTypes: readonly string[],
): boolean {
const types = projectTypes.map(normalizeProjectType)
return DISCLOSURE_SUPPORTED_PROJECT_TYPES[disclosureType].some((type) => types.includes(type))
}
export function isActiveDisclosure(
disclosure: { deleted_at?: string | null } | null | undefined,
): boolean {
return !!disclosure && disclosure.deleted_at == null
}
export function getActiveDisclosures<T extends { deleted_at?: string | null }>(
disclosures: readonly T[] | null | undefined,
): T[] {
return (disclosures ?? []).filter((disclosure) => isActiveDisclosure(disclosure))
}
+2
View File
@@ -1,6 +1,8 @@
export { createAttributionGroupTitle } from '../components/external_files/external-project-utils'
export * from './advanced-filter-preferences'
export * from './auto-icons'
export * from './common-messages'
export * from './disclosures'
export * from './events'
export * from './file-extensions'
export * from './game-modes'
+4 -4
View File
@@ -1,4 +1,4 @@
import { isEqual } from 'es-toolkit'
import { cloneDeep, isEqual } from 'es-toolkit'
import type { ComputedRef, Ref } from 'vue'
import { computed, ref } from 'vue'
@@ -15,7 +15,7 @@ export function useSavable<T extends Record<string, unknown>>(
save: () => Promise<void>
} {
const savedValues = computed(data)
const currentValues = ref({ ...data() }) as Ref<T>
const currentValues = ref(cloneDeep(data())) as Ref<T>
const saving = ref(false)
const changes = computed<Partial<T>>(() => {
@@ -32,7 +32,7 @@ export function useSavable<T extends Record<string, unknown>>(
const hasChanges = computed(() => Object.keys(changes.value).length > 0)
const reset = () => {
currentValues.value = data()
currentValues.value = cloneDeep(data())
}
const saveInternal = async () => {
@@ -40,7 +40,7 @@ export function useSavable<T extends Record<string, unknown>>(
saving.value = true
try {
await save(changes.value)
currentValues.value = data()
currentValues.value = cloneDeep(data())
} finally {
saving.value = false
}
+231 -43
View File
@@ -1,10 +1,35 @@
import type { Labrinth } from '@modrinth/api-client'
import { ClientIcon, getCategoryIcon, getLoaderIcon, ServerIcon } from '@modrinth/assets'
import {
ArchiveIcon,
CircleDollarSignIcon,
CircuitBoardIcon,
ClientIcon,
EyeIcon,
getCategoryIcon,
getLoaderIcon,
GitForkIcon,
MegaphoneIcon,
RadioTowerIcon,
ServerIcon,
SparklesIcon,
} from '@modrinth/assets'
import { sortedCategories } from '@modrinth/utils'
import { type Component, computed, readonly, type Ref, ref } from 'vue'
import { type LocationQueryRaw, type LocationQueryValue, useRoute } from 'vue-router'
import { defineMessage, useVIntl } from '../composables/i18n'
import { getProjectTypeIcon } from './auto-icons'
import {
disclosureAiUsageMessages,
disclosureTelemetryConsentMessages,
getProjectTypeCategoryMessage,
} from './common-messages'
import {
AI_USAGE_TYPES,
isDisclosureCompatibleWithProjectTypes,
PROJECT_DISCLOSURE_TYPES,
TELEMETRY_CONSENT_TYPES,
} from './disclosures'
import {
DEFAULT_MOD_LOADERS,
DEFAULT_PLUGIN_LOADERS,
@@ -21,6 +46,7 @@ type BaseOption = {
icon?: string | Component
query_value?: string
group?: string
sub_options?: FilterOption[]
}
export type FilterOption = BaseOption &
@@ -29,6 +55,40 @@ export type FilterOption = BaseOption &
| { method: 'environment'; environment: 'client' | 'server' }
)
export function flattenFilterOptions(options: readonly FilterOption[]): FilterOption[] {
return options.flatMap((option) => [
option,
...(option.sub_options ? flattenFilterOptions(option.sub_options) : []),
])
}
export function findFilterOption(
options: readonly FilterOption[],
optionId: string,
): FilterOption | undefined {
for (const option of options) {
if (option.id === optionId) {
return option
}
if (option.sub_options) {
const nested = findFilterOption(option.sub_options, optionId)
if (nested) {
return nested
}
}
}
return undefined
}
export function findParentFilterOption(
options: readonly FilterOption[],
optionId: string,
): FilterOption | undefined {
return options.find((option) =>
option.sub_options?.some((subOption) => subOption.id === optionId),
)
}
export type FilterMode = 'include' | 'exclude'
export type FilterType = {
@@ -120,6 +180,152 @@ const PROJECT_TYPE_EXCLUSION_FILTERS: Partial<Record<ProjectType, ProjectType[]>
datapack: ['mod', 'plugin'],
}
export type DisclosureTypeFilter = Labrinth.Projects.v3.ProjectDisclosureType
const DISCLOSURE_TYPE_ICONS: Record<DisclosureTypeFilter, Component> = {
ai_content: SparklesIcon,
advertisements: MegaphoneIcon,
epilepsy_triggers: EyeIcon,
system_interactions: CircuitBoardIcon,
telemetry: RadioTowerIcon,
derivative_work: GitForkIcon,
paid_features: CircleDollarSignIcon,
archived: ArchiveIcon,
}
function isProjectTypeExclusionOption(optionId: string): optionId is ProjectType {
return (ALL_PROJECT_TYPES as string[]).includes(optionId)
}
type FormatMessage = (
descriptor: { id: string; defaultMessage: string },
values?: Record<string, unknown>,
) => string
export function formatDisclosureTypeLabel(
formatMessage: FormatMessage,
disclosureType: DisclosureTypeFilter,
): string {
switch (disclosureType) {
case 'ai_content':
return formatMessage(
defineMessage({
id: 'search.filter_type.advanced.disclosure.ai_content',
defaultMessage: 'AI-generated content',
}),
)
case 'advertisements':
return formatMessage(
defineMessage({
id: 'search.filter_type.advanced.disclosure.advertisements',
defaultMessage: 'Advertisements',
}),
)
case 'epilepsy_triggers':
return formatMessage(
defineMessage({
id: 'search.filter_type.advanced.disclosure.epilepsy_triggers',
defaultMessage: 'Photosensitivity triggers',
}),
)
case 'system_interactions':
return formatMessage(
defineMessage({
id: 'search.filter_type.advanced.disclosure.system_interactions',
defaultMessage: 'External system interactions',
}),
)
case 'telemetry':
return formatMessage(
defineMessage({
id: 'search.filter_type.advanced.disclosure.telemetry',
defaultMessage: 'Telemetry',
}),
)
case 'derivative_work':
return formatMessage(
defineMessage({
id: 'search.filter_type.advanced.disclosure.derivative_work',
defaultMessage: 'Derivative content',
}),
)
case 'paid_features':
return formatMessage(
defineMessage({
id: 'search.filter_type.advanced.disclosure.paid_features',
defaultMessage: 'Paid features',
}),
)
case 'archived':
return formatMessage(
defineMessage({
id: 'search.filter_type.advanced.disclosure.archived',
defaultMessage: 'Archived',
}),
)
}
}
export function formatAiUsageFilterLabel(
formatMessage: FormatMessage,
usage: Labrinth.Projects.v3.AiUsage,
): string {
return formatMessage(
defineMessage({
id: 'search.filter_type.advanced.disclosure.ai_content.usage',
defaultMessage: 'AI {usage}',
}),
{ usage: formatMessage(disclosureAiUsageMessages[usage]) },
)
}
export function formatTelemetryConsentFilterLabel(
formatMessage: FormatMessage,
consent: Labrinth.Projects.v3.TelemetryConsent,
): string {
return formatMessage(disclosureTelemetryConsentMessages[consent])
}
function createDisclosureSubOptions(
formatMessage: FormatMessage,
disclosureType: DisclosureTypeFilter,
): FilterOption[] | undefined {
switch (disclosureType) {
case 'ai_content':
return AI_USAGE_TYPES.map((usage) => ({
id: `${disclosureType}_${usage}`,
formatted_name: formatAiUsageFilterLabel(formatMessage, usage),
method: 'or' as const,
value: `disclosure_types:${disclosureType}_${usage}`,
}))
case 'telemetry':
return TELEMETRY_CONSENT_TYPES.map((consent) => ({
id: `${disclosureType}_${consent}`,
formatted_name: formatTelemetryConsentFilterLabel(formatMessage, consent),
method: 'or' as const,
value: `disclosure_types:${disclosureType}_${consent}`,
}))
default:
return undefined
}
}
export function createDisclosureFilterOptions(
formatMessage: FormatMessage,
projectTypes: readonly ProjectType[],
): FilterOption[] {
return PROJECT_DISCLOSURE_TYPES.filter((disclosureType) =>
isDisclosureCompatibleWithProjectTypes(disclosureType, projectTypes),
).map((disclosureType) => ({
id: disclosureType,
formatted_name: formatDisclosureTypeLabel(formatMessage, disclosureType),
icon: DISCLOSURE_TYPE_ICONS[disclosureType],
method: 'or' as const,
value: `disclosure_types:${disclosureType}`,
sub_options: createDisclosureSubOptions(formatMessage, disclosureType),
}))
}
export function useSearch(
projectTypes: Ref<ProjectType[]>,
tags: Ref<Tags>,
@@ -151,34 +357,6 @@ export function useSearch(
return formatCategory(formatMessage, categoryName)
}
const formatExcludeProjectTypeLabel = (projectType: ProjectType): string => {
switch (projectType) {
case 'mod':
return formatMessage(
defineMessage({
id: 'search.filter_type.advanced.exclude_mod',
defaultMessage: 'Exclude mods',
}),
)
case 'plugin':
return formatMessage(
defineMessage({
id: 'search.filter_type.advanced.exclude_plugin',
defaultMessage: 'Exclude plugins',
}),
)
case 'datapack':
return formatMessage(
defineMessage({
id: 'search.filter_type.advanced.exclude_datapack',
defaultMessage: 'Exclude data packs',
}),
)
default:
return projectType
}
}
const filters = computed(() => {
const categoryFilters: Record<string, FilterType> = {}
for (const category of sortedCategories(tags.value, formatCategoryName, locale.value)) {
@@ -479,21 +657,25 @@ export function useSearch(
formatted_name: formatMessage(
defineMessage({
id: 'search.filter_type.advanced',
defaultMessage: 'Advanced',
defaultMessage: 'Advanced exclusions',
}),
),
supported_project_types: ['mod', 'plugin', 'datapack'],
supported_project_types: ALL_PROJECT_TYPES,
display: 'all',
query_param: 'a',
supports: ['exclude'],
searchable: false,
ordering: -1000,
options: excludeableProjectTypes.map((target) => ({
id: target,
formatted_name: formatExcludeProjectTypeLabel(target),
method: 'and',
value: `all_project_types:${mapProjectTypeToSearch(target)}`,
})),
options: [
...createDisclosureFilterOptions(formatMessage, projectTypes.value),
...excludeableProjectTypes.map((target) => ({
id: target,
formatted_name: formatMessage(getProjectTypeCategoryMessage(target)),
icon: getProjectTypeIcon(target),
method: 'and' as const,
value: `all_project_types:${mapProjectTypeToSearch(target)}`,
})),
],
},
]
@@ -503,6 +685,7 @@ export function useSearch(
projectTypes.value.includes(projectType),
),
)
.filter((filterType) => filterType.id !== 'advanced' || filterType.options.length > 0)
.sort((a, b) => (b.ordering ?? 0) - (a.ordering ?? 0))
})
@@ -526,10 +709,10 @@ export function useSearch(
console.error(`Filter type ${filterValue.type} not found`)
continue
}
if (type.id === 'advanced') {
if (type.id === 'advanced' && isProjectTypeExclusionOption(filterValue.option)) {
continue
}
let option = type?.options.find((option) => option.id === filterValue.option)
let option = type ? findFilterOption(type.options, filterValue.option) : undefined
if (!option && type.allows_custom_options) {
option = {
id: filterValue.option,
@@ -620,7 +803,10 @@ export function useSearch(
}
const excludedProjectTypes = filterValues
.filter((filterValue) => filterValue.type === 'advanced')
.filter(
(filterValue) =>
filterValue.type === 'advanced' && isProjectTypeExclusionOption(filterValue.option),
)
.map((filterValue) =>
formatSearchFilterValue(mapProjectTypeToSearch(filterValue.option as ProjectType)),
)
@@ -675,7 +861,7 @@ export function useSearch(
const set = typeof filter === 'string' ? new Set([filter]) : new Set(filter)
typesLoop: for (const type of filters.value) {
for (const option of type.options) {
for (const option of flattenFilterOptions(type.options)) {
const value = getOptionValue(option, false)
if (
set.has(value) &&
@@ -734,7 +920,9 @@ export function useSearch(
let matched = false
for (const type of types) {
const option = type.options.find((option) => getOptionValue(option, negative) === value)
const option = flattenFilterOptions(type.options).find(
(option) => getOptionValue(option, negative) === value,
)
if (!option) {
continue
}
@@ -773,7 +961,7 @@ export function useSearch(
currentFilters.value.forEach((filterValue) => {
const type = filters.value.find((type) => type.id === filterValue.type)
const option = type?.options.find((option) => option.id === filterValue.option)
const option = type ? findFilterOption(type.options, filterValue.option) : undefined
if (type && option) {
const value = getOptionValue(option, filterValue.negative)
if (items[type.query_param]) {
+37 -4
View File
@@ -6,7 +6,7 @@ import { useRoute } from 'vue-router'
import { defineMessage, LOCALES, useVIntl } from '../composables/i18n'
import type { FilterType, FilterValue, SortType, Tags } from './search'
import { formatSearchFilterValue } from './search'
import { createDisclosureFilterOptions, findFilterOption, formatSearchFilterValue } from './search'
import { formatCategory, formatCategoryHeader } from './tag-messages'
export const SERVER_REGIONS = {
@@ -339,6 +339,22 @@ export function useServerSearch(opts: {
},
],
},
{
id: 'advanced',
formatted_name: formatMessage(
defineMessage({
id: 'search.filter_type.advanced',
defaultMessage: 'Advanced exclusions',
}),
),
supported_project_types: ['server'],
display: 'all',
query_param: 'a',
supports: ['exclude'],
searchable: false,
ordering: -1000,
options: createDisclosureFilterOptions(formatMessage, ['server']),
},
]
})
@@ -346,11 +362,28 @@ export function useServerSearch(opts: {
const parts = ['project_types = minecraft_java_server']
for (const filterType of serverFilterTypes.value) {
const field = getFilterField(filterType.id)
if (!field) continue
const matched = serverCurrentFilters.value.filter((f) => f.type === filterType.id)
if (matched.length === 0) continue
if (filterType.id === 'advanced') {
const disclosureValues = matched
.map((filterValue) => {
const option = findFilterOption(filterType.options, filterValue.option)
if (!option || !('value' in option)) return null
const [, val] = option.value.split(':')
return val
})
.filter((val): val is string => !!val)
if (disclosureValues.length > 0) {
const quoted = disclosureValues.map(formatSearchFilterValue).join(', ')
parts.push(`disclosure_types NOT IN [${quoted}]`)
}
continue
}
const field = getFilterField(filterType.id)
if (!field) continue
if (filterType.id === 'server_status') {
const selected = matched[0]?.option
if (selected === 'online') {
@@ -442,7 +475,7 @@ export function useServerSearch(opts: {
for (const value of values) {
const isNegative = value.startsWith('!')
const cleanValue = isNegative ? value.slice(1) : value
const option = filterType.options.find((o) => o.id === cleanValue)
const option = findFilterOption(filterType.options, cleanValue)
if (option) {
serverCurrentFilters.value.push({
type: filterType.id,
+16 -4
View File
@@ -1,4 +1,4 @@
import { createTextVNode, isVNode, toDisplayString, type VNode } from 'vue'
import { createTextVNode, createVNode, Fragment, isVNode, toDisplayString, type VNode } from 'vue'
/**
* Checks whether a specific child is a VNode. If not, converts it to a display
@@ -18,8 +18,20 @@ function normalizeChild(child: unknown): VNode {
* that string.
*
* @param children Children to normalize.
* @returns Children with all of non-VNodes converted to display strings.
* @returns A single VNode (or Fragment VNode when there are multiple children).
*/
export function normalizeChildren(children: unknown | unknown[]): VNode[] {
return Array.isArray(children) ? children.map(normalizeChild) : [normalizeChild(children)]
export function normalizeChildren(children: unknown | unknown[]): VNode {
const normalized = Array.isArray(children)
? children.map(normalizeChild)
: [normalizeChild(children)]
if (normalized.length === 0) {
return createTextVNode('')
}
if (normalized.length === 1) {
return normalized[0]!
}
return createVNode(Fragment, null, normalized)
}