project disclosures frontend (#6955)

* begin project disclosures

* new project settings header

* begin disclosure settings, edit content rules

* togglecards

* update phrasing of the rules, prepr

* more disclosure settings structuring

* add functionality toggle

* implement functionality with staging api

* improve project settings head titles

* feat(labrinth): project disclosures model

* feat(labrinth): project disclosures database model

* feat(labrinth): project disclosures get endpoint

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

* feat(labrinth): wrap disclosures in struct

* feat(labrinth): edit project disclosures endpoint

* style(labrinth): cargo fmt

* style(labrinth): fix typo

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

* fix(labrinth): field typo

* chore(labrinth): update query cache

* feat(labrinth): index disclosures in search

* refactor(labrinth): use enum instead of bools

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

* feat(labrinth): change archived status to disclosure

* feat(labrinth): use disclosure for archival status

* fix(labrinth): error type for deserialization

* fix(labrinth): migration timestamp

* fix(labrinth): add disclosures to elasticsearch schema

* disclosures in search

* update photosensitivity warning copy

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

* blue archive banner

* display AI use types

* improve labels

* add modrinth to link

* add placeholder missing disclosure report type

* add AI metadata checking to block image uploads with notice

* update copy

* update section 4 of rules

* Update rule 6 layout

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

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

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

* Update moderation checklist (#7056)

* fix: missing delphi severity (#7054)

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

* add prefix to collect

* Finish up new disclosures stage

* update r4 messages

* new r4 msg, update showcase clarity message

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

* fix: mrpack exporting with zip64 (#7064)

* fix: action bar max width (#7048)

* fix: action bar max width

* fix: width

* changelog

* Rule placeholders with subsections & anchors, update messages.

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

* Add nag for content disclosures

---------

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

* prepr

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

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

* fix(labrinth): dedupe ai usages

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

* feat(labrinth): add interactions field

* style(labrinth): cargo fmt

* feat(labrinth): granular disclosure locking

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

* move disclosures stage earlier

* include consent model info in telemetry disclosure msgs

* prepr + update date

* typo

* qa pass

* search suboptions

* add empty state

* checkboxes in column

* persistent advanced filters

* blog draft

* prepr

* support soft deletion

* reload instead of optimistically updating

* update blog

* TOS update + minor verbiage adjustment

* 45 day grace

---------

Co-authored-by: sychic <47618543+Sychic@users.noreply.github.com>
Co-authored-by: coolbot <76798835+coolbot100s@users.noreply.github.com>
Co-authored-by: ThatGravyBoat <gravy@thatgravyboat.tech>
Co-authored-by: chyzman <chyzalt@gmail.com>
Co-authored-by: Truman Gao <106889354+tdgao@users.noreply.github.com>
This commit is contained in:
Prospector
2026-08-13 09:01:18 -07:00
committed by GitHub
co-authored by ThatGravyBoat chyzman Truman Gao sychic coolbot
parent 06b7c44dcc
commit 755825b09a
230 changed files with 5802 additions and 1268 deletions
@@ -0,0 +1,77 @@
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.title)"
:closable="false"
fade="warning"
max-width="544px"
>
<div class="flex flex-col">
<p class="mb-4 mt-0 leading-normal">
{{ formatMessage(messages.body1) }}
</p>
<p class="mb-4 mt-0 leading-normal">
<IntlFormatted :message-id="messages.body2">
<template #rules="{ children }">
<nuxt-link
to="/legal/rules#generative-ai"
target="_blank"
class="font-semibold text-orange hover:underline"
>
<component :is="() => normalizeChildren(children)" />
</nuxt-link>
</template>
</IntlFormatted>
</p>
<div class="flex justify-end">
<Button type="colored" color="brand" native-type="button" @click="hide">
<CheckCircleIcon class="size-4" />
{{ formatMessage(commonMessages.iUnderstandButton) }}
</Button>
</div>
</div>
</NewModal>
</template>
<script setup lang="ts">
import { CheckCircleIcon } from '@modrinth/assets'
import {
Button,
commonMessages,
defineMessages,
IntlFormatted,
NewModal,
normalizeChildren,
useVIntl,
} from '@modrinth/ui'
import { useTemplateRef } from 'vue'
const { formatMessage } = useVIntl()
const modal = useTemplateRef('modal')
const messages = defineMessages({
title: {
id: 'project.ai-image-warning-modal.title',
defaultMessage: 'AI-generated images not allowed',
},
body1: {
id: 'project.ai-image-warning-modal.body.1',
defaultMessage:
'Using AI-generated images to represent your project is not allowed. Attempting to work around detection may lead to your Modrinth account being suspended.',
},
body2: {
id: 'project.ai-image-warning-modal.body.2',
defaultMessage: `See section 6 of <rules>Modrinth's Content Rules</rules> for more information on our generative AI policy.`,
},
})
function show() {
modal.value?.show()
}
function hide() {
modal.value?.hide()
}
defineExpose({ show, hide })
</script>
@@ -278,11 +278,13 @@ import {
CollapsibleRegion,
type ContentItem,
CopyCode,
formatReportType,
getProjectTypeIcon,
injectModrinthClient,
injectNotificationManager,
useFormatDateTime,
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
import { formatProjectType } from '@modrinth/utils'
import { computed, ref, watch } from 'vue'
@@ -297,6 +299,7 @@ import SharedInstanceReportContext, {
} from './SharedInstanceReportContext.vue'
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
const client = injectModrinthClient()
const auth = await useAuth()
@@ -787,13 +790,9 @@ const reportItemUrl = computed(() => {
}
})
const formattedReportType = computed(() => {
const reportType = props.report.report_type
// some are split by -, some are split by " "
const words = reportType.includes('-') ? reportType.split('-') : reportType.split(' ')
return words.map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
})
const formattedReportType = computed(() =>
formatReportType(formatMessage, props.report.report_type),
)
function copyId() {
navigator.clipboard.writeText(props.report.id).then(() => {
@@ -0,0 +1,86 @@
<script setup lang="ts">
import { MegaphoneIcon } from '@modrinth/assets'
import {
commonMessages,
defineMessages,
IntlFormatted,
normalizeChildren,
SettingsFormGroup,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import DisclosureToggleCard from './DisclosureToggleCard.vue'
import type { DisclosureCardMetaProps, DisclosureLockStatus, NoteDisclosure } from './types'
const model = defineModel<NoteDisclosure>({ required: true })
const props = defineProps<DisclosureCardMetaProps>()
const emit = defineEmits<{
setLockStatus: [status: DisclosureLockStatus]
}>()
const { formatMessage } = useVIntl()
const messages = defineMessages({
title: {
id: 'project.settings.disclosures.advertising.title',
defaultMessage: 'Contains advertisements',
},
description1: {
id: 'project.settings.disclosures.advertising.description.1',
defaultMessage: `You must enable this if your project contains advertisements, sponsorships, or promotions of other works.`,
},
description2: {
id: 'project.settings.disclosures.advertising.description.2',
defaultMessage: `If the promotion has no direct monetary value <emphasis>and</emphasis> it is for something that the average person would consider <italic>relevant</italic> and <italic>unobtrusive</italic> (such as a link to your Modrinth profile in the corner of the settings page for your own mod), we would not consider that an advertisement.`,
},
notePlaceholder: {
id: 'project.settings.disclosures.advertising.note-placeholder',
defaultMessage: 'e.g. Adds the Modrinth SMP server to your server list automatically.',
},
})
</script>
<template>
<DisclosureToggleCard
v-bind="props"
v-model="model.enabled"
:icon="MegaphoneIcon"
:title="formatMessage(messages.title)"
@set-lock-status="emit('setLockStatus', $event)"
>
<p>{{ formatMessage(messages.description1) }}</p>
<p>
<IntlFormatted :message-id="messages.description2">
<template #italic="{ children }">
<span class="italic">
<component :is="() => normalizeChildren(children)" />
</span>
</template>
<template #emphasis="{ children }">
<span class="font-bold italic">
<component :is="() => normalizeChildren(children)" />
</span>
</template>
</IntlFormatted>
</p>
<template #expanded>
<SettingsFormGroup
:title="formatMessage(commonMessages.explanationLabel)"
title-for="advertising-disclosure-note"
>
<StyledInput
id="advertising-disclosure-note"
v-model="model.note"
multiline
:rows="3"
class="max-w-[40rem]"
:disabled="disabled"
:placeholder="formatMessage(messages.notePlaceholder)"
/>
</SettingsFormGroup>
</template>
</DisclosureToggleCard>
</template>
@@ -0,0 +1,125 @@
<script setup lang="ts">
import { SparklesIcon } from '@modrinth/assets'
import {
Checkbox,
commonMessages,
defineMessages,
disclosureAiUsageMessages,
IntlFormatted,
normalizeChildren,
SettingsFormGroup,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import DisclosureToggleCard from './DisclosureToggleCard.vue'
import type { AiDisclosure, AiUsage, DisclosureCardMetaProps, DisclosureLockStatus } from './types'
const model = defineModel<AiDisclosure>({ required: true })
const props = defineProps<DisclosureCardMetaProps>()
const emit = defineEmits<{
setLockStatus: [status: DisclosureLockStatus]
}>()
const { formatMessage } = useVIntl()
const AI_USES: AiUsage[] = ['code', 'assets', 'text', 'functionality']
const messages = defineMessages({
title: {
id: 'project.settings.disclosures.ai.title',
defaultMessage: 'Contains AI-generated content',
},
description: {
id: 'project.settings.disclosures.ai.description',
defaultMessage: `You must enable this if this project contains a substantial amount of AI-generated code, any
assets that are substantially AI-generated, the project's functionality relies on the use of
generative AI, or if any element of your project's page such as description or publishing
relies on generative AI.`,
},
contentRules: {
id: 'project.settings.disclosures.ai.content-rules',
defaultMessage:
"Please refer to Section 6 of <rules>Modrinth's Content Rules</rules> for more information.",
},
typesDescription: {
id: 'project.settings.disclosures.ai.types-description',
defaultMessage: 'What is generative AI being used for?',
},
notePlaceholder: {
id: 'project.settings.disclosures.ai.note-placeholder',
defaultMessage: 'e.g. The Chinese and Arabic translations are AI-generated.',
},
})
function hasUse(use: AiUsage): boolean {
return model.value.uses.includes(use)
}
function setUse(use: AiUsage, enabled: boolean) {
if (enabled) {
if (!model.value.uses.includes(use)) {
model.value.uses = [...model.value.uses, use]
}
return
}
model.value.uses = model.value.uses.filter((entry) => entry !== use)
}
</script>
<template>
<DisclosureToggleCard
v-bind="props"
v-model="model.enabled"
:icon="SparklesIcon"
:title="formatMessage(messages.title)"
:description="formatMessage(messages.description)"
@set-lock-status="emit('setLockStatus', $event)"
>
<p class="text-secondary">
<IntlFormatted :message-id="messages.contentRules">
<template #rules="{ children }">
<nuxt-link
to="/legal/rules#generative-ai"
target="_blank"
class="smart-clickable:allow-pointer-events underline hover:text-primary"
>
<component :is="() => normalizeChildren(children)" />
</nuxt-link>
</template>
</IntlFormatted>
</p>
<template #expanded>
<SettingsFormGroup :title="formatMessage(messages.typesDescription)">
<div class="flex flex-col gap-2">
<Checkbox
v-for="use in AI_USES"
:key="use"
:model-value="hasUse(use)"
:disabled="disabled"
@update:model-value="(enabled) => setUse(use, enabled)"
>
{{ formatMessage(disclosureAiUsageMessages[use]) }}
</Checkbox>
</div>
</SettingsFormGroup>
<SettingsFormGroup
:title="formatMessage(commonMessages.explanationLabel)"
title-for="ai-disclosure-note"
optional
>
<StyledInput
id="ai-disclosure-note"
v-model="model.note"
multiline
:rows="3"
class="max-w-[40rem]"
:disabled="disabled"
:placeholder="formatMessage(messages.notePlaceholder)"
/>
</SettingsFormGroup>
</template>
</DisclosureToggleCard>
</template>
@@ -0,0 +1,86 @@
<script setup lang="ts">
import { ArchiveIcon } from '@modrinth/assets'
import {
ArchivedProjectBanner,
commonMessages,
defineMessages,
SettingsFormGroup,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import DisclosureToggleCard from './DisclosureToggleCard.vue'
import type { DisclosureCardMetaProps, DisclosureLockStatus, NoteDisclosure } from './types'
const model = defineModel<NoteDisclosure>({ required: true })
const props = defineProps<
DisclosureCardMetaProps & {
projectTitle: string
}
>()
const emit = defineEmits<{
setLockStatus: [status: DisclosureLockStatus]
}>()
const { formatMessage } = useVIntl()
const messages = defineMessages({
title: {
id: 'project.settings.disclosures.archived.title',
defaultMessage: 'Archive project',
},
description1: {
id: 'project.settings.disclosures.archived.description.1',
defaultMessage:
'Mark your project as archived to let users know that you are no longer working on it.',
},
description2: {
id: 'project.settings.disclosures.archived.description.2',
defaultMessage:
'Archived projects remain discoverable when their visibility is set to Public. If you wish to de-list your project, set its visibility to Unlisted.',
},
notePlaceholder: {
id: 'project.settings.disclosures.archived.note-placeholder',
defaultMessage: `e.g. I don't have time to maintain this project anymore, feel free to fork it!`,
},
bannerPreview: {
id: 'project.settings.disclosures.archived.banner-preview',
defaultMessage: 'Banner preview',
},
})
</script>
<template>
<DisclosureToggleCard
v-bind="props"
v-model="model.enabled"
:icon="ArchiveIcon"
:title="formatMessage(messages.title)"
@set-lock-status="emit('setLockStatus', $event)"
>
<p>{{ formatMessage(messages.description1) }}</p>
<p>{{ formatMessage(messages.description2) }}</p>
<template #expanded>
<SettingsFormGroup
:title="formatMessage(commonMessages.explanationLabel)"
title-for="archived-disclosure-note"
optional
>
<StyledInput
id="archived-disclosure-note"
v-model="model.note"
multiline
:rows="3"
class="max-w-[40rem]"
:disabled="disabled"
:placeholder="formatMessage(messages.notePlaceholder)"
/>
</SettingsFormGroup>
<SettingsFormGroup :title="formatMessage(messages.bannerPreview)">
<ArchivedProjectBanner :title="projectTitle" :reason="model.note" />
</SettingsFormGroup>
</template>
</DisclosureToggleCard>
</template>
@@ -0,0 +1,179 @@
<script setup lang="ts">
import { GitForkIcon, PlusIcon, TrashIcon } from '@modrinth/assets'
import {
Button,
commonMessages,
defineMessages,
SettingsFormGroup,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { watch } from 'vue'
import DisclosureToggleCard from './DisclosureToggleCard.vue'
import type {
DerivativeDisclosure,
DerivativeSource,
DisclosureCardMetaProps,
DisclosureLockStatus,
} from './types'
const model = defineModel<DerivativeDisclosure>({ required: true })
const props = defineProps<DisclosureCardMetaProps>()
const emit = defineEmits<{
setLockStatus: [status: DisclosureLockStatus]
}>()
const { formatMessage } = useVIntl()
const messages = defineMessages({
title: {
id: 'project.settings.disclosures.derivative.title',
defaultMessage: 'Contains derivative content',
},
description: {
id: 'project.settings.disclosures.derivative.description',
defaultMessage:
'You must enable this if your project is derivative of another project, such as being a fork or containing a substantial amount of someone elses work.',
},
nameLabel: {
id: 'project.settings.disclosures.derivative.name-label',
defaultMessage: 'Name of original work',
},
namePlaceholder: {
id: 'project.settings.disclosures.derivative.name-placeholder',
defaultMessage: 'Example project',
},
linkLabel: {
id: 'project.settings.disclosures.derivative.link-label',
defaultMessage: 'Link to original work',
},
linkPlaceholder: {
id: 'project.settings.disclosures.derivative.link-placeholder',
defaultMessage: 'https://example.com',
},
noteLabel: {
id: 'project.settings.disclosures.derivative.note-label',
defaultMessage: 'Explain how your project is based on the original work',
},
})
function emptyDerivativeSource(): DerivativeSource {
return { label: '' }
}
function updateSource(index: number, patch: Partial<DerivativeSource>) {
model.value.sources = model.value.sources.map((source, i) =>
i === index ? { ...source, ...patch } : source,
)
}
watch(
() => model.value.enabled,
(enabled) => {
if (enabled && model.value.sources.length === 0) {
model.value.sources = [emptyDerivativeSource()]
}
},
)
function addSource() {
model.value.sources = [...model.value.sources, emptyDerivativeSource()]
}
function removeSource(index: number) {
if (model.value.sources.length <= 1) return
model.value.sources = model.value.sources.filter((_, i) => i !== index)
}
function setOptionalField(
index: number,
field: 'link' | 'note',
value: string | number | undefined,
) {
updateSource(index, {
[field]: typeof value === 'string' && value ? value : null,
})
}
</script>
<template>
<DisclosureToggleCard
v-bind="props"
v-model="model.enabled"
:icon="GitForkIcon"
:title="formatMessage(messages.title)"
:description="formatMessage(messages.description)"
@set-lock-status="emit('setLockStatus', $event)"
>
<template #expanded>
<div
v-for="(source, index) in model.sources"
:key="index"
class="relative flex flex-col gap-4 rounded-2xl border border-solid border-surface-4 bg-surface-3 p-4"
>
<div class="absolute right-3 top-3">
<Button
v-if="model.sources.length > 1"
type="quiet"
color="red"
:disabled="disabled"
@click="removeSource(index)"
>
<TrashIcon />
{{ formatMessage(commonMessages.removeButton) }}
</Button>
</div>
<SettingsFormGroup
:title="formatMessage(messages.nameLabel)"
:title-for="`derivative-name-${index}`"
class="max-w-[40rem]"
>
<StyledInput
:id="`derivative-name-${index}`"
:model-value="source.label"
:disabled="disabled"
:placeholder="formatMessage(messages.namePlaceholder)"
@update:model-value="
(value) => updateSource(index, { label: typeof value === 'string' ? value : '' })
"
/>
</SettingsFormGroup>
<SettingsFormGroup
:title="formatMessage(messages.linkLabel)"
:title-for="`derivative-link-${index}`"
class="max-w-[40rem]"
>
<StyledInput
:id="`derivative-link-${index}`"
:model-value="source.link ?? undefined"
type="url"
:disabled="disabled"
:placeholder="formatMessage(messages.linkPlaceholder)"
@update:model-value="(value) => setOptionalField(index, 'link', value)"
/>
</SettingsFormGroup>
<SettingsFormGroup
:title="formatMessage(messages.noteLabel)"
:title-for="`derivative-note-${index}`"
class="max-w-[40rem]"
>
<StyledInput
:id="`derivative-note-${index}`"
:model-value="source.note ?? undefined"
multiline
:rows="3"
:disabled="disabled"
@update:model-value="(value) => setOptionalField(index, 'note', value)"
/>
</SettingsFormGroup>
</div>
<Button class="w-fit" :disabled="disabled" @click="addSource">
<PlusIcon />
{{ formatMessage(commonMessages.addAnotherButton) }}
</Button>
</template>
</DisclosureToggleCard>
</template>
@@ -0,0 +1,146 @@
<script setup lang="ts">
import { CircleSlashIcon, LockIcon, LockOpenIcon } from '@modrinth/assets'
import {
Button,
ButtonGroup,
defineMessages,
IntlFormatted,
normalizeChildren,
SettingsInlineWarning,
SettingsToggleCard,
} from '@modrinth/ui'
import type { Component } from 'vue'
import { computed } from 'vue'
import DisclosureUpdatedBy from './DisclosureUpdatedBy.vue'
import type { DisclosureCardMetaProps, DisclosureLockStatus } from './types'
defineOptions({ inheritAttrs: false })
const props = defineProps<
DisclosureCardMetaProps & {
title: string
icon?: Component
description?: string
}
>()
const emit = defineEmits<{
setLockStatus: [status: DisclosureLockStatus]
}>()
const enabled = defineModel<boolean>({ required: true })
const resolvedLockStatus = computed(() => props.lockStatus ?? 'unlocked')
const LOCK_STATUSES = [
{ status: 'unlocked', label: 'Unlocked' },
{ status: 'cannot_disable', label: 'Cannot disable' },
{ status: 'fully_locked', label: 'Fully locked' },
] as const
const messages = defineMessages({
cannotDisableWarning: {
id: 'project.settings.disclosures.lock-status.cannot-disable.warning',
defaultMessage:
'Please <contact-support-link>contact support</contact-support-link> if this disclosure needs to be removed.',
},
fullyLockedWarning: {
id: 'project.settings.disclosures.lock-status.fully-locked.warning',
defaultMessage:
'Please <contact-support-link>contact support</contact-support-link> if this disclosure needs to be edited or removed.',
},
disabledLockedWarning: {
id: 'project.settings.disclosures.lock-status.disabled.warning',
defaultMessage:
'This disclosure has been locked by the moderators. Please <contact-support-link>contact support</contact-support-link> if you believe this is in error.',
},
})
const lockWarningMessage = computed(() => {
if (resolvedLockStatus.value === 'cannot_disable') {
return enabled.value ? messages.cannotDisableWarning : null
}
if (resolvedLockStatus.value === 'fully_locked') {
return enabled.value ? messages.fullyLockedWarning : messages.disabledLockedWarning
}
return null
})
const showLockWarning = computed(() => !!lockWarningMessage.value)
const showModeratorLockControls = computed(() => !!props.showLockControls)
const showFooter = computed(
() => !!props.updatedAt || showModeratorLockControls.value || showLockWarning.value,
)
function setLockStatus(status: DisclosureLockStatus) {
if (resolvedLockStatus.value === status) return
emit('setLockStatus', status)
}
</script>
<template>
<SettingsToggleCard
v-model="enabled"
:disabled="disabled"
:toggle-disabled="toggleDisabled"
:icon="icon"
:title="title"
:description="description"
>
<slot />
<template v-if="$slots.expanded" #expanded>
<slot name="expanded" />
</template>
<template v-if="showFooter" #footer>
<div class="flex flex-col gap-3">
<SettingsInlineWarning v-if="showLockWarning && lockWarningMessage">
<IntlFormatted :message-id="lockWarningMessage">
<template #contact-support-link="{ children }">
<a
class="smart-clickable:allow-pointer-events text-orange underline hover:brightness-110"
href="https://support.modrinth.com"
target="_blank"
rel="noopener noreferrer"
>
<component :is="() => normalizeChildren(children)" />
</a>
</template>
</IntlFormatted>
</SettingsInlineWarning>
<div class="flex flex-wrap items-center justify-between gap-3">
<DisclosureUpdatedBy
v-if="updatedAt"
:updated-at="updatedAt"
:updated-by="updatedBy"
:set-by-moderator="setByModerator"
/>
<span v-else />
<ButtonGroup v-if="showModeratorLockControls" class="ml-auto" label="Lock status">
<Button
v-for="{ status, label } in LOCK_STATUSES"
:key="status"
:color="
status === 'cannot_disable'
? 'orange'
: status === 'fully_locked'
? 'red'
: undefined
"
:type="status !== 'unlocked' ? 'colored-text' : undefined"
:disabled="disabled || resolvedLockStatus === status"
@click="setLockStatus(status)"
>
<LockOpenIcon v-if="status === 'unlocked'" />
<CircleSlashIcon v-else-if="status === 'cannot_disable'" />
<LockIcon v-else />
{{ label }}
</Button>
</ButtonGroup>
</div>
</div>
</template>
</SettingsToggleCard>
</template>
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { ScaleIcon } from '@modrinth/assets'
import {
AutoLink,
Avatar,
defineMessages,
IntlFormatted,
useFormatDateTime,
useVIntl,
} from '@modrinth/ui'
import { computed } from 'vue'
import type { DisclosureUpdatedByUser } from './types'
const props = defineProps<{
updatedAt: string
updatedBy?: DisclosureUpdatedByUser | null
setByModerator?: boolean
}>()
const { formatMessage } = useVIntl()
const formatDate = useFormatDateTime({ dateStyle: 'long' })
const formatDateTime = useFormatDateTime({
dateStyle: 'long',
timeStyle: 'short',
})
const flags = useFeatureFlags()
const showModeratorLabel = computed(
() => !!props.setByModerator && (!props.updatedBy || flags.value.showModeratorProjectMemberUi),
)
const messages = defineMessages({
lastUpdated: {
id: 'project.settings.disclosures.last-updated',
defaultMessage: 'Last updated on {date} by {user}',
},
updatedByModerator: {
id: 'project.settings.disclosures.last-updated.moderator',
defaultMessage: 'Moderator',
},
})
</script>
<template>
<div class="inline-flex flex-wrap items-center gap-1 text-secondary">
<IntlFormatted :message-id="messages.lastUpdated">
<template #date>
<span v-tooltip="formatDateTime(updatedAt)">{{ formatDate(updatedAt) }}</span>
</template>
<template #user>
<span v-if="showModeratorLabel" class="inline-flex items-center gap-1 text-orange">
<ScaleIcon class="size-5 shrink-0" />
{{ formatMessage(messages.updatedByModerator) }}
</span>
<AutoLink
v-else-if="updatedBy"
:to="`/user/${updatedBy.username}`"
class="inline-flex min-w-0 max-w-full items-center gap-1 text-contrast hover:underline"
>
<Avatar
v-if="updatedBy.avatar_url"
:src="updatedBy.avatar_url"
:alt="updatedBy.username"
size="20px"
class="shrink-0"
circle
/>
<span class="truncate">{{ updatedBy.username }}</span>
</AutoLink>
</template>
</IntlFormatted>
</div>
</template>
@@ -0,0 +1,100 @@
<script setup lang="ts">
import { CircleDollarSignIcon, ListPlusIcon, XIcon } from '@modrinth/assets'
import {
Button,
commonMessages,
defineMessages,
IconButton,
SettingsFormGroup,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { watch } from 'vue'
import DisclosureToggleCard from './DisclosureToggleCard.vue'
import type { DisclosureCardMetaProps, DisclosureLockStatus, PaidFeaturesDisclosure } from './types'
const model = defineModel<PaidFeaturesDisclosure>({ required: true })
const props = defineProps<DisclosureCardMetaProps>()
const emit = defineEmits<{
setLockStatus: [status: DisclosureLockStatus]
}>()
const { formatMessage } = useVIntl()
const messages = defineMessages({
title: {
id: 'project.settings.disclosures.paid-features.title',
defaultMessage: 'Contains paid features',
},
description: {
id: 'project.settings.disclosures.paid-features.description',
defaultMessage:
'You must enable this if your project contains features that can be obtained by spending real-world money.',
},
featuresDescription: {
id: 'project.settings.disclosures.paid-features.features-description',
defaultMessage: 'List the types of paid features',
},
featurePlaceholder: {
id: 'project.settings.disclosures.paid-features.feature-placeholder',
defaultMessage: 'e.g. Cosmetics available as Patreon reward',
},
})
watch(
() => model.value.enabled,
(enabled) => {
if (enabled && model.value.features.length === 0) {
model.value.features = ['']
}
},
)
function addFeature() {
model.value.features = [...model.value.features, '']
}
function removeFeature(index: number) {
if (model.value.features.length <= 1) return
model.value.features = model.value.features.filter((_, i) => i !== index)
}
</script>
<template>
<DisclosureToggleCard
v-bind="props"
v-model="model.enabled"
:icon="CircleDollarSignIcon"
:title="formatMessage(messages.title)"
:description="formatMessage(messages.description)"
@set-lock-status="emit('setLockStatus', $event)"
>
<template #expanded>
<SettingsFormGroup :title="formatMessage(messages.featuresDescription)">
<div v-for="(_, index) in model.features" :key="index" class="flex items-center gap-2">
<StyledInput
v-model="model.features[index]"
class="min-w-0 flex-1"
:disabled="disabled"
:placeholder="formatMessage(messages.featurePlaceholder)"
/>
<IconButton
v-if="model.features.length > 1"
:label="formatMessage(commonMessages.removeButton)"
:disabled="disabled"
@click="removeFeature(index)"
>
<XIcon />
</IconButton>
</div>
</SettingsFormGroup>
<Button class="w-fit" :disabled="disabled" @click="addFeature">
<ListPlusIcon />
{{ formatMessage(commonMessages.addAnotherButton) }}
</Button>
</template>
</DisclosureToggleCard>
</template>
@@ -0,0 +1,68 @@
<script setup lang="ts">
import { EyeIcon } from '@modrinth/assets'
import {
commonMessages,
defineMessages,
SettingsFormGroup,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import DisclosureToggleCard from './DisclosureToggleCard.vue'
import type { DisclosureCardMetaProps, DisclosureLockStatus, NoteDisclosure } from './types'
const model = defineModel<NoteDisclosure>({ required: true })
const props = defineProps<DisclosureCardMetaProps>()
const emit = defineEmits<{
setLockStatus: [status: DisclosureLockStatus]
}>()
const { formatMessage } = useVIntl()
const messages = defineMessages({
title: {
id: 'project.settings.disclosures.photosensitivity.title',
defaultMessage: 'Photosensitivity warning',
},
description: {
id: 'project.settings.disclosures.photosensitivity.description',
defaultMessage:
'Enable this if your project contains anything that you think may be dangerous to certain people who are sensitive to flashing lights or patterns.',
},
notePlaceholder: {
id: 'project.settings.disclosures.photosensitivity.note-placeholder',
defaultMessage:
'e.g. It adds a flashlight item that has a strobe mode. It can be disabled in Accessibility settings in-game.',
},
})
</script>
<template>
<DisclosureToggleCard
v-bind="props"
v-model="model.enabled"
:icon="EyeIcon"
:title="formatMessage(messages.title)"
:description="formatMessage(messages.description)"
@set-lock-status="emit('setLockStatus', $event)"
>
<template #expanded>
<SettingsFormGroup
:title="formatMessage(commonMessages.explanationLabel)"
title-for="photosensitivity-disclosure-note"
>
<StyledInput
id="photosensitivity-disclosure-note"
v-model="model.note"
multiline
:rows="3"
class="max-w-[40rem]"
:disabled="disabled"
:placeholder="formatMessage(messages.notePlaceholder)"
/>
</SettingsFormGroup>
</template>
</DisclosureToggleCard>
</template>
@@ -0,0 +1,69 @@
<script setup lang="ts">
import { CircuitBoardIcon } from '@modrinth/assets'
import { defineMessages, SettingsFormGroup, StyledInput, useVIntl } from '@modrinth/ui'
import DisclosureToggleCard from './DisclosureToggleCard.vue'
import type {
DisclosureCardMetaProps,
DisclosureLockStatus,
SystemInteractionsDisclosure,
} from './types'
const model = defineModel<SystemInteractionsDisclosure>({ required: true })
const props = defineProps<DisclosureCardMetaProps>()
const emit = defineEmits<{
setLockStatus: [status: DisclosureLockStatus]
}>()
const { formatMessage } = useVIntl()
const messages = defineMessages({
title: {
id: 'project.settings.disclosures.system-interactions.title',
defaultMessage: 'External system interactions',
},
description: {
id: 'project.settings.disclosures.system-interactions.description',
defaultMessage:
"You must enable this if your project reads or edits things on the user's system outside of the game.",
},
noteLabel: {
id: 'project.settings.disclosures.system-interactions.note-label',
defaultMessage: 'Describe the external system interactions',
},
notePlaceholder: {
id: 'project.settings.disclosures.system-interactions.note-placeholder',
defaultMessage: 'e.g. It adds a file to the desktop called wake_up.txt',
},
})
</script>
<template>
<DisclosureToggleCard
v-bind="props"
v-model="model.enabled"
:icon="CircuitBoardIcon"
:title="formatMessage(messages.title)"
:description="formatMessage(messages.description)"
@set-lock-status="emit('setLockStatus', $event)"
>
<template #expanded>
<SettingsFormGroup
:title="formatMessage(messages.noteLabel)"
title-for="system-interactions-disclosure-note"
>
<StyledInput
id="system-interactions-disclosure-note"
v-model="model.note"
multiline
:rows="3"
class="max-w-[40rem]"
:disabled="disabled"
:placeholder="formatMessage(messages.notePlaceholder)"
/>
</SettingsFormGroup>
</template>
</DisclosureToggleCard>
</template>
@@ -0,0 +1,132 @@
<script setup lang="ts">
import { ListPlusIcon, RadioTowerIcon, TrashIcon } from '@modrinth/assets'
import {
Button,
Chips,
commonMessages,
defineMessages,
disclosureTelemetryConsentMessages,
IconButton,
SettingsFormGroup,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { watch } from 'vue'
import DisclosureToggleCard from './DisclosureToggleCard.vue'
import type {
DisclosureCardMetaProps,
DisclosureLockStatus,
TelemetryConsent,
TelemetryDisclosure,
} from './types'
const model = defineModel<TelemetryDisclosure>({ required: true })
const props = defineProps<DisclosureCardMetaProps>()
const emit = defineEmits<{
setLockStatus: [status: DisclosureLockStatus]
}>()
const { formatMessage } = useVIntl()
const CONSENT_MODELS: TelemetryConsent[] = ['opt_in', 'opt_out', 'always_active']
const messages = defineMessages({
title: {
id: 'project.settings.disclosures.telemetry.title',
defaultMessage: 'Contains telemetry',
},
description: {
id: 'project.settings.disclosures.telemetry.description',
defaultMessage:
'You must enable this if your project sends usage data back to yourself or a third party.',
},
consentDescription: {
id: 'project.settings.disclosures.telemetry.consent-description',
defaultMessage: 'What is the consent model of your telemetry?',
},
dataLabel: {
id: 'project.settings.disclosures.telemetry.data-label',
defaultMessage: 'What data is collected?',
},
dataDescription: {
id: 'project.settings.disclosures.telemetry.data-description',
defaultMessage:
'You can either add a privacy policy, or a list of types of data that are collected. Remember to mention if it is anonymous or contains personally identifiable information (PII).',
},
dataPlaceholder: {
id: 'project.settings.disclosures.telemetry.data-placeholder',
defaultMessage:
'e.g. Anonymous launch analytics to track Minecraft version and mod loader usage.',
},
})
watch(
() => model.value.enabled,
(enabled) => {
if (enabled && model.value.entries.length === 0) {
model.value.entries = ['']
}
},
)
function addEntry() {
model.value.entries = [...model.value.entries, '']
}
function removeEntry(index: number) {
if (model.value.entries.length <= 1) return
model.value.entries = model.value.entries.filter((_, i) => i !== index)
}
</script>
<template>
<DisclosureToggleCard
v-bind="props"
v-model="model.enabled"
:icon="RadioTowerIcon"
:title="formatMessage(messages.title)"
:description="formatMessage(messages.description)"
@set-lock-status="emit('setLockStatus', $event)"
>
<template #expanded>
<SettingsFormGroup :title="formatMessage(messages.consentDescription)">
<Chips
v-model="model.consent"
:items="CONSENT_MODELS"
:capitalize="false"
:format-label="
(item: TelemetryConsent) => formatMessage(disclosureTelemetryConsentMessages[item])
"
/>
</SettingsFormGroup>
<SettingsFormGroup
:title="formatMessage(messages.dataLabel)"
:description="formatMessage(messages.dataDescription)"
>
<div v-for="(_, index) in model.entries" :key="index" class="flex items-center gap-2">
<StyledInput
v-model="model.entries[index]"
class="min-w-0 flex-1"
:disabled="disabled"
:placeholder="formatMessage(messages.dataPlaceholder)"
/>
<IconButton
v-if="model.entries.length > 1"
:label="formatMessage(commonMessages.removeButton)"
:disabled="disabled"
@click="removeEntry(index)"
>
<TrashIcon />
</IconButton>
</div>
</SettingsFormGroup>
<Button class="w-fit" :disabled="disabled" @click="addEntry">
<ListPlusIcon />
{{ formatMessage(commonMessages.addAnotherButton) }}
</Button>
</template>
</DisclosureToggleCard>
</template>
@@ -0,0 +1,322 @@
import type { Labrinth } from '@modrinth/api-client'
import {
isActiveDisclosure,
isDisclosureCompatibleWithProjectTypes,
PROJECT_DISCLOSURE_TYPES,
} from '@modrinth/ui'
import type {
DisclosureFormState,
DisclosureLockStatus,
DisclosureOf,
DisclosureType,
NoteDisclosure,
ProjectDisclosure,
ProjectDisclosureData,
} from './types'
function findDisclosure<T extends DisclosureType>(
disclosures: ProjectDisclosureData[],
type: T,
): DisclosureOf<T> | undefined {
return disclosures.find((disclosure): disclosure is DisclosureOf<T> => disclosure.type === type)
}
type NoteDisclosureType = 'advertisements' | 'epilepsy_triggers' | 'archived'
function createNoteModel(
disclosures: ProjectDisclosureData[],
type: NoteDisclosureType,
): NoteDisclosure {
const disclosure = findDisclosure(disclosures, type)
return {
enabled: isActiveDisclosure(disclosure),
note: disclosure?.note ?? '',
}
}
function nonemptyOrPlaceholder(values: string[]): string[] {
return values.length > 0 ? [...values] : ['']
}
function createLockStatuses(
disclosures: ProjectDisclosureData[],
): Record<DisclosureType, DisclosureLockStatus> {
const lockStatuses = Object.fromEntries(
PROJECT_DISCLOSURE_TYPES.map((type) => [type, 'unlocked']),
) as Record<DisclosureType, DisclosureLockStatus>
for (const disclosure of disclosures) {
lockStatuses[disclosure.type] = disclosure.lock_status
}
return lockStatuses
}
export function disclosuresToForm(disclosures: ProjectDisclosureData[]): DisclosureFormState {
const ai = findDisclosure(disclosures, 'ai_content')
const paidFeatures = findDisclosure(disclosures, 'paid_features')
const telemetry = findDisclosure(disclosures, 'telemetry')
const derivative = findDisclosure(disclosures, 'derivative_work')
const systemInteractions = findDisclosure(disclosures, 'system_interactions')
return {
ai: {
enabled: isActiveDisclosure(ai),
uses: ai ? [...(ai.uses ?? [])] : [],
note: ai?.note ?? '',
},
advertising: createNoteModel(disclosures, 'advertisements'),
paidFeatures: {
enabled: isActiveDisclosure(paidFeatures),
features: paidFeatures ? nonemptyOrPlaceholder(paidFeatures.features) : [],
},
telemetry: {
enabled: isActiveDisclosure(telemetry),
consent: telemetry?.consent ?? 'opt_in',
entries: telemetry ? nonemptyOrPlaceholder(telemetry.data_collected) : [],
},
derivative: {
enabled: isActiveDisclosure(derivative),
sources: derivative ? derivative.sources.map((source) => ({ ...source })) : [],
},
photosensitivity: createNoteModel(disclosures, 'epilepsy_triggers'),
systemInteractions: {
enabled: isActiveDisclosure(systemInteractions),
note: systemInteractions?.note ?? '',
interactions: systemInteractions ? [...systemInteractions.interactions] : [],
},
archived: createNoteModel(disclosures, 'archived'),
lockStatuses: createLockStatuses(disclosures),
}
}
export function findDisclosureData<T extends DisclosureType>(
disclosures: ProjectDisclosureData[] | undefined,
type: T,
): DisclosureOf<T> | undefined {
if (!disclosures) return undefined
return findDisclosure(disclosures, type)
}
export function formToDisclosure(
form: DisclosureFormState,
type: DisclosureType,
): ProjectDisclosure {
switch (type) {
case 'ai_content':
return {
type: 'ai_content',
uses: [...form.ai.uses],
note: form.ai.note.trim() || null,
}
case 'advertisements':
return { type: 'advertisements', note: form.advertising.note.trim() || null }
case 'paid_features':
return {
type: 'paid_features',
features: form.paidFeatures.features.map((feature) => feature.trim()).filter(Boolean),
}
case 'telemetry':
return {
type: 'telemetry',
consent: form.telemetry.consent,
data_collected: form.telemetry.entries.map((entry) => entry.trim()).filter(Boolean),
}
case 'derivative_work':
return {
type: 'derivative_work',
sources: form.derivative.sources.map((source) => ({
label: source.label.trim(),
link: source.link?.trim() || null,
note: source.note?.trim() || null,
})),
}
case 'epilepsy_triggers':
return { type: 'epilepsy_triggers', note: form.photosensitivity.note.trim() || null }
case 'system_interactions':
return {
type: 'system_interactions',
interactions: [...form.systemInteractions.interactions],
note: form.systemInteractions.note.trim() || null,
}
case 'archived':
return { type: 'archived', note: form.archived.note.trim() || null }
}
}
export function formToDisclosures(form: DisclosureFormState): ProjectDisclosure[] {
const set: ProjectDisclosure[] = []
if (form.ai.enabled) {
set.push(formToDisclosure(form, 'ai_content'))
}
if (form.advertising.enabled) {
set.push(formToDisclosure(form, 'advertisements'))
}
if (form.paidFeatures.enabled) {
set.push(formToDisclosure(form, 'paid_features'))
}
if (form.telemetry.enabled) {
set.push(formToDisclosure(form, 'telemetry'))
}
if (form.derivative.enabled) {
set.push(formToDisclosure(form, 'derivative_work'))
}
if (form.photosensitivity.enabled) {
set.push(formToDisclosure(form, 'epilepsy_triggers'))
}
if (form.systemInteractions.enabled) {
set.push(formToDisclosure(form, 'system_interactions'))
}
if (form.archived.enabled) {
set.push(formToDisclosure(form, 'archived'))
}
return set
}
export function toModifyRequests(
form: DisclosureFormState,
previous: DisclosureFormState,
): Labrinth.Projects.v3.ModifyProjectDisclosures[] {
const next = formToDisclosures(form)
const previousDisclosures = formToDisclosures(previous)
const previousByType = new Map(
previousDisclosures.map((disclosure) => [disclosure.type, disclosure]),
)
const nextTypes = new Set(next.map((disclosure) => disclosure.type))
const remove: DisclosureType[] = []
const contentOnly: ProjectDisclosure[] = []
const lockChangeGroups = new Map<DisclosureLockStatus, ProjectDisclosure[]>()
const disabledLockGroups = new Map<DisclosureLockStatus, ProjectDisclosure[]>()
for (const disclosure of next) {
const existing = previousByType.get(disclosure.type)
const contentChanged = !existing || JSON.stringify(existing) !== JSON.stringify(disclosure)
const previousLock = previous.lockStatuses[disclosure.type] ?? 'unlocked'
const nextLock = form.lockStatuses[disclosure.type] ?? 'unlocked'
const lockChanged = previousLock !== nextLock
if (!contentChanged && !lockChanged) {
continue
}
if (lockChanged) {
const group = lockChangeGroups.get(nextLock) ?? []
group.push(disclosure)
lockChangeGroups.set(nextLock, group)
} else {
contentOnly.push(disclosure)
}
}
for (const type of PROJECT_DISCLOSURE_TYPES) {
if (nextTypes.has(type)) {
continue
}
const previousLock = previous.lockStatuses[type] ?? 'unlocked'
const nextLock = form.lockStatuses[type] ?? 'unlocked'
const lockChanged = previousLock !== nextLock
const wasEnabled = previousByType.has(type)
if (wasEnabled && !lockChanged) {
remove.push(type)
continue
}
if (!lockChanged) {
continue
}
const group = disabledLockGroups.get(nextLock) ?? []
group.push(formToDisclosure(form, type))
disabledLockGroups.set(nextLock, group)
}
const requests: Labrinth.Projects.v3.ModifyProjectDisclosures[] = []
if (remove.length > 0) {
requests.push({ set: [], remove })
}
if (contentOnly.length > 0) {
requests.push({ set: contentOnly, remove: [] })
}
for (const [lockStatus, set] of lockChangeGroups) {
requests.push({ set, remove: [], lock_status: lockStatus })
}
for (const [lockStatus, set] of disabledLockGroups) {
requests.push({ set, remove: [], lock_status: lockStatus })
requests.push({ set: [], remove: set.map((disclosure) => disclosure.type) })
}
return requests
}
export function getDisclosureFormSnapshot(form: DisclosureFormState) {
return {
disclosures: formToDisclosures(form),
lockStatuses: { ...form.lockStatuses },
}
}
export type DisclosureFormIssue =
| 'advertising-note'
| 'paid-features-empty'
| 'telemetry-empty'
| 'derivative-empty'
| 'derivative-source-label'
| 'photosensitivity-note'
| 'system-interactions-note'
export function getDisclosureFormIssues(
form: DisclosureFormState,
projectTypes?: readonly string[],
): DisclosureFormIssue[] {
const issues: DisclosureFormIssue[] = []
const missingNote = (model: NoteDisclosure) => model.enabled && !model.note.trim()
const compatible = (type: DisclosureType) =>
!projectTypes || isDisclosureCompatibleWithProjectTypes(type, projectTypes)
if (compatible('advertisements') && missingNote(form.advertising)) {
issues.push('advertising-note')
}
if (
compatible('paid_features') &&
form.paidFeatures.enabled &&
!form.paidFeatures.features.some((feature) => feature.trim())
) {
issues.push('paid-features-empty')
}
if (
compatible('telemetry') &&
form.telemetry.enabled &&
!form.telemetry.entries.some((entry) => entry.trim())
) {
issues.push('telemetry-empty')
}
if (compatible('derivative_work') && form.derivative.enabled) {
if (form.derivative.sources.length === 0) {
issues.push('derivative-empty')
} else if (form.derivative.sources.some((source) => !source.label?.trim())) {
issues.push('derivative-source-label')
}
}
if (compatible('epilepsy_triggers') && missingNote(form.photosensitivity)) {
issues.push('photosensitivity-note')
}
if (
compatible('system_interactions') &&
form.systemInteractions.enabled &&
!form.systemInteractions.note.trim()
) {
issues.push('system-interactions-note')
}
return issues
}
@@ -0,0 +1,12 @@
export { default as AdvertisingDisclosureCard } from './AdvertisingDisclosureCard.vue'
export { default as AiDisclosureCard } from './AiDisclosureCard.vue'
export { default as ArchivedDisclosureCard } from './ArchivedDisclosureCard.vue'
export { default as DerivativeDisclosureCard } from './DerivativeDisclosureCard.vue'
export { default as DisclosureToggleCard } from './DisclosureToggleCard.vue'
export { default as DisclosureUpdatedBy } from './DisclosureUpdatedBy.vue'
export * from './form'
export { default as PaidFeaturesDisclosureCard } from './PaidFeaturesDisclosureCard.vue'
export { default as PhotosensitivityDisclosureCard } from './PhotosensitivityDisclosureCard.vue'
export { default as SystemInteractionsDisclosureCard } from './SystemInteractionsDisclosureCard.vue'
export { default as TelemetryDisclosureCard } from './TelemetryDisclosureCard.vue'
export * from './types'
@@ -0,0 +1,71 @@
import type { Labrinth } from '@modrinth/api-client'
export type TelemetryConsent = Labrinth.Projects.v3.TelemetryConsent
export type AiUsage = Labrinth.Projects.v3.AiUsage
export type DerivativeSource = Labrinth.Projects.v3.DerivativeSource
export type DisclosureLockStatus = Labrinth.Projects.v3.DisclosureLockStatus
export type ProjectDisclosure = Labrinth.Projects.v3.ProjectDisclosure
export type ProjectDisclosureData = Labrinth.Projects.v3.ProjectDisclosureData
export type DisclosureType = Labrinth.Projects.v3.ProjectDisclosureType
export type DisclosureOf<T extends DisclosureType> = Labrinth.Projects.v3.ProjectDisclosureOf<T>
export type NoteDisclosure = {
enabled: boolean
note: string
}
export type SystemInteractionsDisclosure = {
enabled: boolean
note: string
interactions: string[]
}
export type AiDisclosure = {
enabled: boolean
uses: AiUsage[]
note: string
}
export type PaidFeaturesDisclosure = {
enabled: boolean
features: string[]
}
export type TelemetryDisclosure = {
enabled: boolean
consent: TelemetryConsent
entries: string[]
}
export type DerivativeDisclosure = {
enabled: boolean
sources: DerivativeSource[]
}
export type DisclosureFormState = {
ai: AiDisclosure
advertising: NoteDisclosure
paidFeatures: PaidFeaturesDisclosure
telemetry: TelemetryDisclosure
derivative: DerivativeDisclosure
photosensitivity: NoteDisclosure
systemInteractions: SystemInteractionsDisclosure
archived: NoteDisclosure
lockStatuses: Record<DisclosureType, DisclosureLockStatus>
}
export type DisclosureUpdatedByUser = {
id: string
username: string
avatar_url?: string | null
}
export type DisclosureCardMetaProps = {
disabled?: boolean
toggleDisabled?: boolean
updatedAt?: string | null
updatedBy?: DisclosureUpdatedByUser | null
setByModerator?: boolean
lockStatus?: DisclosureLockStatus | null
showLockControls?: boolean
}
@@ -90,7 +90,14 @@
</div>
<div class="report-type">
<Badge v-if="report.closed" type="closed" />
<Badge :type="`Reported for ${report.report_type}`" color="orange" />
<Badge
:type="
formatMessage(messages.reportedFor, {
type: formatReportType(formatMessage, report.report_type),
})
"
color="orange"
/>
</div>
<div v-if="showMessage" class="markdown-body" v-html="renderHighlightedString(report.body)" />
<ThreadSummary
@@ -125,12 +132,22 @@
<script setup>
import { BoxesIcon, ReportIcon, UnknownIcon, VersionIcon } from '@modrinth/assets'
import { Avatar, Badge, CopyCode, useFormatDateTime, useRelativeTime } from '@modrinth/ui'
import {
Avatar,
Badge,
CopyCode,
defineMessages,
formatReportType,
useFormatDateTime,
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
import { formatProjectType, renderHighlightedString } from '@modrinth/utils'
import ThreadSummary from '~/components/ui/thread/ThreadSummary.vue'
import { getProjectTypeForUrl } from '~/helpers/projects.js'
const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()
const formatDateTime = useFormatDateTime({
timeStyle: 'short',
@@ -165,6 +182,13 @@ defineProps({
})
const flags = useFeatureFlags()
const messages = defineMessages({
reportedFor: {
id: 'report.reported-for',
defaultMessage: 'Reported for {type}',
},
})
</script>
<style lang="scss" scoped>
@@ -1,13 +1,20 @@
<template>
<template v-if="moderation">
<Chips v-model="reasonFilter" :items="reasons" />
<Chips
v-model="reasonFilter"
:items="reasons"
:format-label="formatReasonLabel"
:capitalize="false"
/>
<p v-if="reports.length === MAX_REPORTS" class="text-red">
There are at least {{ MAX_REPORTS }} open reports. This page is at its max reports and will
not show any more recent ones.
</p>
<p v-else-if="reasonFilter === 'All'">There are {{ filteredReports.length }} open reports.</p>
<p v-else>
There are {{ filteredReports.length }}/{{ reports.length }} open '{{ reasonFilter }}' reports.
There are {{ filteredReports.length }}/{{ reports.length }} open '{{
formatReportType(formatMessage, reasonFilter)
}}' reports.
</p>
</template>
<ReportInfo
@@ -24,7 +31,7 @@
<p v-if="filteredReports.length === 0">You don't have any active reports.</p>
</template>
<script setup>
import { Chips, injectModrinthClient } from '@modrinth/ui'
import { Chips, formatReportType, injectModrinthClient, useVIntl } from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import { computed, ref } from 'vue'
@@ -43,10 +50,16 @@ const props = defineProps({
},
})
const { formatMessage } = useVIntl()
const client = injectModrinthClient()
const viewMode = ref('open')
const reasonFilter = ref('All')
const formatReasonLabel = (reason) => {
if (reason === 'All') return reason
return formatReportType(formatMessage, reason)
}
const MAX_REPORTS = 1500
const { data: rawReportsData } = useQuery({