mirror of
https://github.com/modrinth/code.git
synced 2026-08-27 18:14:49 +00:00
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:
co-authored by
ThatGravyBoat
chyzman
Truman Gao
sychic
coolbot
parent
06b7c44dcc
commit
755825b09a
@@ -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))
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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]) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user