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',