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
}