mirror of
https://github.com/modrinth/code.git
synced 2026-08-27 10:04:52 +00:00
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:
co-authored by
ThatGravyBoat
chyzman
Truman Gao
sychic
coolbot
parent
06b7c44dcc
commit
755825b09a
@@ -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(() => {
|
||||
|
||||
+86
@@ -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>
|
||||
+86
@@ -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>
|
||||
+179
@@ -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 else’s 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>
|
||||
+146
@@ -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>
|
||||
+100
@@ -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>
|
||||
+68
@@ -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>
|
||||
+69
@@ -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>
|
||||
+132
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user