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
@@ -1,7 +1,8 @@
<template>
<div
:class="[
'relative grid grid-cols-[1.5rem_minmax(0,1fr)_auto] items-start gap-x-2 rounded-2xl border border-solid p-4 text-contrast',
'relative grid grid-cols-[1.5rem_minmax(0,1fr)_auto] gap-x-2 rounded-2xl border border-solid p-4 text-contrast',
centerContent ? 'items-center' : 'items-start',
progress != null ? 'overflow-hidden pb-5' : '',
typeClasses[type],
]"
@@ -13,7 +14,7 @@
class="col-start-2 min-w-0"
:class="
inlineActions && !showActionsUnderneath && $slots.actions
? 'flex flex-wrap items-start gap-x-4 gap-y-3'
? ['flex flex-wrap gap-x-4 gap-y-3', centerContent ? 'items-center' : 'items-start']
: 'flex flex-1 flex-col gap-2'
"
>
@@ -39,7 +40,7 @@
{{ relativeTimeLabel }}
</span>
</div>
<div class="font-normal text-contrast/85 leading-tight">
<div v-if="!!$slots.default || body" class="font-normal text-contrast/85 leading-tight">
<slot>{{ body }}</slot>
</div>
</div>
@@ -119,6 +120,7 @@ const props = withDefaults(
waiting?: boolean
/** Accepts a Date, an ISO string, or a millisecond Unix timestamp. */
timestamp?: Date | string | number
centerContent?: boolean
}>(),
{
type: 'info',
@@ -131,6 +133,7 @@ const props = withDefaults(
progressColor: undefined,
waiting: false,
timestamp: undefined,
centerContent: false,
},
)
@@ -1,11 +1,20 @@
<script setup lang="ts">
import IntlMessageFormat, { type FormatXMLElementFn, type PrimitiveType } from 'intl-messageformat'
import { computed, markRaw, useSlots, type VNode } from 'vue'
import { computed, defineComponent, markRaw, type PropType, useSlots, type VNode } from 'vue'
import type { MessageDescriptor } from '../../composables/i18n'
import { injectI18nDebug } from '../../composables/i18n-debug'
import { injectI18n } from '../../providers/i18n'
const VNodeRenderer = defineComponent({
props: {
node: { type: Object as PropType<VNode>, required: true },
},
setup(props) {
return () => props.node
},
})
const props = defineProps<{
messageId: MessageDescriptor
values?: Record<string, PrimitiveType>
@@ -49,11 +58,13 @@ const formattedParts = computed(() => {
slotHandlers[normalizedName] = (chunks) => {
const slot = slots[slotName]
if (slot) {
return markRaw(
slot({
children: chunks,
}),
) as VNode[]
const nodes = slot({
children: chunks,
})
if (Array.isArray(nodes) && nodes.length === 1) {
return markRaw(nodes[0]) as VNode
}
return markRaw(nodes) as VNode[]
}
return markRaw(chunks) as VNode[]
}
@@ -71,33 +82,35 @@ const formattedParts = computed(() => {
...slotHandlers,
})
// ensure result array items are marked as raw if they're VNodes
// prevents VNodes from entering the reactive system and SSR payload
if (Array.isArray(result)) {
return result.map((part) =>
typeof part === 'object' && part !== null ? markRaw(part) : part,
)
}
return [typeof result === 'object' && result !== null ? markRaw(result) : result]
return toFormattedParts(result)
} catch {
return [msg]
}
})
function toFormattedParts(value: unknown): unknown[] {
if (Array.isArray(value)) {
return value.flatMap(toFormattedParts)
}
if (typeof value === 'object' && value !== null) {
return [markRaw(value)]
}
return [value]
}
function isVNodePart(part: unknown): part is VNode {
return typeof part === 'object' && part !== null
}
</script>
<template>
<span
v-if="debugEnabled && !debugKeyReveal"
:data-i18n-key="messageId.id"
style="display: contents"
:data-i18n-key="debugEnabled && !debugKeyReveal ? messageId.id : undefined"
>
<template v-for="(part, index) in formattedParts" :key="index">
<component :is="() => part" v-if="typeof part === 'object'" />
<VNodeRenderer v-if="isVNodePart(part)" :node="part" />
<template v-else>{{ part }}</template>
</template>
</span>
<template v-for="(part, index) in formattedParts" v-else :key="index">
<component :is="() => part" v-if="typeof part === 'object'" />
<template v-else>{{ part }}</template>
</template>
</template>
@@ -0,0 +1,53 @@
<script setup lang="ts">
import { defineMessage, useVIntl } from '../../composables/i18n'
const { formatMessage } = useVIntl()
withDefaults(
defineProps<{
title?: string
titleFor?: string
description?: string
optional?: boolean
}>(),
{
optional: false,
},
)
const optionalMessage = defineMessage({
id: 'settings.form.group.optional',
defaultMessage: '(optional)',
})
</script>
<template>
<div class="flex flex-col gap-2">
<div
v-if="title || description || $slots.title || $slots.description"
:class="description || $slots.description ? 'flex flex-col gap-1' : undefined"
>
<label
v-if="title && titleFor"
:for="titleFor"
class="m-0 leading-normal text-contrast font-semibold"
>
{{ title }}
<span v-if="optional" class="text-secondary font-normal">
{{ formatMessage(optionalMessage) }}
</span>
</label>
<p v-else-if="title" class="m-0 leading-normal text-contrast font-semibold">
{{ title }}
<span v-if="optional" class="text-secondary font-normal">
{{ formatMessage(optionalMessage) }}
</span>
</p>
<slot name="title" />
<p v-if="description" class="m-0 leading-normal text-primary">
{{ description }}
</p>
<slot name="description" />
</div>
<slot />
</div>
</template>
@@ -0,0 +1,12 @@
<script setup lang="ts">
import { TriangleAlertIcon } from '@modrinth/assets'
</script>
<template>
<div class="flex flex-wrap items-center gap-1.5 text-orange">
<TriangleAlertIcon aria-hidden="true" class="size-4 shrink-0" />
<div class="min-w-0">
<slot />
</div>
</div>
</template>
@@ -0,0 +1,32 @@
<script setup lang="ts">
import type { Component } from 'vue'
defineProps<{
title: string
icon?: Component
description?: string
}>()
</script>
<template>
<div
class="grid w-full grid-cols-[1fr_auto] items-center gap-6 p-4 rounded-2xl border border-solid border-surface-4 bg-surface-3"
>
<div>
<h3 class="mb-1 mt-0 flex items-center gap-2 text-lg font-semibold text-contrast">
<component :is="icon" v-if="icon" class="size-5 shrink-0 text-primary" aria-hidden="true" />
{{ title }}
</h3>
<div
v-if="description || $slots.default"
class="flex flex-col gap-2 leading-normal [&>p]:m-0"
>
<p v-if="description">{{ description }}</p>
<slot />
</div>
</div>
<div>
<slot name="actions" />
</div>
</div>
</template>
@@ -0,0 +1,36 @@
<script setup lang="ts">
import type { Component } from 'vue'
import ToggleCard from './ToggleCard.vue'
defineProps<{
title: string
icon?: Component
description?: string
disabled?: boolean
toggleDisabled?: boolean
}>()
const enabled = defineModel<boolean>({ required: true })
</script>
<template>
<ToggleCard v-model="enabled" :disabled="disabled" :toggle-disabled="toggleDisabled">
<h3 class="mb-1 mt-0 flex items-center gap-2 text-lg font-semibold text-contrast">
<component :is="icon" v-if="icon" class="size-5 shrink-0 text-primary" aria-hidden="true" />
{{ title }}
</h3>
<div v-if="description || $slots.default" class="flex flex-col gap-2 leading-normal [&>p]:m-0">
<p v-if="description">{{ description }}</p>
<slot />
</div>
<template v-if="$slots.expanded" #expanded>
<div class="flex flex-col gap-4">
<slot name="expanded" />
</div>
</template>
<template v-if="$slots.footer" #footer>
<slot name="footer" />
</template>
</ToggleCard>
</template>
@@ -5,7 +5,8 @@
v-bind="$attrs"
class="smart-clickable__contents"
:class="{
'pointer-events-none': !!$slots.clickable,
'smart-clickable__contents--disabled': disabled,
'pointer-events-none': !!$slots.clickable && !disabled,
}"
>
<slot />
@@ -14,6 +15,10 @@
</template>
<script setup lang="ts">
defineProps<{
disabled?: boolean
}>()
defineOptions({
inheritAttrs: false,
})
@@ -49,8 +54,7 @@ defineOptions({
// When clickable is being hovered or focus-visible, give contents an effect
:first-child:hover + .smart-clickable__contents,
:first-child:focus-visible + .smart-clickable__contents,
.smart-clickable__contents:hover,
.smart-clickable__contents:focus-within {
.smart-clickable__contents:hover {
// Utility classes for contents
:deep(.smart-clickable\:underline-on-hover) {
text-decoration: underline;
@@ -78,8 +82,8 @@ defineOptions({
}
// When clickable is being clicked, give contents an effect
:first-child:active + .smart-clickable__contents {
scale: 0.97;
:first-child:active + .smart-clickable__contents:not(.smart-clickable__contents--disabled) {
scale: 0.98;
}
}
</style>
@@ -0,0 +1,85 @@
<script setup lang="ts">
import { SmartClickable, Toggle } from '@modrinth/ui'
import { computed, useId } from 'vue'
const props = defineProps<{
disabled?: boolean
toggleDisabled?: boolean
}>()
const value = defineModel<boolean>({ required: true })
const baseId = useId()
const toggleId = computed(() => `toggle-card-toggle-${baseId}`)
const isToggleLocked = computed(() => !!props.disabled || (!!props.toggleDisabled && value.value))
function toggle() {
if (isToggleLocked.value) return
value.value = !value.value
}
</script>
<template>
<SmartClickable
class="flex w-full flex-col overflow-clip rounded-2xl border border-solid border-surface-4 bg-surface-3"
:disabled="isToggleLocked"
>
<template #clickable>
<button
aria-hidden="true"
tabindex="-1"
:disabled="isToggleLocked"
class="flex h-full w-full"
:class="isToggleLocked ? 'cursor-not-allowed' : 'cursor-pointer'"
@click="toggle"
/>
</template>
<div class="grid w-full grid-cols-[1fr_auto] items-center gap-6 p-4">
<div>
<slot :toggle-id="toggleId" />
</div>
<div>
<slot name="toggle" :disabled="isToggleLocked">
<Toggle
:id="toggleId"
v-model="value"
:disabled="isToggleLocked"
class="smart-clickable:allow-pointer-events"
/>
</slot>
</div>
</div>
<Transition name="toggle-card-content">
<div v-if="value && $slots.expanded" class="smart-clickable:allow-pointer-events">
<div>
<div class="border-0 border-t border-solid border-surface-4 bg-surface-2 p-4">
<slot name="expanded" />
</div>
</div>
</div>
</Transition>
<div
v-if="$slots.footer"
class="smart-clickable:allow-pointer-events border-0 border-t bg-surface-3 border-solid border-surface-4 px-4 py-3"
>
<slot name="footer" />
</div>
</SmartClickable>
</template>
<style scoped>
.toggle-card-content-enter-active,
.toggle-card-content-leave-active {
display: grid;
grid-template-rows: 1fr;
transition: grid-template-rows 0.25s var(--ease-out-expo);
& > div {
grid-row: 1 / span 2;
}
}
.toggle-card-content-enter-from,
.toggle-card-content-leave-to {
grid-template-rows: 0fr;
}
</style>
@@ -48,8 +48,10 @@ const props = withDefaults(
},
)
const shown = computed(() =>
Object.keys(props.modified).some((key) => !isEqual(props.original[key], props.modified[key])),
const shown = computed(
() =>
props.saving ||
Object.keys(props.modified).some((key) => !isEqual(props.original[key], props.modified[key])),
)
function localizeIfPossible(message: MessageDescriptor | string) {
@@ -45,6 +45,8 @@ const typeClasses: Record<ButtonType, string> = {
base: 'button-frame--base bg-surface-4 text-contrast [&>svg]:text-primary',
colored:
'button-frame--colored bg-[--button-color] text-[var(--color-accent-contrast)] [&>svg]:text-inherit',
'colored-text':
'button-frame--colored-text bg-surface-4 text-[--button-color] [&>svg]:text-inherit',
outlined:
'button-frame--outlined bg-transparent text-[var(--button-color,var(--color-contrast))] [&>svg]:text-[var(--button-color,var(--color-base))]',
quiet: 'button-frame--quiet bg-transparent [&>svg]:text-inherit',
@@ -100,7 +102,12 @@ const classes = computed(() => [
])
const style = computed((): CSSProperties | undefined => {
if ((props.type === 'outlined' || props.type === 'quiet') && !props.color) return undefined
if (props.type !== 'colored' && props.type !== 'outlined' && props.type !== 'quiet')
if (
props.type !== 'colored' &&
props.type !== 'colored-text' &&
props.type !== 'outlined' &&
props.type !== 'quiet'
)
return undefined
return {
@@ -125,7 +132,8 @@ defineExpose({ element })
</template>
<style scoped>
.button-frame--base {
.button-frame--base,
.button-frame--colored-text {
box-shadow:
inset 0 0 0 1px var(--surface-5),
0 1px 1px rgba(0, 0, 0, 0.12);
@@ -3,7 +3,7 @@ import type { RouteLocationRaw } from 'vue-router'
import type { AnchoredTeleportPlacement } from '../../../utils/use-anchored-teleport'
export type ButtonType = 'base' | 'colored' | 'outlined' | 'quiet'
export type ButtonType = 'base' | 'colored' | 'colored-text' | 'outlined' | 'quiet'
export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
@@ -35,6 +35,10 @@ export type ButtonVisualProps = {
type: 'colored'
color?: ButtonColor
}
| {
type: 'colored-text'
color?: ButtonColor
}
| {
type: 'quiet'
color?: ButtonColor
+5
View File
@@ -107,7 +107,11 @@ export { default as RadioButtons } from './RadioButtons.vue'
export { default as ReadyTransition } from './ReadyTransition.vue'
export { default as ScrollablePanel } from './ScrollablePanel.vue'
export { default as ServerNotice } from './ServerNotice.vue'
export { default as SettingsFormGroup } from './SettingsFormGroup.vue'
export { default as SettingsInlineWarning } from './SettingsInlineWarning.vue'
export { default as SettingsLabel } from './SettingsLabel.vue'
export { default as SettingsOptionCard } from './SettingsOptionCard.vue'
export { default as SettingsToggleCard } from './SettingsToggleCard.vue'
export { default as SimpleBadge } from './SimpleBadge.vue'
export { default as Slider } from './Slider.vue'
export { default as SmartClickable } from './SmartClickable.vue'
@@ -132,4 +136,5 @@ export type {
export { default as TimeFramePicker } from './TimeFramePicker.vue'
export { default as Timeline } from './Timeline.vue'
export { default as Toggle } from './Toggle.vue'
export { default as ToggleCard } from './ToggleCard.vue'
export { default as UnsavedChangesPopup } from './UnsavedChangesPopup.vue'
@@ -2,6 +2,7 @@
import { type Component, computed } from 'vue'
import PrideCollectionWidget from './PrideCollectionWidget.vue'
import Rule6Widget from './Rule6Widget.vue'
import SparkLiveWidget from './SparkLiveWidget.vue'
import SparkLiveWidgetEmbed from './SparkLiveWidgetEmbed.vue'
@@ -9,6 +10,7 @@ const ARTICLE_WIDGETS: Record<string, Component> = {
'spark-live-widget': SparkLiveWidget,
'spark-live-widget-embed': SparkLiveWidgetEmbed,
'pride-collection-widget': PrideCollectionWidget,
'rule-6': Rule6Widget,
}
type ArticleBodyPart = { type: 'html'; content: string } | { type: 'widget'; id: string }
@@ -0,0 +1,71 @@
<template>
<div
id="rule-6"
class="rounded-2xl border border-solid border-surface-4 bg-surface-3 markdown-body p-4"
>
<nuxt-link to="/legal/rules#generative-ai" target="_blank">
<h2 class="!mt-0">6. Usage of Generative "AI"</h2>
</nuxt-link>
<p>
Projects must be forthright and honest about the usage of generative AI in their production,
publication, and within the project itself. Projects cannot be entirely or primarily comprised
of content created or derived from generative AI output.
</p>
<h3 id="disclosure-of-ai-generated-content">6.1. Disclosure of AI generated content</h3>
<p>You must apply the appropriate Contains AI-generated content content disclosure when:</p>
<ol type="a">
<li>a substantial portion of the project's code is a product of AI output.</li>
<li>
the project includes any assets that are primarily or entirely a product of AI output.
</li>
<li>the project's design or functionality relies on the use of generative AI.</li>
<li>
any element of the project's page such as description or publishing relies on generative AI.
</li>
</ol>
<h3 id="prohibited-usage-of-ai">6.2. Prohibited AI generated content</h3>
<ol type="a">
<li>
No images uploaded to a gallery, icon, description, or any other part of a project page may
be created or derived from generative AI output. Any such images may be removed.
</li>
<li>
Projects may not be published publicly if the contents are primarily or entirely a product
of AI output.
</li>
</ol>
</div>
</template>
<style scoped>
.markdown-body h2 {
@apply mb-3 mt-5 pb-1 pt-0 text-xl font-semibold text-contrast;
}
.markdown-body h3 {
@apply mb-1 mt-5 p-0 text-lg font-semibold text-contrast;
}
.markdown-body a {
@apply !font-semibold !text-brand !no-underline hover:!text-brand hover:!underline;
}
.markdown-body p {
@apply leading-normal;
}
.markdown-body ol {
@apply mb-3;
}
.markdown-body ol ol {
@apply mb-2;
}
.markdown-body li {
@apply mb-1;
}
</style>
@@ -1,10 +1,12 @@
<template>
<NewModal ref="modal" :noblur="noblur" :danger="danger" :on-hide="onHide" max-width="550px">
<template #title>
<slot name="title">
<span class="font-extrabold text-contrast text-lg">{{ title }}</span>
</slot>
</template>
<NewModal
ref="modal"
:noblur="noblur"
:danger="danger"
:on-hide="onHide"
max-width="800px"
:header="title"
>
<div class="flex flex-col gap-4">
<template v-if="description">
<div
@@ -20,7 +22,7 @@
<label v-if="hasToType" for="confirmation">
<span>
To confirm you want to proceed, type
<span class="italic font-bold">{{ confirmationText }}</span> below:
<span class="font-semibold text-contrast">{{ confirmationText }}</span> below:
</span>
</label>
<StyledInput
@@ -0,0 +1,104 @@
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.title)"
:on-hide="handleHide"
:on-after-hide="handleAfterHide"
:closable="false"
fade="warning"
max-width="544px"
>
<div class="flex flex-col">
<p class="mt-0 mb-4 leading-normal">
{{ formatMessage(messages.body1) }}
</p>
<p class="mt-0 mb-4 leading-normal">
{{ formatMessage(messages.body2) }}
</p>
<p class="mt-0 mb-4 leading-normal">
{{ formatMessage(messages.body3) }}
</p>
<div class="flex justify-between items-center">
<Checkbox v-model="dontShowAgain" :label="formatMessage(messages.dontShowAgain)" />
<Button type="colored" color="brand" native-type="button" @click="acknowledge">
<CheckCircleIcon class="size-4" />
{{ formatMessage(commonMessages.iUnderstandButton) }}
</Button>
</div>
</div>
</NewModal>
</template>
<script setup lang="ts">
import { CheckCircleIcon } from '@modrinth/assets'
import { ref, useTemplateRef } from 'vue'
import { Button } from '#ui/components/base/buttons'
import { defineMessages, useVIntl } from '../../composables/i18n'
import { commonMessages } from '../../utils/common-messages'
import Checkbox from '../base/Checkbox.vue'
import NewModal from './NewModal.vue'
const emit = defineEmits<{
dismiss: [dontShowAgain: boolean]
}>()
const { formatMessage } = useVIntl()
const modal = useTemplateRef('modal')
const dontShowAgain = ref(false)
let pendingDontShowAgain = false
const messages = defineMessages({
title: {
id: 'search.photosensitivity-warning-modal.title',
defaultMessage: 'Results are not guaranteed to be safe',
},
body1: {
id: 'search.photosensitivity-warning-modal.body.1',
defaultMessage:
'We cannot guarantee that all content on Modrinth has been labeled appropriately.',
},
body2: {
id: 'search.photosensitivity-warning-modal.body.2',
defaultMessage:
'Content labels for photosensitivity triggers are self-assigned by the creators who upload their content to Modrinth. These projects have not gone through any safety testing.',
},
body3: {
id: 'search.photosensitivity-warning-modal.body.3',
defaultMessage: 'Using any content on Modrinth is at your own risk. Please stay safe! 💚',
},
dontShowAgain: {
id: 'search.photosensitivity-warning-modal.dont-show-again',
defaultMessage: "Don't show this again",
},
})
function show() {
dontShowAgain.value = false
pendingDontShowAgain = false
modal.value?.show()
}
function hide() {
modal.value?.hide()
}
function handleHide() {
pendingDontShowAgain = dontShowAgain.value
}
function handleAfterHide() {
emit('dismiss', pendingDontShowAgain)
dontShowAgain.value = false
pendingDontShowAgain = false
}
function acknowledge() {
pendingDontShowAgain = dontShowAgain.value
modal.value?.hide()
}
defineExpose({ show, hide })
</script>
@@ -3,6 +3,7 @@ export { default as ConfirmModal } from './ConfirmModal.vue'
export { default as NewModal } from './NewModal.vue'
export type { ServerProject as OpenInAppModalServerProject } from './OpenInAppModal.vue'
export { default as OpenInAppModal } from './OpenInAppModal.vue'
export { default as PhotosensitivityWarningModal } from './PhotosensitivityWarningModal.vue'
export { default as ShareModal } from './ShareModal.vue'
export type { Tab as TabbedModalTab } from './TabbedModal.vue'
export { default as TabbedModal } from './TabbedModal.vue'
@@ -0,0 +1,37 @@
<script setup lang="ts">
import { ArchiveIcon } from '@modrinth/assets'
import { computed } from 'vue'
import Admonition from '#ui/components/base/Admonition.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
const props = defineProps<{
title: string
reason?: string | null
}>()
const { formatMessage } = useVIntl()
const trimmedReason = computed(() => props.reason?.trim() || null)
const messages = defineMessages({
header: {
id: 'project.status.archived.header',
defaultMessage: '{title} has been archived',
},
body: {
id: 'project.status.archived.body',
defaultMessage:
'{title} will not receive any further updates unless the author decides to unarchive the project.',
},
})
</script>
<template>
<Admonition type="info" :header="formatMessage(messages.header, { title })">
<template #icon="{ iconClass }">
<ArchiveIcon :class="iconClass" />
</template>
<span>{{ trimmedReason ?? formatMessage(messages.body, { title }) }}</span>
</Admonition>
</template>
@@ -9,7 +9,7 @@
</template>
<template v-if="showStatusBadge" #badges>
<ProjectStatusBadge :status="project.status" />
<ProjectStatusBadge :status="projectV3.status" />
</template>
<template #metadata>
@@ -13,7 +13,89 @@
<div class="markdown-body" v-html="licenseHtml" />
</NewModal>
<h2 class="text-lg m-0">{{ formatMessage(commonMessages.detailsLabel) }}</h2>
<div class="flex flex-col gap-3 [&>div]:flex [&>div]:gap-2 [&>div]:items-center">
<div
class="flex flex-col gap-3 [&>div>svg]:shrink-0 [&>div>svg]:mt-[1px] [&>div]:flex [&>div]:gap-2 [&>div]:items-start"
>
<div v-if="photosensitivityDisclosure" class="text-orange">
<EyeIcon aria-hidden="true" />
<div class="flex flex-col gap-1">
<span>
{{ capitalizeString(formatMessage(messages.photosensitivityTitle)) }}
</span>
<span v-if="photosensitivityDisclosure.note" class="text-sm text-secondary">
{{ photosensitivityDisclosure.note }}
</span>
</div>
</div>
<div v-if="aiDisclosure">
<SparklesIcon aria-hidden="true" />
<div class="flex flex-col gap-1">
<span>
{{ capitalizeString(aiGeneratedLabel) }}
</span>
<span v-if="aiDisclosure.note" class="text-sm text-secondary">
{{ aiDisclosure.note }}
</span>
</div>
</div>
<div v-if="advertisingDisclosure">
<MegaphoneIcon aria-hidden="true" />
<div class="flex flex-col gap-1">
<span>
{{ capitalizeString(formatMessage(messages.advertisingTitle)) }}
</span>
<span v-if="advertisingDisclosure.note" class="text-sm text-secondary">
{{ advertisingDisclosure.note }}
</span>
</div>
</div>
<div v-if="paidFeaturesDisclosure">
<CircleDollarSignIcon aria-hidden="true" />
<div class="flex flex-col gap-1">
<span>
{{ capitalizeString(formatMessage(messages.paidFeatures)) }}
</span>
<span
v-for="(feature, index) in paidFeaturesDisclosure.features"
:key="`${feature}-${index}`"
class="text-sm text-secondary"
>
{{ feature }}
</span>
</div>
</div>
<div v-if="telemetryDisclosure">
<RadioTowerIcon aria-hidden="true" />
<div class="flex flex-col gap-1">
<span>
{{
capitalizeString(
formatMessage(messages.telemetryTitle, {
consent: telemetryDisclosure.consent,
}),
)
}}
</span>
<span
v-for="(entry, index) in telemetryDisclosure.data_collected"
:key="`${entry}-${index}`"
class="text-sm text-secondary"
>
{{ entry }}
</span>
</div>
</div>
<div v-if="systemInteractionsDisclosure">
<CircuitBoardIcon aria-hidden="true" />
<div class="flex flex-col gap-1">
<span>
{{ capitalizeString(formatMessage(messages.systemInteractionsTitle)) }}
</span>
<span v-if="systemInteractionsDisclosure.note" class="text-sm text-secondary">
{{ systemInteractionsDisclosure.note }}
</span>
</div>
</div>
<div v-if="!hideLicense">
<BookTextIcon aria-hidden="true" />
<div>
@@ -41,6 +123,32 @@
</IntlFormatted>
</div>
</div>
<div v-if="derivativeWorkDisclosure">
<GitForkIcon aria-hidden="true" />
<div class="flex flex-col gap-2">
<span>
{{ capitalizeString(formatMessage(messages.derivativeWork)) }}
</span>
<div
v-for="(source, index) in derivativeWorkDisclosure.sources"
:key="`${source.label}-${index}`"
class="flex flex-col gap-1"
>
<a
v-if="source.link"
:href="source.link"
:target="linkTarget"
rel="noopener nofollow ugc"
class="text-blue text-sm flex items-center gap-1 hover:underline"
>
{{ source.label }}
<ExternalIcon />
</a>
<span v-else class="text-sm">{{ source.label }}</span>
<span v-if="source.note" class="text-sm text-secondary">{{ source.note }}</span>
</div>
</div>
</div>
<div v-if="showFollowers">
<HeartIcon aria-hidden="true" />
<div>
@@ -94,21 +202,37 @@
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { BookTextIcon, CalendarIcon, HeartIcon, ScaleIcon, VersionIcon } from '@modrinth/assets'
import {
BookTextIcon,
CalendarIcon,
CircleDollarSignIcon,
CircuitBoardIcon,
ExternalIcon,
EyeIcon,
GitForkIcon,
HeartIcon,
MegaphoneIcon,
RadioTowerIcon,
ScaleIcon,
SparklesIcon,
VersionIcon,
} from '@modrinth/assets'
import { capitalizeString, renderString } from '@modrinth/utils'
import { useQuery } from '@tanstack/vue-query'
import { computed, ref, useTemplateRef } from 'vue'
import { useFormatDateTime, useRelativeTime } from '../../composables'
import { defineMessages, useVIntl } from '../../composables/i18n'
import { defineMessage, defineMessages, useVIntl } from '../../composables/i18n'
import { injectModrinthClient } from '../../providers'
import { commonMessages } from '../../utils/common-messages'
import { getActiveDisclosures } from '../../utils/disclosures'
import { Avatar, IntlFormatted } from '../base'
import { NewModal } from '../modal'
const LICENSE_STALE_TIME = 1000 * 60 * 10
const DISCLOSURE_STALE_TIME = 1000 * 60 * 5
const { formatMessage } = useVIntl()
const { formatMessage, locale } = useVIntl()
const { labrinth } = injectModrinthClient()
const formatRelativeTime = useRelativeTime()
const formatDateTime = useFormatDateTime({
@@ -116,6 +240,8 @@ const formatDateTime = useFormatDateTime({
dateStyle: 'long',
})
const AI_USE_ORDER: Labrinth.Projects.v3.AiUsage[] = ['code', 'assets', 'text', 'functionality']
const props = defineProps<{
project: Labrinth.Projects.v2.Project
linkTarget: string
@@ -143,6 +269,98 @@ const messages = defineMessages({
id: 'project.license.loading',
defaultMessage: 'Loading license text...',
},
advertisingTitle: {
id: 'project.disclosure.advertising.title',
defaultMessage: 'Contains advertising',
},
paidFeatures: {
id: 'project.disclosure.paid-features.title',
defaultMessage: 'Contains paid features',
},
aiGeneratedContent: {
id: 'project.disclosure.ai-generated-content.title',
defaultMessage: 'Contains AI-generated {types}',
},
derivativeWork: {
id: 'project.disclosure.derivative-work.title',
defaultMessage: 'This is a derivative work of:',
},
telemetryTitle: {
id: 'project.disclosure.telemetry.title',
defaultMessage:
'Contains {consent, select, opt_in {opt-in telemetry} opt_out {opt-out telemetry} always_active {always-active telemetry} other {telemetry}}',
},
photosensitivityTitle: {
id: 'project.disclosure.photosensitivity.title',
defaultMessage: 'Photosensitivity warning',
},
systemInteractionsTitle: {
id: 'project.disclosure.system-interactions.title',
defaultMessage: 'Contains external system interactions',
},
})
const { data: disclosuresResponse } = useQuery({
queryKey: computed(() => ['project', 'disclosures', 'v3', props.project.id] as const),
queryFn: () => labrinth.projects_v3.getDisclosures(props.project.id),
staleTime: DISCLOSURE_STALE_TIME,
})
const disclosures = computed(() => getActiveDisclosures(disclosuresResponse.value?.disclosures))
function findDisclosure<T extends Labrinth.Projects.v3.ProjectDisclosureType>(type: T) {
return disclosures.value.find(
(d): d is Labrinth.Projects.v3.ProjectDisclosureOf<T> => d.type === type,
)
}
const aiDisclosure = computed(() => findDisclosure('ai_content'))
const advertisingDisclosure = computed(() => findDisclosure('advertisements'))
const paidFeaturesDisclosure = computed(() => findDisclosure('paid_features'))
const telemetryDisclosure = computed(() => findDisclosure('telemetry'))
const derivativeWorkDisclosure = computed(() => findDisclosure('derivative_work'))
const photosensitivityDisclosure = computed(() => findDisclosure('epilepsy_triggers'))
const systemInteractionsDisclosure = computed(() => findDisclosure('system_interactions'))
const aiUseLabels = {
code: defineMessage({
id: 'project.disclosure.ai-generated-content.use.code',
defaultMessage: 'code',
}),
assets: defineMessage({
id: 'project.disclosure.ai-generated-content.use.assets',
defaultMessage: 'assets',
}),
text: defineMessage({
id: 'project.disclosure.ai-generated-content.use.text',
defaultMessage: 'text',
}),
functionality: defineMessage({
id: 'project.disclosure.ai-generated-content.use.functionality',
defaultMessage: 'functionality',
}),
content: defineMessage({
id: 'project.disclosure.ai-generated-content.use.content',
defaultMessage: 'content',
}),
} as const
const aiGeneratedLabel = computed(() => {
const disclosure = aiDisclosure.value
if (!disclosure) {
return ''
}
const orderedUses = AI_USE_ORDER.filter((use) => disclosure.uses.includes(use))
const types =
orderedUses.length === 0
? formatMessage(aiUseLabels.content)
: new Intl.ListFormat(locale.value, {
style: 'long',
type: 'conjunction',
}).format(orderedUses.map((use) => formatMessage(aiUseLabels[use])))
return formatMessage(messages.aiGeneratedContent, { types })
})
const createdDate = computed(() =>
@@ -2,6 +2,7 @@ export * from './server'
export * from './settings'
// Other
export { default as ArchivedProjectBanner } from './ArchivedProjectBanner.vue'
export { default as ProjectCard } from './card/ProjectCard.vue'
export { default as ProjectBackgroundGradient } from './ProjectBackgroundGradient.vue'
export { default as ProjectCardList } from './ProjectCardList.vue'
@@ -40,7 +40,12 @@ import { BanIcon, LockIcon, XCircleIcon, XIcon } from '@modrinth/assets'
import { computed, type ComputedRef } from 'vue'
import { defineMessage, type MessageDescriptor, useVIntl } from '../../composables/i18n'
import type { FilterOption, FilterType, FilterValue } from '../../utils/search'
import {
type FilterOption,
type FilterType,
type FilterValue,
flattenFilterOptions,
} from '../../utils/search'
import TagItem from '../base/TagItem.vue'
const { formatMessage } = useVIntl()
@@ -73,7 +78,7 @@ function filterMatches(type: FilterType, option: FilterOption, list: FilterValue
const items: ComputedRef<Item[]> = computed(() => {
return props.filters.flatMap((type) =>
type.options
flattenFilterOptions(type.options)
.filter(
(option) =>
filterMatches(type, option, selectedFilters.value) ||
@@ -1,7 +1,7 @@
<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 [&>svg.ban-icon]:hover:text-red [&>svg.ban-icon]:focus-visible:text-red'}`"
:class="`flex border-none cursor-pointer flex-1 min-w-0 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 [&>svg.ban-icon]:hover:text-red [&>svg.ban-icon]:focus-visible:text-red'}`"
@click="() => emit(primaryAction === 'exclude' ? 'toggleExclude' : 'toggle', option)"
>
<slot> </slot>
@@ -29,11 +29,33 @@
>
<BanIcon class="filter-action-icon h-4 w-4" aria-hidden="true" />
</button>
<button
v-if="hasSubOptions"
v-tooltip="
expanded
? formatMessage(messages.collapseSubOptionsTooltip)
: formatMessage(messages.expandSubOptionsTooltip)
"
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 transition-all hover:bg-button-bg hover:text-contrast focus-visible:bg-button-bg focus-visible:text-contrast active:scale-[0.96]"
:aria-expanded="expanded"
:aria-label="
expanded
? formatMessage(messages.collapseSubOptionsTooltip)
: formatMessage(messages.expandSubOptionsTooltip)
"
@click.stop="emit('toggleExpand')"
>
<DropdownIcon
class="h-4 w-4 transition-transform duration-200"
:class="{ 'rotate-180': expanded }"
aria-hidden="true"
/>
</button>
</div>
</template>
<script setup lang="ts">
import { BanIcon, CheckIcon } from '@modrinth/assets'
import { BanIcon, CheckIcon, DropdownIcon } from '@modrinth/assets'
import { computed } from 'vue'
import { defineMessages, useVIntl } from '../../composables/i18n'
@@ -45,9 +67,13 @@ const props = withDefaults(
included: boolean
excluded: boolean
supports?: FilterMode[]
hasSubOptions?: boolean
expanded?: boolean
}>(),
{
supports: () => ['include'],
hasSubOptions: false,
expanded: false,
},
)
@@ -63,6 +89,7 @@ const { formatMessage } = useVIntl()
const emit = defineEmits<{
toggle: [option: FilterOption]
toggleExclude: [option: FilterOption]
toggleExpand: []
}>()
const messages = defineMessages({
@@ -70,6 +97,14 @@ const messages = defineMessages({
id: 'search.filter.option.exclusion.add.tooltip',
defaultMessage: 'Exclude',
},
expandSubOptionsTooltip: {
id: 'search.filter.option.sub_options.expand.tooltip',
defaultMessage: 'Show more options',
},
collapseSubOptionsTooltip: {
id: 'search.filter.option.sub_options.collapse.tooltip',
defaultMessage: 'Hide more options',
},
})
</script>
<style scoped lang="scss">
@@ -30,14 +30,14 @@
<div class="flex gap-1 flex-wrap">
<div
v-for="option in selectedFilterOptions"
:key="`selected-filter-${filterType.id}-${option}`"
:key="`selected-filter-${filterType.id}-${option.id}`"
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}`"
:key="`excluded-filter-${filterType.id}-${option.id}`"
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 }}
@@ -98,37 +98,62 @@
/>
</template>
<template v-else>
<SearchFilterOption
v-for="option in visibleOptions"
:key="`${filterType.id}-${option}`"
:option="option"
:included="isIncluded(option)"
:excluded="isExcluded(option)"
:supports="filterType.supports"
:class="{
'mr-3': scrollable,
}"
@toggle="toggleFilter"
@toggle-exclude="toggleNegativeFilter"
>
<slot name="option" :filter="filterType" :option="option">
<span
v-if="option.icon"
class="inline-flex items-center justify-center shrink-0 h-4 w-4"
:style="iconStyle(option)"
<template v-for="option in visibleOptions" :key="`${filterType.id}-${option.id}`">
<SearchFilterOption
:option="option"
:included="isIncluded(option)"
:excluded="isExcluded(option)"
:supports="filterType.supports"
:has-sub-options="!!option.sub_options?.length"
:expanded="isExpanded(option)"
:class="{
'mr-3': scrollable,
}"
@toggle="toggleFilter"
@toggle-exclude="toggleNegativeFilter"
@toggle-expand="toggleExpand(option)"
>
<slot name="option" :filter="filterType" :option="option">
<span
v-if="option.icon"
class="inline-flex items-center justify-center shrink-0 h-4 w-4"
:style="iconStyle(option)"
>
<div
v-if="typeof option.icon === 'string'"
class="h-4 w-4"
v-html="option.icon"
/>
<component :is="option.icon" v-else class="h-4 w-4" />
</span>
<span class="truncate text-sm" :style="iconStyle(option)">
{{ option.formatted_name ?? option.id }}
</span>
</slot>
</SearchFilterOption>
<div
v-if="option.sub_options?.length && isExpanded(option)"
class="ml-4 flex flex-col gap-1"
:class="{ 'mr-3': scrollable }"
>
<SearchFilterOption
v-for="subOption in option.sub_options"
:key="`${filterType.id}-${subOption.id}`"
:option="subOption"
:included="isIncluded(subOption)"
:excluded="isExcluded(subOption)"
:supports="filterType.supports"
@toggle="toggleFilter"
@toggle-exclude="toggleNegativeFilter"
>
<div
v-if="typeof option.icon === 'string'"
class="h-4 w-4"
v-html="option.icon"
/>
<component :is="option.icon" v-else class="h-4 w-4" />
</span>
<span class="truncate text-sm" :style="iconStyle(option)">
{{ option.formatted_name ?? option.id }}
</span>
</slot>
</SearchFilterOption>
<slot name="option" :filter="filterType" :option="subOption">
<span class="truncate text-sm">
{{ subOption.formatted_name ?? subOption.id }}
</span>
</slot>
</SearchFilterOption>
</div>
</template>
</template>
<button
v-if="filterType.display === 'expandable'"
@@ -180,12 +205,18 @@
<script setup lang="ts">
import { BanIcon, DropdownIcon, LockOpenIcon, SearchIcon, UpdatedIcon } from '@modrinth/assets'
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { Button } from '#ui/components/base/buttons'
import { defineMessages, useVIntl } from '../../composables/i18n'
import type { FilterOption, FilterType, FilterValue } from '../../utils/search'
import {
type FilterOption,
type FilterType,
type FilterValue,
findParentFilterOption,
flattenFilterOptions,
} from '../../utils/search'
import Accordion from '../base/Accordion.vue'
import { Checkbox, ScrollablePanel, StyledInput } from '../index'
import SearchFilterGroup from './SearchFilterGroup.vue'
@@ -215,22 +246,31 @@ defineOptions({
const query = ref('')
const showMore = ref(false)
const expandedOptionIds = ref<string[]>([])
const accordion = ref<InstanceType<typeof Accordion> | null>()
const allOptions = computed(() => flattenFilterOptions(props.filterType.options))
const selectedFilterOptions = computed(() =>
props.filterType.options.filter((option) =>
allOptions.value.filter((option) =>
locked.value ? isProvided(option, false) : isIncluded(option),
),
)
const selectedNegativeFilterOptions = computed(() =>
props.filterType.options.filter((option) =>
allOptions.value.filter((option) =>
locked.value ? isProvided(option, true) : isExcluded(option),
),
)
const visibleOptions = computed(() =>
props.filterType.options
.filter((option) => isVisible(option) || isIncluded(option) || isExcluded(option))
.filter(
(option) =>
isVisible(option) ||
isIncluded(option) ||
isExcluded(option) ||
hasSelectedSubOption(option),
)
.slice()
.sort((a, b) => {
if (props.filterType.display === 'expandable') {
@@ -309,6 +349,24 @@ function isExcluded(filter: FilterOption) {
return selectedFilters.value.some((value) => value.option === filter.id && value.negative)
}
function hasSelectedSubOption(filter: FilterOption) {
return (
filter.sub_options?.some((subOption) => isIncluded(subOption) || isExcluded(subOption)) ?? false
)
}
function isExpanded(filter: FilterOption) {
return expandedOptionIds.value.includes(filter.id)
}
function toggleExpand(filter: FilterOption) {
if (isExpanded(filter)) {
expandedOptionIds.value = expandedOptionIds.value.filter((id) => id !== filter.id)
} else {
expandedOptionIds.value = [...expandedOptionIds.value, filter.id]
}
}
function isVisible(filter: FilterOption) {
const filterKey = filter.formatted_name?.toLowerCase() ?? filter.id.toLowerCase()
const matchesQuery = !query.value || filterKey.includes(query.value.toLowerCase())
@@ -341,7 +399,22 @@ function toggleNegativeFilter(filter: FilterOption) {
}
function setFilter(filter: FilterOption, state: FilterState) {
const newFilters = selectedFilters.value.filter((selected) => selected.option !== filter.id)
let newFilters = selectedFilters.value.filter((selected) => selected.option !== filter.id)
if (state !== 'ignore') {
const subOptionIds = new Set(filter.sub_options?.map((subOption) => subOption.id) ?? [])
if (subOptionIds.size > 0) {
newFilters = newFilters.filter((selected) => !subOptionIds.has(selected.option))
}
const parent = findParentFilterOption(props.filterType.options, filter.id)
if (parent) {
newFilters = newFilters.filter((selected) => selected.option !== parent.id)
if (!expandedOptionIds.value.includes(parent.id)) {
expandedOptionIds.value = [...expandedOptionIds.value, parent.id]
}
}
}
const baseValues = {
type: props.filterType.id,
@@ -369,6 +442,18 @@ function clearFilters() {
)
}
watch(
selectedFilters,
() => {
for (const option of props.filterType.options) {
if (hasSelectedSubOption(option) && !expandedOptionIds.value.includes(option.id)) {
expandedOptionIds.value = [...expandedOptionIds.value, option.id]
}
}
},
{ deep: true, immediate: true },
)
const messages = defineMessages({
searchPlaceholder: {
id: 'search.filter.option.search.placeholder',
@@ -0,0 +1,22 @@
<script setup lang="ts">
import { InfoIcon } from '@modrinth/assets'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
const { formatMessage } = useVIntl()
const messages = defineMessages({
persistenceNote: {
id: 'browse.advanced-filters.persistence-note',
defaultMessage:
'Advanced exclusions are saved across sessions. Opening a shared link with filters will override them.',
},
})
</script>
<template>
<div class="mb-2 flex gap-2 px-3 text-sm text-secondary opacity-80">
<InfoIcon class="mt-0.5 size-4 shrink-0" />
<span>{{ formatMessage(messages.persistenceNote) }}</span>
</div>
</template>
@@ -4,6 +4,15 @@ import { computed, nextTick, ref, shallowRef, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useDebugLogger } from '#ui/composables/debug-logger'
import {
compatibleAdvancedFilters,
getAdvancedOptionIds,
getAvailableAdvancedIds,
mergeAdvancedPrefs,
replaceAdvancedFilters,
sameOptionIds,
useAdvancedPrefs,
} from '#ui/utils/advanced-filter-preferences'
import type {
EnvironmentSearchOverride,
FilterType,
@@ -66,6 +75,9 @@ export interface BrowseSearchState {
setPage: (page: number) => Promise<void>
clearSearch: () => void
onFilterChange: () => void
linkOverridesAdvancedPrefs: Ref<boolean>
applySavedAdvancedPrefs: () => void
}
export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchState {
@@ -197,6 +209,76 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
const effectiveCurrentFilters = computed(() =>
isServerType.value ? serverCurrentFilters.value : currentFilters.value,
)
const effectiveFilterTypes = computed(() =>
isServerType.value ? serverFilterTypes.value : filters.value,
)
const advancedPrefs = useAdvancedPrefs()
const linkOverridesAdvancedPrefs = ref(false)
const selectedAdvancedIds = computed(() => getAdvancedOptionIds(effectiveCurrentFilters.value))
function hasSearchQuery(): boolean {
return Object.keys(route.query).some((key) => !options.persistentQueryParams.includes(key))
}
function getCompatiblePrefs(): FilterValue[] {
return compatibleAdvancedFilters(advancedPrefs.value, effectiveFilterTypes.value)
}
function setEffectiveFilters(nextFilters: FilterValue[]) {
if (isServerType.value) {
serverCurrentFilters.value = nextFilters
} else {
currentFilters.value = nextFilters
}
}
function applyAdvancedPrefs(prefs: FilterValue[]) {
setEffectiveFilters(replaceAdvancedFilters(effectiveCurrentFilters.value, prefs))
}
function syncLinkOverride() {
const prefIds = getAdvancedOptionIds(getCompatiblePrefs())
linkOverridesAdvancedPrefs.value =
prefIds.length > 0 && !sameOptionIds(selectedAdvancedIds.value, prefIds)
}
function initAdvancedPrefs() {
const prefs = getCompatiblePrefs()
if (!hasSearchQuery()) {
if (prefs.length > 0) {
applyAdvancedPrefs(prefs)
}
linkOverridesAdvancedPrefs.value = false
return
}
syncLinkOverride()
}
function applySavedAdvancedPrefs() {
applyAdvancedPrefs(getCompatiblePrefs())
linkOverridesAdvancedPrefs.value = false
}
initAdvancedPrefs()
watch(selectedAdvancedIds, (selected, previous) => {
if (previous && sameOptionIds(selected, previous)) {
return
}
const nextPrefs = mergeAdvancedPrefs(
advancedPrefs.value,
selected,
getAvailableAdvancedIds(effectiveFilterTypes.value),
)
if (!sameOptionIds(advancedPrefs.value, nextPrefs)) {
advancedPrefs.value = nextPrefs
}
linkOverridesAdvancedPrefs.value = false
})
watch(
[
@@ -340,6 +422,10 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
effectiveSortTypes.value.find((sortType) => sortType.name === 'relevance') ??
effectiveSortTypes.value[0]
query.value = ''
void nextTick(() => {
initAdvancedPrefs()
})
},
)
@@ -369,5 +455,7 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
setPage,
clearSearch,
onFilterChange,
linkOverridesAdvancedPrefs,
applySavedAdvancedPrefs,
}
}
@@ -1,8 +1,9 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { SearchIcon } from '@modrinth/assets'
import { RotateCounterClockwiseIcon, SearchIcon } from '@modrinth/assets'
import { computed, ref, toValue } from 'vue'
import Admonition from '#ui/components/base/Admonition.vue'
import { Button, IconButton } from '#ui/components/base/buttons'
import Combobox, { type ComboboxOption } from '#ui/components/base/Combobox.vue'
import LoadingIndicator from '#ui/components/base/LoadingIndicator.vue'
@@ -66,6 +67,14 @@ const messages = defineMessages({
id: 'browse.no-results',
defaultMessage: 'No results found for your query!',
},
linkOverridingPreferences: {
id: 'browse.advanced-filters.link-overriding-preferences',
defaultMessage: "This link's filters differ from your saved advanced exclusions",
},
applySavedPreferences: {
id: 'browse.advanced-filters.apply-saved-preferences',
defaultMessage: 'Apply saved preferences',
},
})
function cardActionType(action: CardAction) {
@@ -151,6 +160,21 @@ function getProjectCardTags(result: Labrinth.Search.v3.ResultSearchProject, disp
@clear="ctx.clearSearch()"
/>
<Admonition
v-if="ctx.linkOverridesAdvancedPrefs.value"
type="info"
:header="formatMessage(messages.linkOverridingPreferences)"
inline-actions
center-content
>
<template #actions>
<Button type="colored" color="blue" @click="ctx.applySavedAdvancedPrefs()">
<RotateCounterClockwiseIcon />
{{ formatMessage(messages.applySavedPreferences) }}
</Button>
</template>
</Admonition>
<div class="flex flex-wrap items-center gap-2">
<Combobox
:model-value="ctx.effectiveCurrentSortType.value"
@@ -45,6 +45,8 @@ export interface BrowseManagerContext {
setPage: (page: number) => Promise<void>
clearSearch: () => void
onFilterChange: () => void
linkOverridesAdvancedPrefs: Ref<boolean>
applySavedAdvancedPrefs: () => void
getProjectLink: (result: Labrinth.Search.v3.ResultSearchProject) => string | RouteLocationRaw
getServerProjectLink: (
@@ -74,6 +76,7 @@ export interface BrowseManagerContext {
serverOnlyLabel?: ComputedRef<string>
hiddenFilterTypes?: ComputedRef<string[]>
advancedFiltersCollapsed?: Ref<boolean>
dismissedPhotosensitivityFilterWarning?: Ref<boolean>
onInstalled?: (projectId: string) => void
displayMode?: Ref<'list' | 'grid' | 'gallery'> | ComputedRef<'list' | 'grid' | 'gallery'>
@@ -1,23 +1,30 @@
<script setup lang="ts">
import { InfoIcon, XIcon } from '@modrinth/assets'
import { computed, toValue } from 'vue'
import { computed, nextTick, toValue, useTemplateRef, watch } from 'vue'
import { IconButton } from '#ui/components/base/buttons'
import Toggle from '#ui/components/base/Toggle.vue'
import PhotosensitivityWarningModal from '#ui/components/modal/PhotosensitivityWarningModal.vue'
import SearchSidebarFilter from '#ui/components/search/SearchSidebarFilter.vue'
import { useVIntl } from '#ui/composables/i18n'
import { useAdvancedPrefs } from '#ui/utils/advanced-filter-preferences'
import { commonMessages } from '#ui/utils/common-messages'
import AdvancedFiltersPersistenceNote from './components/AdvancedFiltersPersistenceNote.vue'
import { injectBrowseManager } from './providers/browse-manager'
const PHOTOSENSITIVITY_FILTER_OPTION = 'epilepsy_triggers'
const ctx = injectBrowseManager()
const { formatMessage } = useVIntl()
const advancedPrefs = useAdvancedPrefs()
const isApp = computed(() => ctx.variant === 'app')
const lockedMessages = computed(() => toValue(ctx.lockedFilterMessages))
const hiddenFilterTypes = computed(() => ctx.hiddenFilterTypes?.value ?? [])
const advancedFiltersCollapsed = computed(() => ctx.advancedFiltersCollapsed?.value ?? true)
const photosensitivityWarningModal = useTemplateRef('photosensitivityWarningModal')
function setAdvancedFiltersCollapsed(collapsed: boolean) {
if (ctx.advancedFiltersCollapsed) {
@@ -25,6 +32,40 @@ function setAdvancedFiltersCollapsed(collapsed: boolean) {
}
}
function isPhotosensitivityExclusionSelected(filters: { type: string; option: string }[]) {
return filters.some(
(filter) => filter.type === 'advanced' && filter.option === PHOTOSENSITIVITY_FILTER_OPTION,
)
}
watch(
() => ({
selected: isPhotosensitivityExclusionSelected(
ctx.isServerType.value ? ctx.serverCurrentFilters.value : ctx.currentFilters.value,
),
saved: advancedPrefs.value.includes(PHOTOSENSITIVITY_FILTER_OPTION),
}),
(current, previous) => {
if (!previous) {
return
}
const selected = current.selected && !previous.selected
const alreadySaved = previous.saved
const dismissed = ctx.dismissedPhotosensitivityFilterWarning?.value
if (selected && !alreadySaved && !dismissed) {
nextTick(() => photosensitivityWarningModal.value?.show())
}
},
)
function onPhotosensitivityWarningDismiss(dontShowAgain: boolean) {
if (dontShowAgain && ctx.dismissedPhotosensitivityFilterWarning) {
ctx.dismissedPhotosensitivityFilterWarning.value = true
}
}
function closeFiltersMenu() {
if (ctx.filtersMenuOpen) {
ctx.filtersMenuOpen.value = false
@@ -88,6 +129,11 @@ function getFilterOpenByDefault(filterId: string): boolean {
<template>
<slot name="prepend" />
<PhotosensitivityWarningModal
ref="photosensitivityWarningModal"
@dismiss="onPhotosensitivityWarningDismiss"
/>
<div v-if="ctx.filtersMenuOpen?.value" class="fixed inset-0 z-40 bg-bg" />
<div
@@ -173,12 +219,17 @@ function getFilterOpenByDefault(filterId: string): boolean {
:content-class="contentClass"
:inner-panel-class="innerPanelClass"
:open-by-default="getFilterOpenByDefault(filterType.id)"
@on-open="() => filterType.id === 'advanced' && setAdvancedFiltersCollapsed(false)"
@on-close="() => filterType.id === 'advanced' && setAdvancedFiltersCollapsed(true)"
>
<template #header>
<h3 :class="isApp ? 'text-base m-0' : 'm-0 text-base font-semibold'">
{{ filterType.formatted_name }}
</h3>
</template>
<template v-if="filterType.id === 'advanced'" #prefix>
<AdvancedFiltersPersistenceNote />
</template>
</SearchSidebarFilter>
</template>
<template v-else>
@@ -205,8 +256,11 @@ function getFilterOpenByDefault(filterId: string): boolean {
{{ filter.formatted_name }}
</h3>
</template>
<template v-if="filter.id === 'advanced'" #prefix>
<AdvancedFiltersPersistenceNote />
</template>
<template
v-if="
v-else-if="
lockedMessages?.gameVersionShaderMessage &&
ctx.projectType.value === 'shader' &&
filter.id === 'game_version'
@@ -215,7 +269,7 @@ function getFilterOpenByDefault(filterId: string): boolean {
>
<div class="mb-4 grid grid-cols-[auto_1fr] gap-2 px-3 text-sm font-medium text-blue">
<InfoIcon class="mt-1 size-4" />
<span>{{ lockedMessages.gameVersionShaderMessage }}</span>
<span>{{ lockedMessages?.gameVersionShaderMessage }}</span>
</div>
</template>
<template v-if="lockedMessages?.gameVersion" #locked-game_version>
-9
View File
@@ -3791,15 +3791,6 @@
"search.filter_type.advanced": {
"defaultMessage": "Erweitert"
},
"search.filter_type.advanced.exclude_datapack": {
"defaultMessage": "Datenpakete ausschließen"
},
"search.filter_type.advanced.exclude_mod": {
"defaultMessage": "Mods ausschließen"
},
"search.filter_type.advanced.exclude_plugin": {
"defaultMessage": "Plugins ausschließen"
},
"search.filter_type.environment": {
"defaultMessage": "Umgebung"
},
-9
View File
@@ -3791,15 +3791,6 @@
"search.filter_type.advanced": {
"defaultMessage": "Erweitert"
},
"search.filter_type.advanced.exclude_datapack": {
"defaultMessage": "Datenpakete ausschließen"
},
"search.filter_type.advanced.exclude_mod": {
"defaultMessage": "Mods ausschließen"
},
"search.filter_type.advanced.exclude_plugin": {
"defaultMessage": "Plugins ausschließen"
},
"search.filter_type.environment": {
"defaultMessage": "Umgebung"
},
+154 -7
View File
@@ -92,6 +92,15 @@
"billing.resubscribe-modal.title": {
"defaultMessage": "Resubscribe to Server"
},
"browse.advanced-filters.apply-saved-preferences": {
"defaultMessage": "Apply saved preferences"
},
"browse.advanced-filters.link-overriding-preferences": {
"defaultMessage": "This link's filters differ from your saved advanced exclusions"
},
"browse.advanced-filters.persistence-note": {
"defaultMessage": "Advanced exclusions are saved across sessions. Opening a shared link with filters will override them."
},
"browse.filter-results": {
"defaultMessage": "Filter results..."
},
@@ -131,6 +140,9 @@
"button.accept": {
"defaultMessage": "Accept"
},
"button.add-another": {
"defaultMessage": "Add another"
},
"button.add-server-to-instance": {
"defaultMessage": "Add server to instance"
},
@@ -203,6 +215,9 @@
"button.hide-snapshots": {
"defaultMessage": "Hide snapshots"
},
"button.i-understand": {
"defaultMessage": "I understand"
},
"button.install": {
"defaultMessage": "Install"
},
@@ -1058,6 +1073,9 @@
"creation-flow.title.set-up-server": {
"defaultMessage": "Set up server"
},
"empty-state.upload-versions.description": {
"defaultMessage": "Come back once you've uploaded your versions."
},
"external-files.permissions-card.add-files-modal.confirm": {
"defaultMessage": "{count, plural, one {Add file} other {Add files}}"
},
@@ -2363,6 +2381,9 @@
"label.error": {
"defaultMessage": "Error"
},
"label.explanation": {
"defaultMessage": "Explanation"
},
"label.extract-failed": {
"defaultMessage": "Extract failed"
},
@@ -3344,6 +3365,42 @@
"project.about.tags.title": {
"defaultMessage": "Tags"
},
"project.disclosure.advertising.title": {
"defaultMessage": "Contains advertising"
},
"project.disclosure.ai-generated-content.title": {
"defaultMessage": "Contains AI-generated {types}"
},
"project.disclosure.ai-generated-content.use.assets": {
"defaultMessage": "assets"
},
"project.disclosure.ai-generated-content.use.code": {
"defaultMessage": "code"
},
"project.disclosure.ai-generated-content.use.content": {
"defaultMessage": "content"
},
"project.disclosure.ai-generated-content.use.functionality": {
"defaultMessage": "functionality"
},
"project.disclosure.ai-generated-content.use.text": {
"defaultMessage": "text"
},
"project.disclosure.derivative-work.title": {
"defaultMessage": "This is a derivative work of:"
},
"project.disclosure.paid-features.title": {
"defaultMessage": "Contains paid features"
},
"project.disclosure.photosensitivity.title": {
"defaultMessage": "Photosensitivity warning"
},
"project.disclosure.system-interactions.title": {
"defaultMessage": "Contains external system interactions"
},
"project.disclosure.telemetry.title": {
"defaultMessage": "Contains {consent, select, opt_in {opt-in telemetry} opt_out {opt-out telemetry} always_active {always-active telemetry} other {telemetry}}"
},
"project.download-count-tooltip": {
"defaultMessage": "{count, number} {count, plural, one {download} other {downloads}}"
},
@@ -3707,6 +3764,30 @@
"project.settings.description.title": {
"defaultMessage": "Description"
},
"project.settings.disclosures.ai.types-assets": {
"defaultMessage": "Assets"
},
"project.settings.disclosures.ai.types-code": {
"defaultMessage": "Code"
},
"project.settings.disclosures.ai.types-functionality": {
"defaultMessage": "Functionality"
},
"project.settings.disclosures.ai.types-text": {
"defaultMessage": "Text"
},
"project.settings.disclosures.telemetry.consent-always-active": {
"defaultMessage": "Always active"
},
"project.settings.disclosures.telemetry.consent-opt-in": {
"defaultMessage": "Opt-in"
},
"project.settings.disclosures.telemetry.consent-opt-out": {
"defaultMessage": "Opt-out"
},
"project.settings.disclosures.title": {
"defaultMessage": "Disclosures"
},
"project.settings.environment.client_and_server.description": {
"defaultMessage": "Has some functionality on both the client and server, even if only partially."
},
@@ -3836,6 +3917,12 @@
"project.stats.followers-label": {
"defaultMessage": "{count, plural, one {follower} other {followers}}"
},
"project.status.archived.body": {
"defaultMessage": "{title} will not receive any further updates unless the author decides to unarchive the project."
},
"project.status.archived.header": {
"defaultMessage": "{title} has been archived"
},
"project.versions.channel.alpha.symbol": {
"defaultMessage": "A"
},
@@ -3902,6 +3989,24 @@
"report.item-type.version": {
"defaultMessage": "version"
},
"report.type.copyright": {
"defaultMessage": "Reuploaded work"
},
"report.type.inappropriate": {
"defaultMessage": "Inappropriate"
},
"report.type.malicious": {
"defaultMessage": "Malicious"
},
"report.type.missing-disclosure": {
"defaultMessage": "Missing or incorrect disclosure"
},
"report.type.name-squatting": {
"defaultMessage": "Name squatting"
},
"report.type.spam": {
"defaultMessage": "Spam"
},
"s.bg": {
"defaultMessage": "Background task running"
},
@@ -3935,17 +4040,41 @@
"search.filter.option.show_more": {
"defaultMessage": "Show more"
},
"search.filter.option.sub_options.collapse.tooltip": {
"defaultMessage": "Hide more options"
},
"search.filter.option.sub_options.expand.tooltip": {
"defaultMessage": "Show more options"
},
"search.filter_type.advanced": {
"defaultMessage": "Advanced"
"defaultMessage": "Advanced exclusions"
},
"search.filter_type.advanced.exclude_datapack": {
"defaultMessage": "Exclude data packs"
"search.filter_type.advanced.disclosure.advertisements": {
"defaultMessage": "Advertisements"
},
"search.filter_type.advanced.exclude_mod": {
"defaultMessage": "Exclude mods"
"search.filter_type.advanced.disclosure.ai_content": {
"defaultMessage": "AI-generated content"
},
"search.filter_type.advanced.exclude_plugin": {
"defaultMessage": "Exclude plugins"
"search.filter_type.advanced.disclosure.ai_content.usage": {
"defaultMessage": "AI {usage}"
},
"search.filter_type.advanced.disclosure.archived": {
"defaultMessage": "Archived"
},
"search.filter_type.advanced.disclosure.derivative_work": {
"defaultMessage": "Derivative content"
},
"search.filter_type.advanced.disclosure.epilepsy_triggers": {
"defaultMessage": "Photosensitivity triggers"
},
"search.filter_type.advanced.disclosure.paid_features": {
"defaultMessage": "Paid features"
},
"search.filter_type.advanced.disclosure.system_interactions": {
"defaultMessage": "External system interactions"
},
"search.filter_type.advanced.disclosure.telemetry": {
"defaultMessage": "Telemetry"
},
"search.filter_type.environment": {
"defaultMessage": "Environment"
@@ -3998,6 +4127,21 @@
"search.filter_type.shader_loader": {
"defaultMessage": "Loader"
},
"search.photosensitivity-warning-modal.body.1": {
"defaultMessage": "We cannot guarantee that all content on Modrinth has been labeled appropriately."
},
"search.photosensitivity-warning-modal.body.2": {
"defaultMessage": "Content labels for photosensitivity triggers are self-assigned by the creators who upload their content to Modrinth. These projects have not gone through any safety testing."
},
"search.photosensitivity-warning-modal.body.3": {
"defaultMessage": "Using any content on Modrinth is at your own risk. Please stay safe! 💚"
},
"search.photosensitivity-warning-modal.dont-show-again": {
"defaultMessage": "Don't show this again"
},
"search.photosensitivity-warning-modal.title": {
"defaultMessage": "Results are not guaranteed to be safe"
},
"search.server_content_type.modpack": {
"defaultMessage": "Modded"
},
@@ -5339,6 +5483,9 @@
"settings.feature-flags.title": {
"defaultMessage": "Feature flags"
},
"settings.form.group.optional": {
"defaultMessage": "(optional)"
},
"settings.language.categories.default": {
"defaultMessage": "Standard languages"
},
@@ -3791,15 +3791,6 @@
"search.filter_type.advanced": {
"defaultMessage": "Avanzado"
},
"search.filter_type.advanced.exclude_datapack": {
"defaultMessage": "Excluir paquetes de datos"
},
"search.filter_type.advanced.exclude_mod": {
"defaultMessage": "Excluir mods"
},
"search.filter_type.advanced.exclude_plugin": {
"defaultMessage": "Excluir complementos"
},
"search.filter_type.environment": {
"defaultMessage": "Entorno"
},
-9
View File
@@ -3788,15 +3788,6 @@
"search.filter_type.advanced": {
"defaultMessage": "Avanzado"
},
"search.filter_type.advanced.exclude_datapack": {
"defaultMessage": "Excluir paquetes de datos"
},
"search.filter_type.advanced.exclude_mod": {
"defaultMessage": "Excluir mods"
},
"search.filter_type.advanced.exclude_plugin": {
"defaultMessage": "Excluir plugins"
},
"search.filter_type.environment": {
"defaultMessage": "Entorno"
},
-9
View File
@@ -3785,15 +3785,6 @@
"search.filter_type.advanced": {
"defaultMessage": "Avancé"
},
"search.filter_type.advanced.exclude_datapack": {
"defaultMessage": "Exclure les data packs"
},
"search.filter_type.advanced.exclude_mod": {
"defaultMessage": "Exclure les mods"
},
"search.filter_type.advanced.exclude_plugin": {
"defaultMessage": "Exclure les plugins"
},
"search.filter_type.environment": {
"defaultMessage": "Environnement"
},
-9
View File
@@ -3749,15 +3749,6 @@
"search.filter_type.advanced": {
"defaultMessage": "Avanzate"
},
"search.filter_type.advanced.exclude_datapack": {
"defaultMessage": "Escludi pacchetti di dati"
},
"search.filter_type.advanced.exclude_mod": {
"defaultMessage": "Escludi mod"
},
"search.filter_type.advanced.exclude_plugin": {
"defaultMessage": "Escludi plugin"
},
"search.filter_type.environment": {
"defaultMessage": "Ambiente"
},
-3
View File
@@ -3710,9 +3710,6 @@
"search.filter_type.advanced": {
"defaultMessage": "もっと見る"
},
"search.filter_type.advanced.exclude_datapack": {
"defaultMessage": "データパックを除く"
},
"search.filter_type.environment": {
"defaultMessage": "環境"
},
-9
View File
@@ -3776,15 +3776,6 @@
"search.filter_type.advanced": {
"defaultMessage": "고급"
},
"search.filter_type.advanced.exclude_datapack": {
"defaultMessage": "데이터 팩 제외"
},
"search.filter_type.advanced.exclude_mod": {
"defaultMessage": "모드 제외"
},
"search.filter_type.advanced.exclude_plugin": {
"defaultMessage": "플러그인 제외"
},
"search.filter_type.environment": {
"defaultMessage": "실행 환경"
},
-9
View File
@@ -3782,15 +3782,6 @@
"search.filter_type.advanced": {
"defaultMessage": "Gevorderd"
},
"search.filter_type.advanced.exclude_datapack": {
"defaultMessage": "Gegevenspakketten uitsluiten"
},
"search.filter_type.advanced.exclude_mod": {
"defaultMessage": "Mods uitsluiten"
},
"search.filter_type.advanced.exclude_plugin": {
"defaultMessage": "Plug-ins uitsluiten"
},
"search.filter_type.environment": {
"defaultMessage": "Omgeving"
},
-9
View File
@@ -3758,15 +3758,6 @@
"search.filter_type.advanced": {
"defaultMessage": "Zaawansowane"
},
"search.filter_type.advanced.exclude_datapack": {
"defaultMessage": "Wyklucz paczki danych"
},
"search.filter_type.advanced.exclude_mod": {
"defaultMessage": "Wyklucz mody"
},
"search.filter_type.advanced.exclude_plugin": {
"defaultMessage": "Wyklucz pluginy"
},
"search.filter_type.environment": {
"defaultMessage": "Środowisko"
},
-9
View File
@@ -3791,15 +3791,6 @@
"search.filter_type.advanced": {
"defaultMessage": "Avançado"
},
"search.filter_type.advanced.exclude_datapack": {
"defaultMessage": "Excluir pacotes de dados"
},
"search.filter_type.advanced.exclude_mod": {
"defaultMessage": "Excluir mods"
},
"search.filter_type.advanced.exclude_plugin": {
"defaultMessage": "Excluir plugins"
},
"search.filter_type.environment": {
"defaultMessage": "Ambiente"
},
-9
View File
@@ -3704,15 +3704,6 @@
"search.filter_type.advanced": {
"defaultMessage": "Расширенные"
},
"search.filter_type.advanced.exclude_datapack": {
"defaultMessage": "Исключать наборы данных"
},
"search.filter_type.advanced.exclude_mod": {
"defaultMessage": "Исключать моды"
},
"search.filter_type.advanced.exclude_plugin": {
"defaultMessage": "Исключать плагины"
},
"search.filter_type.environment": {
"defaultMessage": "Среда"
},
-9
View File
@@ -3560,15 +3560,6 @@
"search.filter_type.advanced": {
"defaultMessage": "Napredno"
},
"search.filter_type.advanced.exclude_datapack": {
"defaultMessage": "Isključi data pakete"
},
"search.filter_type.advanced.exclude_mod": {
"defaultMessage": "Isključi modove"
},
"search.filter_type.advanced.exclude_plugin": {
"defaultMessage": "Isključi plugine"
},
"search.filter_type.environment": {
"defaultMessage": "Okruže"
},
-6
View File
@@ -3416,12 +3416,6 @@
"search.filter_type.advanced": {
"defaultMessage": "Avancerat"
},
"search.filter_type.advanced.exclude_datapack": {
"defaultMessage": "Exkludera datapaket"
},
"search.filter_type.advanced.exclude_mod": {
"defaultMessage": "Exkludera moddar"
},
"search.filter_type.environment": {
"defaultMessage": "Miljö"
},
-9
View File
@@ -3617,15 +3617,6 @@
"search.filter_type.advanced": {
"defaultMessage": "Gelişmiş"
},
"search.filter_type.advanced.exclude_datapack": {
"defaultMessage": "Veri paketlerini hariç tut"
},
"search.filter_type.advanced.exclude_mod": {
"defaultMessage": "Modları hariç tut"
},
"search.filter_type.advanced.exclude_plugin": {
"defaultMessage": "Eklentileri hariç tut"
},
"search.filter_type.environment": {
"defaultMessage": "Ortam"
},
-9
View File
@@ -3746,15 +3746,6 @@
"search.filter_type.advanced": {
"defaultMessage": "Більше"
},
"search.filter_type.advanced.exclude_datapack": {
"defaultMessage": "Виключити пакети даних"
},
"search.filter_type.advanced.exclude_mod": {
"defaultMessage": "Виключити моди"
},
"search.filter_type.advanced.exclude_plugin": {
"defaultMessage": "Виключити плаґіни"
},
"search.filter_type.environment": {
"defaultMessage": "Середовище"
},
-9
View File
@@ -3791,15 +3791,6 @@
"search.filter_type.advanced": {
"defaultMessage": "高级"
},
"search.filter_type.advanced.exclude_datapack": {
"defaultMessage": "排除数据包"
},
"search.filter_type.advanced.exclude_mod": {
"defaultMessage": "排除模组"
},
"search.filter_type.advanced.exclude_plugin": {
"defaultMessage": "排除插件"
},
"search.filter_type.environment": {
"defaultMessage": "运行环境"
},
-9
View File
@@ -3791,15 +3791,6 @@
"search.filter_type.advanced": {
"defaultMessage": "進階篩選"
},
"search.filter_type.advanced.exclude_datapack": {
"defaultMessage": "排除資料包"
},
"search.filter_type.advanced.exclude_mod": {
"defaultMessage": "排除模組"
},
"search.filter_type.advanced.exclude_plugin": {
"defaultMessage": "排除插件"
},
"search.filter_type.environment": {
"defaultMessage": "環境"
},
@@ -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)
}