implement functionality with staging api

This commit is contained in:
Prospector
2026-08-02 16:52:19 -07:00
parent 21a31b337f
commit 57be502f99
26 changed files with 1620 additions and 385 deletions
+1 -1
View File
@@ -54,7 +54,7 @@ export default {
},
},
created() {
if (this.items.length > 0 && this.neverEmpty) {
if (this.items.length > 0 && this.neverEmpty && !this.modelValue) {
this.selected = this.items[0]
}
},
@@ -0,0 +1,87 @@
<script setup lang="ts">
import { MegaphoneIcon } from '@modrinth/assets'
import {
defineMessages,
IntlFormatted,
normalizeChildren,
SettingsFormGroup,
SettingsToggleCard,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import type { NoteDisclosure } from './types'
const model = defineModel<NoteDisclosure>({ required: true })
defineProps<{
disabled?: boolean
}>()
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.`,
},
noteDescription: {
id: 'project.settings.disclosures.advertising.note-description',
defaultMessage:
'Please explain how your project utilizes advertising so that users can know what to expect.',
},
notePlaceholder: {
id: 'project.settings.disclosures.advertising.note-placeholder',
defaultMessage: 'e.g. Adds the Modrinth SMP server to your server list automatically.',
},
})
</script>
<template>
<SettingsToggleCard
v-model="model.enabled"
:disabled="disabled"
:icon="MegaphoneIcon"
:title="formatMessage(messages.title)"
>
<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(messages.noteDescription)"
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>
</SettingsToggleCard>
</template>
@@ -0,0 +1,147 @@
<script setup lang="ts">
import { SparklesIcon } from '@modrinth/assets'
import {
Checkbox,
defineMessages,
IntlFormatted,
normalizeChildren,
SettingsFormGroup,
SettingsToggleCard,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import type { AiDisclosure, AiUsage } from './types'
const model = defineModel<AiDisclosure>({ required: true })
defineProps<{
disabled?: boolean
}>()
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 Modrinth's <rules>Content Rules</rules> for more information.",
},
typesDescription: {
id: 'project.settings.disclosures.ai.types-description',
defaultMessage: 'Select what this project uses generative AI for.',
},
typeCode: {
id: 'project.settings.disclosures.ai.types-code',
defaultMessage: 'Code',
},
typeAssets: {
id: 'project.settings.disclosures.ai.types-assets',
defaultMessage: 'Assets',
},
typeText: {
id: 'project.settings.disclosures.ai.types-text',
defaultMessage: 'Text',
},
typeFunctionality: {
id: 'project.settings.disclosures.ai.types-functionality',
defaultMessage: 'Functionality',
},
noteDescription: {
id: 'project.settings.disclosures.ai.note-description',
defaultMessage:
'You may optionally provide a note to explain how you use generative AI in this project.',
},
notePlaceholder: {
id: 'project.settings.disclosures.ai.note-placeholder',
defaultMessage: 'e.g. The Chinese and Arabic translations are AI-generated.',
},
})
const useLabels = {
code: messages.typeCode,
assets: messages.typeAssets,
text: messages.typeText,
functionality: messages.typeFunctionality,
} as const
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>
<SettingsToggleCard
v-model="model.enabled"
:disabled="disabled"
:icon="SparklesIcon"
:title="formatMessage(messages.title)"
:description="formatMessage(messages.description)"
>
<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="grid gap-2 sm:grid-cols-4">
<Checkbox
v-for="use in AI_USES"
:key="use"
:model-value="hasUse(use)"
:disabled="disabled"
@update:model-value="(enabled) => setUse(use, enabled)"
>
{{ formatMessage(useLabels[use]) }}
</Checkbox>
</div>
</SettingsFormGroup>
<SettingsFormGroup
:title="formatMessage(messages.noteDescription)"
title-for="ai-disclosure-note"
>
<StyledInput
id="ai-disclosure-note"
v-model="model.note"
multiline
:rows="3"
class="max-w-[40rem]"
:disabled="disabled"
:placeholder="formatMessage(messages.notePlaceholder)"
/>
</SettingsFormGroup>
</template>
</SettingsToggleCard>
</template>
@@ -0,0 +1,169 @@
<script setup lang="ts">
import { GitForkIcon, PlusIcon, TrashIcon } from '@modrinth/assets'
import {
ButtonStyled,
commonMessages,
defineMessages,
SettingsFormGroup,
SettingsToggleCard,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { watch } from 'vue'
import type { DerivativeDisclosure, DerivativeSource } from './types'
const model = defineModel<DerivativeDisclosure>({ required: true })
defineProps<{
disabled?: boolean
}>()
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: 'Please 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>
<SettingsToggleCard
v-model="model.enabled"
:disabled="disabled"
:icon="GitForkIcon"
:title="formatMessage(messages.title)"
:description="formatMessage(messages.description)"
>
<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">
<ButtonStyled v-if="model.sources.length > 1" color="red" color-fill="text">
<button type="button" :disabled="disabled" @click="removeSource(index)">
<TrashIcon />
{{ formatMessage(commonMessages.removeButton) }}
</button>
</ButtonStyled>
</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>
<ButtonStyled>
<button type="button" class="w-fit" :disabled="disabled" @click="addSource">
<PlusIcon />
{{ formatMessage(commonMessages.addAnotherButton) }}
</button>
</ButtonStyled>
</template>
</SettingsToggleCard>
</template>
@@ -0,0 +1,100 @@
<script setup lang="ts">
import { CircleDollarSignIcon, ListPlusIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
commonMessages,
defineMessages,
SettingsFormGroup,
SettingsToggleCard,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { watch } from 'vue'
import type { PaidFeaturesDisclosure } from './types'
const model = defineModel<PaidFeaturesDisclosure>({ required: true })
defineProps<{
disabled?: boolean
}>()
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: 'What kinds of paid features does it add?',
},
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>
<SettingsToggleCard
v-model="model.enabled"
:disabled="disabled"
:icon="CircleDollarSignIcon"
:title="formatMessage(messages.title)"
:description="formatMessage(messages.description)"
>
<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)"
/>
<ButtonStyled v-if="model.features.length > 1" circular>
<button
type="button"
:disabled="disabled"
:aria-label="formatMessage(commonMessages.removeButton)"
@click="removeFeature(index)"
>
<XIcon />
</button>
</ButtonStyled>
</div>
</SettingsFormGroup>
<ButtonStyled>
<button type="button" class="w-fit" :disabled="disabled" @click="addFeature">
<ListPlusIcon />
{{ formatMessage(commonMessages.addAnotherButton) }}
</button>
</ButtonStyled>
</template>
</SettingsToggleCard>
</template>
@@ -0,0 +1,68 @@
<script setup lang="ts">
import { EyeIcon } from '@modrinth/assets'
import {
defineMessages,
SettingsFormGroup,
SettingsToggleCard,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import type { NoteDisclosure } from './types'
const model = defineModel<NoteDisclosure>({ required: true })
defineProps<{
disabled?: boolean
}>()
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.',
},
noteLabel: {
id: 'project.settings.disclosures.photosensitivity.note-label',
defaultMessage: 'Please briefly describe why your project has a photosensitivity warning.',
},
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>
<SettingsToggleCard
v-model="model.enabled"
:disabled="disabled"
:icon="EyeIcon"
:title="formatMessage(messages.title)"
:description="formatMessage(messages.description)"
>
<template #expanded>
<SettingsFormGroup
:title="formatMessage(messages.noteLabel)"
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>
</SettingsToggleCard>
</template>
@@ -0,0 +1,68 @@
<script setup lang="ts">
import { CircuitBoardIcon } from '@modrinth/assets'
import {
defineMessages,
SettingsFormGroup,
SettingsToggleCard,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import type { NoteDisclosure } from './types'
const model = defineModel<NoteDisclosure>({ required: true })
defineProps<{
disabled?: boolean
}>()
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:
'Please describe the external system interactions in the mod so users can know what to expect.',
},
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>
<SettingsToggleCard
v-model="model.enabled"
:disabled="disabled"
:icon="CircuitBoardIcon"
:title="formatMessage(messages.title)"
:description="formatMessage(messages.description)"
>
<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>
</SettingsToggleCard>
</template>
@@ -0,0 +1,139 @@
<script setup lang="ts">
import { ListPlusIcon, RadioTowerIcon, TrashIcon } from '@modrinth/assets'
import {
ButtonStyled,
Chips,
commonMessages,
defineMessages,
SettingsFormGroup,
SettingsToggleCard,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { watch } from 'vue'
import type { TelemetryConsent, TelemetryDisclosure } from './types'
const model = defineModel<TelemetryDisclosure>({ required: true })
defineProps<{
disabled?: boolean
}>()
const { formatMessage } = useVIntl()
const CONSENT_MODELS: TelemetryConsent[] = ['opt_in', 'opt_out', 'always_active']
const consentMessages = defineMessages({
opt_in: {
id: 'project.settings.disclosures.telemetry.consent-opt-in',
defaultMessage: 'Opt-in',
},
opt_out: {
id: 'project.settings.disclosures.telemetry.consent-opt-out',
defaultMessage: 'Opt-out',
},
always_active: {
id: 'project.settings.disclosures.telemetry.consent-always-active',
defaultMessage: 'Always active',
},
})
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 being 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>
<SettingsToggleCard
v-model="model.enabled"
:disabled="disabled"
:icon="RadioTowerIcon"
:title="formatMessage(messages.title)"
:description="formatMessage(messages.description)"
>
<template #expanded>
<SettingsFormGroup :title="formatMessage(messages.consentDescription)">
<Chips
v-model="model.consent"
:items="CONSENT_MODELS"
:capitalize="false"
:format-label="(item: TelemetryConsent) => formatMessage(consentMessages[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)"
/>
<ButtonStyled v-if="model.entries.length > 1" circular>
<button
type="button"
:disabled="disabled"
:aria-label="formatMessage(commonMessages.removeButton)"
@click="removeEntry(index)"
>
<TrashIcon />
</button>
</ButtonStyled>
</div>
</SettingsFormGroup>
<ButtonStyled>
<button type="button" class="w-fit" :disabled="disabled" @click="addEntry">
<ListPlusIcon />
{{ formatMessage(commonMessages.addAnotherButton) }}
</button>
</ButtonStyled>
</template>
</SettingsToggleCard>
</template>
@@ -0,0 +1,180 @@
import type { Labrinth } from '@modrinth/api-client'
import type {
DisclosureFormState,
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' | 'system_interactions'
function createNoteModel(
disclosures: ProjectDisclosureData[],
type: NoteDisclosureType,
): NoteDisclosure {
const disclosure = findDisclosure(disclosures, type)
return {
enabled: !!disclosure,
note: disclosure?.note ?? '',
}
}
function nonemptyOrPlaceholder(values: string[]): string[] {
return values.length > 0 ? [...values] : ['']
}
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')
return {
ai: {
enabled: !!ai,
uses: ai ? [...(ai.uses ?? [])] : [],
note: ai?.note ?? '',
},
advertising: createNoteModel(disclosures, 'advertisements'),
paidFeatures: {
enabled: !!paidFeatures,
features: paidFeatures ? nonemptyOrPlaceholder(paidFeatures.features) : [],
},
telemetry: {
enabled: !!telemetry,
consent: telemetry?.consent ?? 'opt_in',
entries: telemetry ? nonemptyOrPlaceholder(telemetry.data_collected) : [],
},
derivative: {
enabled: !!derivative,
sources: derivative ? derivative.sources.map((source) => ({ ...source })) : [],
},
photosensitivity: createNoteModel(disclosures, 'epilepsy_triggers'),
systemInteractions: createNoteModel(disclosures, 'system_interactions'),
}
}
export function formToDisclosures(form: DisclosureFormState): ProjectDisclosure[] {
const set: ProjectDisclosure[] = []
if (form.ai.enabled) {
set.push({
type: 'ai_content',
uses: [...form.ai.uses],
note: form.ai.note.trim() || null,
})
}
if (form.advertising.enabled) {
set.push({ type: 'advertisements', note: form.advertising.note.trim() || null })
}
if (form.paidFeatures.enabled) {
set.push({
type: 'paid_features',
features: form.paidFeatures.features.map((feature) => feature.trim()).filter(Boolean),
})
}
if (form.telemetry.enabled) {
set.push({
type: 'telemetry',
consent: form.telemetry.consent,
data_collected: form.telemetry.entries.map((entry) => entry.trim()).filter(Boolean),
})
}
if (form.derivative.enabled) {
set.push({
type: 'derivative_work',
sources: form.derivative.sources.map((source) => ({
label: source.label.trim(),
link: source.link?.trim() || null,
note: source.note?.trim() || null,
})),
})
}
if (form.photosensitivity.enabled) {
set.push({ type: 'epilepsy_triggers', note: form.photosensitivity.note.trim() || null })
}
if (form.systemInteractions.enabled) {
set.push({ type: 'system_interactions', note: form.systemInteractions.note.trim() || null })
}
return set
}
export function toModifyRequest(
form: DisclosureFormState,
previous: DisclosureFormState,
): Labrinth.Projects.v3.ModifyProjectDisclosures {
const set = formToDisclosures(form)
const nextTypes = new Set(set.map((disclosure) => disclosure.type))
return {
set,
remove: formToDisclosures(previous)
.map((disclosure) => disclosure.type)
.filter((type) => !nextTypes.has(type)),
}
}
export function toCachedDisclosures(set: ProjectDisclosure[]): ProjectDisclosureData[] {
const now = new Date().toISOString()
return set.map((disclosure) => ({
...disclosure,
set_by_moderator: false,
updated_at: now,
updated_by: null,
}))
}
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): DisclosureFormIssue[] {
const issues: DisclosureFormIssue[] = []
const missingNote = (model: NoteDisclosure) => model.enabled && !model.note.trim()
if (missingNote(form.advertising)) {
issues.push('advertising-note')
}
if (form.paidFeatures.enabled && !form.paidFeatures.features.some((feature) => feature.trim())) {
issues.push('paid-features-empty')
}
if (form.telemetry.enabled && !form.telemetry.entries.some((entry) => entry.trim())) {
issues.push('telemetry-empty')
}
if (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 (missingNote(form.photosensitivity)) {
issues.push('photosensitivity-note')
}
if (missingNote(form.systemInteractions)) {
issues.push('system-interactions-note')
}
return issues
}
@@ -0,0 +1,9 @@
export { default as AdvertisingDisclosureCard } from './AdvertisingDisclosureCard.vue'
export { default as AiDisclosureCard } from './AiDisclosureCard.vue'
export { default as DerivativeDisclosureCard } from './DerivativeDisclosureCard.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,46 @@
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 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 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: NoteDisclosure
}
+70 -10
View File
@@ -3761,9 +3761,6 @@
"project.settings.back-to-project-page": {
"message": "Back to project page"
},
"project.settings.disclosures.add-another": {
"message": "Add another"
},
"project.settings.disclosures.advertising.description.1": {
"message": "You must enable this if your project contains advertisements, sponsorships, or promotions of other works."
},
@@ -3774,7 +3771,7 @@
"message": "Please explain how your project utilizes advertising so that users can know what to expect."
},
"project.settings.disclosures.advertising.note-placeholder": {
"message": "e.g. \"Adds the Modrinth SMP server to your server list automatically.\""
"message": "e.g. Adds the Modrinth SMP server to your server list automatically."
},
"project.settings.disclosures.advertising.title": {
"message": "Contains advertisements"
@@ -3812,6 +3809,27 @@
"project.settings.disclosures.content-disclosures": {
"message": "Content disclosures"
},
"project.settings.disclosures.derivative.description": {
"message": "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."
},
"project.settings.disclosures.derivative.link-label": {
"message": "Link to original work"
},
"project.settings.disclosures.derivative.link-placeholder": {
"message": "https://example.com"
},
"project.settings.disclosures.derivative.name-label": {
"message": "Name of original work"
},
"project.settings.disclosures.derivative.name-placeholder": {
"message": "Example project"
},
"project.settings.disclosures.derivative.note-label": {
"message": "Please explain how your project is based on the original work"
},
"project.settings.disclosures.derivative.title": {
"message": "Contains derivative content"
},
"project.settings.disclosures.description": {
"message": "You must add any applicable content disclosures to your project in compliance with Modrinth's <rules>Content Rules</rules>."
},
@@ -3819,7 +3837,7 @@
"message": "You must enable this if your project contains features that can be obtained by spending real-world money."
},
"project.settings.disclosures.paid-features.feature-placeholder": {
"message": "e.g. Cosmetics available as Patreon reward.”"
"message": "e.g. Cosmetics available as Patreon reward"
},
"project.settings.disclosures.paid-features.features-description": {
"message": "What kinds of paid features does it add?"
@@ -3827,11 +3845,53 @@
"project.settings.disclosures.paid-features.title": {
"message": "Contains paid features"
},
"project.settings.disclosures.telemetry.add-data-type": {
"message": "Add type of data"
"project.settings.disclosures.photosensitivity.description": {
"message": "Enable this if your project contains anything that you think may be dangerous to certain people who are sensitive to flashing lights or patterns."
},
"project.settings.disclosures.telemetry.add-privacy-policy": {
"message": "Add privacy policy"
"project.settings.disclosures.photosensitivity.note-label": {
"message": "Please briefly describe why your project has a photosensitivity warning."
},
"project.settings.disclosures.photosensitivity.note-placeholder": {
"message": "e.g. It adds a flashlight item that has a strobe mode. It can be disabled in Accessibility settings in-game."
},
"project.settings.disclosures.photosensitivity.title": {
"message": "Photosensitivity warning"
},
"project.settings.disclosures.save-blocked.advertising-note": {
"message": "Advertising disclosure requires explanation."
},
"project.settings.disclosures.save-blocked.derivative-empty": {
"message": "Derivative works disclosure must list at least one source work."
},
"project.settings.disclosures.save-blocked.derivative-source-label": {
"message": "Derivative work sources must have a name."
},
"project.settings.disclosures.save-blocked.no-permission": {
"message": "You don't have permission to edit this project's disclosures."
},
"project.settings.disclosures.save-blocked.paid-features-empty": {
"message": "Paid features disclosure must list at least one paid feature."
},
"project.settings.disclosures.save-blocked.photosensitivity-note": {
"message": "Photosensitivity warning disclosure must include a description."
},
"project.settings.disclosures.save-blocked.system-interactions-note": {
"message": "External system interactions disclosure must include a description."
},
"project.settings.disclosures.save-blocked.telemetry-empty": {
"message": "Telemetry disclosure must list at least one type of data collected."
},
"project.settings.disclosures.system-interactions.description": {
"message": "You must enable this if your project reads or edits things on the user's system outside of the game."
},
"project.settings.disclosures.system-interactions.note-label": {
"message": "Please describe the external system interactions in the mod so users can know what to expect."
},
"project.settings.disclosures.system-interactions.note-placeholder": {
"message": "e.g. It adds a file to the desktop called wake_up.txt"
},
"project.settings.disclosures.system-interactions.title": {
"message": "External system interactions"
},
"project.settings.disclosures.telemetry.consent-always-active": {
"message": "Always active"
@@ -3852,7 +3912,7 @@
"message": "What data is being collected?"
},
"project.settings.disclosures.telemetry.data-placeholder": {
"message": "e.g. Anonymous launch analytics to track Minecraft version and mod loader usage."
"message": "e.g. Anonymous launch analytics to track Minecraft version and mod loader usage."
},
"project.settings.disclosures.telemetry.description": {
"message": "You must enable this if your project sends usage data back to yourself or a third party."
@@ -1,26 +1,42 @@
<script setup lang="ts">
import {
CircleDollarSignIcon,
ListPlusIcon,
MegaphoneIcon,
PlusIcon,
RadioTowerIcon,
SparklesIcon,
} from '@modrinth/assets'
import {
ButtonStyled,
Checkbox,
ConfirmLeaveModal,
defineMessages,
injectModrinthClient,
injectProjectPageContext,
IntlFormatted,
type MessageDescriptor,
normalizeChildren,
StyledInput,
ToggleCard,
UnsavedChangesPopup,
usePageLeaveSafety,
useSavable,
useVIntl,
} from '@modrinth/ui'
import { TeamMemberPermission } from '@modrinth/utils'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, watch } from 'vue'
import Chips from '~/components/ui/Chips.vue'
import {
AdvertisingDisclosureCard,
AiDisclosureCard,
DerivativeDisclosureCard,
type DisclosureFormIssue,
disclosuresToForm,
getDisclosureFormIssues,
PaidFeaturesDisclosureCard,
PhotosensitivityDisclosureCard,
SystemInteractionsDisclosureCard,
TelemetryDisclosureCard,
toCachedDisclosures,
toModifyRequest,
} from '~/components/ui/project-settings/disclosures'
const DISCLOSURE_QUERY_STALE_TIME = 1000 * 60 * 5
const { formatMessage } = useVIntl()
const { labrinth } = injectModrinthClient()
const { projectV2: project, currentMember } = injectProjectPageContext()
const queryClient = useQueryClient()
const messages = defineMessages({
title: {
@@ -31,172 +47,100 @@ const messages = defineMessages({
id: 'project.settings.disclosures.description',
defaultMessage: `You must add any applicable content disclosures to your project in compliance with Modrinth's <rules>Content Rules</rules>.`,
},
addAnother: {
id: 'project.settings.disclosures.add-another',
defaultMessage: 'Add another',
noPermission: {
id: 'project.settings.disclosures.save-blocked.no-permission',
defaultMessage: `You don't have permission to edit this project's disclosures.`,
},
})
const aiDisclosure = ref(false)
const aiDisclosureTypes = ref({
code: false,
assets: false,
text: false,
functionality: false,
})
const aiDisclosureMessages = defineMessages({
title: {
id: 'project.settings.disclosures.ai.title',
defaultMessage: 'Contains AI-generated content',
const issueMessages = defineMessages({
'advertising-note': {
id: 'project.settings.disclosures.save-blocked.advertising-note',
defaultMessage: 'Advertising disclosure requires explanation.',
},
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.`,
'paid-features-empty': {
id: 'project.settings.disclosures.save-blocked.paid-features-empty',
defaultMessage: 'Paid features disclosure must list at least one paid feature.',
},
contentRules: {
id: 'project.settings.disclosures.ai.content-rules',
defaultMessage:
"Please refer to Section 6 of Modrinth's <rules>Content Rules</rules> for more information.",
'telemetry-empty': {
id: 'project.settings.disclosures.save-blocked.telemetry-empty',
defaultMessage: 'Telemetry disclosure must list at least one type of data collected.',
},
typesDescription: {
id: 'project.settings.disclosures.ai.types-description',
defaultMessage: 'Select what this project uses generative AI for.',
'derivative-empty': {
id: 'project.settings.disclosures.save-blocked.derivative-empty',
defaultMessage: 'Derivative works disclosure must list at least one source work.',
},
typeCode: {
id: 'project.settings.disclosures.ai.types-code',
defaultMessage: 'Code',
'derivative-source-label': {
id: 'project.settings.disclosures.save-blocked.derivative-source-label',
defaultMessage: 'Derivative work sources must have a name.',
},
typeAssets: {
id: 'project.settings.disclosures.ai.types-assets',
defaultMessage: 'Assets',
'photosensitivity-note': {
id: 'project.settings.disclosures.save-blocked.photosensitivity-note',
defaultMessage: 'Photosensitivity warning disclosure must include a description.',
},
typeText: {
id: 'project.settings.disclosures.ai.types-text',
defaultMessage: 'Text',
},
typeFunctionality: {
id: 'project.settings.disclosures.ai.types-functionality',
defaultMessage: 'Functionality',
},
noteDescription: {
id: 'project.settings.disclosures.ai.note-description',
defaultMessage:
'You may optionally provide a note to explain how you use generative AI in this project.',
},
notePlaceholder: {
id: 'project.settings.disclosures.ai.note-placeholder',
defaultMessage: 'e.g. The Chinese and Arabic translations are AI-generated.',
'system-interactions-note': {
id: 'project.settings.disclosures.save-blocked.system-interactions-note',
defaultMessage: 'External system interactions disclosure must include a description.',
},
}) satisfies Record<DisclosureFormIssue, MessageDescriptor>
const disclosuresQueryKey = computed(
() => ['project', 'disclosures', 'v3', project.value.id] as const,
)
const { data: disclosuresResponse } = useQuery({
queryKey: disclosuresQueryKey,
queryFn: () => labrinth.projects_v3.getDisclosures(project.value.id),
staleTime: DISCLOSURE_QUERY_STALE_TIME,
})
const advertisingDisclosure = ref(false)
const advertisingDisclosureMessages = defineMessages({
title: {
id: 'project.settings.disclosures.advertising.title',
defaultMessage: 'Contains advertisements',
const hasPermission = computed(
() => !!((currentMember.value?.permissions ?? 0) & TeamMemberPermission.EDIT_DETAILS),
)
const { saved, current, saving, hasChanges, reset, save } = useSavable(
() => disclosuresToForm(disclosuresResponse.value?.disclosures ?? []),
async () => {
if (!hasPermission.value || !canSave.value) {
throw new Error('Disclosures form is not valid')
}
const previous = disclosuresToForm(disclosuresResponse.value?.disclosures ?? [])
const request = toModifyRequest(current.value, previous)
await labrinth.projects_v3.modifyDisclosures(project.value.id, request)
queryClient.setQueryData(disclosuresQueryKey.value, {
disclosures: toCachedDisclosures(request.set),
})
},
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.`,
},
noteDescription: {
id: 'project.settings.disclosures.advertising.note-description',
defaultMessage:
'Please explain how your project utilizes advertising so that users can know what to expect.',
},
notePlaceholder: {
id: 'project.settings.disclosures.advertising.note-placeholder',
defaultMessage: 'e.g. "Adds the Modrinth SMP server to your server list automatically."',
)
watch(
() => disclosuresResponse.value,
(value, previous) => {
if (value && (!previous || !hasChanges.value)) {
reset()
}
},
)
const issues = computed(() => getDisclosureFormIssues(current.value))
const canSave = computed(() => hasPermission.value && issues.value.length === 0)
const saveDisabledReason = computed(() => {
if (!hasPermission.value) {
// should never come up but y'never know
return formatMessage(messages.noPermission)
}
return issues.value.map((issue) => formatMessage(issueMessages[issue]))
})
const paidFeaturesDisclosure = ref(false)
const paidFeaturesDisclosureMessages = 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: 'What kinds of paid features does it add?',
},
featurePlaceholder: {
id: 'project.settings.disclosures.paid-features.feature-placeholder',
defaultMessage: 'e.g. “Cosmetics available as Patreon reward.”',
},
})
const telemetryDisclosure = ref(false)
type TelemetryConsentModel = 'opt_in' | 'opt_out' | 'always_active'
const telemetryConsentModels: TelemetryConsentModel[] = ['opt_in', 'opt_out', 'always_active']
const telemetryConsentModelMessages = defineMessages({
opt_in: {
id: 'project.settings.disclosures.telemetry.consent-opt-in',
defaultMessage: 'Opt-in',
},
opt_out: {
id: 'project.settings.disclosures.telemetry.consent-opt-out',
defaultMessage: 'Opt-out',
},
always_active: {
id: 'project.settings.disclosures.telemetry.consent-always-active',
defaultMessage: 'Always active',
},
})
const telemetryConsentModel = ref<TelemetryConsentModel>(telemetryConsentModels[0])
const telemetryDisclosureMessages = 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 being 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.”',
},
addPrivacyPolicy: {
id: 'project.settings.disclosures.telemetry.add-privacy-policy',
defaultMessage: 'Add privacy policy',
},
addDataType: {
id: 'project.settings.disclosures.telemetry.add-data-type',
defaultMessage: 'Add type of data',
},
})
const { confirmLeaveModal } = usePageLeaveSafety(hasChanges)
</script>
<template>
<div>
<ConfirmLeaveModal ref="confirmLeaveModal" />
<h2 class="m-0 text-2xl font-semibold">
{{ formatMessage(messages.title) }}
</h2>
@@ -209,180 +153,29 @@ const telemetryDisclosureMessages = defineMessages({
</template>
</IntlFormatted>
</p>
<ToggleCard v-model="aiDisclosure">
<h3 class="mb-1 mt-0 flex items-center gap-2 text-base font-semibold text-contrast">
<SparklesIcon class="size-5 text-primary" />
{{ formatMessage(aiDisclosureMessages.title) }}
</h3>
<p class="mb-2 mt-0 text-sm leading-normal">
{{ formatMessage(aiDisclosureMessages.description) }}
</p>
<p class="m-0 text-sm leading-normal text-secondary">
<IntlFormatted :message-id="aiDisclosureMessages.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>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<p class="m-0 leading-normal text-contrast">
{{ formatMessage(aiDisclosureMessages.typesDescription) }}
</p>
<div class="grid gap-2 sm:grid-cols-4">
<Checkbox v-model="aiDisclosureTypes.code">
{{ formatMessage(aiDisclosureMessages.typeCode) }}
</Checkbox>
<Checkbox v-model="aiDisclosureTypes.assets">
{{ formatMessage(aiDisclosureMessages.typeAssets) }}
</Checkbox>
<Checkbox v-model="aiDisclosureTypes.text">
{{ formatMessage(aiDisclosureMessages.typeText) }}
</Checkbox>
<Checkbox v-model="aiDisclosureTypes.functionality">
{{ formatMessage(aiDisclosureMessages.typeFunctionality) }}
</Checkbox>
</div>
</div>
<div class="flex flex-col gap-2">
<label for="ai-disclosure-note" class="leading-normal text-contrast">
{{ formatMessage(aiDisclosureMessages.noteDescription) }}
</label>
<StyledInput
id="ai-disclosure-note"
multiline
:rows="3"
class="max-w-[40rem]"
:placeholder="formatMessage(aiDisclosureMessages.notePlaceholder)"
/>
</div>
</div>
</template>
</ToggleCard>
<ToggleCard v-model="advertisingDisclosure" class="mt-4">
<h3 class="mb-1 mt-0 flex items-center gap-2 text-base font-semibold text-contrast">
<MegaphoneIcon class="size-5 text-primary" />
{{ formatMessage(advertisingDisclosureMessages.title) }}
</h3>
<p class="mb-2 mt-0 text-sm leading-normal">
{{ formatMessage(advertisingDisclosureMessages.description1) }}
</p>
<p class="m-0 text-sm leading-normal">
<IntlFormatted :message-id="advertisingDisclosureMessages.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>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<label for="advertising-disclosure-note" class="leading-normal text-contrast">
{{ formatMessage(advertisingDisclosureMessages.noteDescription) }}
</label>
<StyledInput
id="advertising-disclosure-note"
multiline
:rows="3"
class="max-w-[40rem]"
:placeholder="formatMessage(advertisingDisclosureMessages.notePlaceholder)"
/>
</div>
</div>
</template>
</ToggleCard>
<ToggleCard v-model="paidFeaturesDisclosure" class="mt-4">
<h3 class="mb-1 mt-0 flex items-center gap-2 text-base font-semibold text-contrast">
<CircleDollarSignIcon class="size-5 text-primary" />
{{ formatMessage(paidFeaturesDisclosureMessages.title) }}
</h3>
<p class="m-0 text-sm leading-normal">
{{ formatMessage(paidFeaturesDisclosureMessages.description) }}
</p>
<template #expanded>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<p class="m-0 leading-normal text-contrast">
{{ formatMessage(paidFeaturesDisclosureMessages.featuresDescription) }}
</p>
<StyledInput
:placeholder="formatMessage(paidFeaturesDisclosureMessages.featurePlaceholder)"
/>
</div>
<ButtonStyled>
<button class="w-fit">
<ListPlusIcon />
{{ formatMessage(messages.addAnother) }}
</button>
</ButtonStyled>
</div>
</template>
</ToggleCard>
<ToggleCard v-model="telemetryDisclosure" class="mt-4">
<h3 class="mb-1 mt-0 flex items-center gap-2 text-base font-semibold text-contrast">
<RadioTowerIcon class="size-5 text-primary" />
{{ formatMessage(telemetryDisclosureMessages.title) }}
</h3>
<p class="m-0 text-sm leading-normal">
{{ formatMessage(telemetryDisclosureMessages.description) }}
</p>
<template #expanded>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<p class="m-0 leading-normal text-contrast">
{{ formatMessage(telemetryDisclosureMessages.consentDescription) }}
</p>
<Chips
v-model="telemetryConsentModel"
:items="telemetryConsentModels"
:format-label="
(item: TelemetryConsentModel) => formatMessage(telemetryConsentModelMessages[item])
"
/>
</div>
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-1">
<p class="m-0 leading-normal text-contrast">
{{ formatMessage(telemetryDisclosureMessages.dataLabel) }}
</p>
<p class="m-0 leading-normal text-primary">
{{ formatMessage(telemetryDisclosureMessages.dataDescription) }}
</p>
</div>
<StyledInput
:placeholder="formatMessage(telemetryDisclosureMessages.dataPlaceholder)"
/>
</div>
<div class="flex gap-2">
<ButtonStyled>
<button>
<PlusIcon />
{{ formatMessage(telemetryDisclosureMessages.addPrivacyPolicy) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button>
<ListPlusIcon />
{{ formatMessage(telemetryDisclosureMessages.addDataType) }}
</button>
</ButtonStyled>
</div>
</div>
</template>
</ToggleCard>
<div class="flex flex-col gap-4">
<AiDisclosureCard v-model="current.ai" :disabled="!hasPermission" />
<AdvertisingDisclosureCard v-model="current.advertising" :disabled="!hasPermission" />
<PaidFeaturesDisclosureCard v-model="current.paidFeatures" :disabled="!hasPermission" />
<TelemetryDisclosureCard v-model="current.telemetry" :disabled="!hasPermission" />
<DerivativeDisclosureCard v-model="current.derivative" :disabled="!hasPermission" />
<PhotosensitivityDisclosureCard
v-model="current.photosensitivity"
:disabled="!hasPermission"
/>
<SystemInteractionsDisclosureCard
v-model="current.systemInteractions"
:disabled="!hasPermission"
/>
</div>
<UnsavedChangesPopup
:original="saved"
:modified="current"
:saving="saving"
:can-save="canSave"
:save-disabled-reason="saveDisabledReason"
@reset="reset"
@save="save"
/>
</div>
</template>