project disclosures frontend (#6955)

* begin project disclosures

* new project settings header

* begin disclosure settings, edit content rules

* togglecards

* update phrasing of the rules, prepr

* more disclosure settings structuring

* add functionality toggle

* implement functionality with staging api

* improve project settings head titles

* feat(labrinth): project disclosures model

* feat(labrinth): project disclosures database model

* feat(labrinth): project disclosures get endpoint

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

* feat(labrinth): wrap disclosures in struct

* feat(labrinth): edit project disclosures endpoint

* style(labrinth): cargo fmt

* style(labrinth): fix typo

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

* fix(labrinth): field typo

* chore(labrinth): update query cache

* feat(labrinth): index disclosures in search

* refactor(labrinth): use enum instead of bools

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

* feat(labrinth): change archived status to disclosure

* feat(labrinth): use disclosure for archival status

* fix(labrinth): error type for deserialization

* fix(labrinth): migration timestamp

* fix(labrinth): add disclosures to elasticsearch schema

* disclosures in search

* update photosensitivity warning copy

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

* blue archive banner

* display AI use types

* improve labels

* add modrinth to link

* add placeholder missing disclosure report type

* add AI metadata checking to block image uploads with notice

* update copy

* update section 4 of rules

* Update rule 6 layout

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

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

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

* Update moderation checklist (#7056)

* fix: missing delphi severity (#7054)

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

* add prefix to collect

* Finish up new disclosures stage

* update r4 messages

* new r4 msg, update showcase clarity message

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

* fix: mrpack exporting with zip64 (#7064)

* fix: action bar max width (#7048)

* fix: action bar max width

* fix: width

* changelog

* Rule placeholders with subsections & anchors, update messages.

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

* Add nag for content disclosures

---------

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

* prepr

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

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

* fix(labrinth): dedupe ai usages

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

* feat(labrinth): add interactions field

* style(labrinth): cargo fmt

* feat(labrinth): granular disclosure locking

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

* move disclosures stage earlier

* include consent model info in telemetry disclosure msgs

* prepr + update date

* typo

* qa pass

* search suboptions

* add empty state

* checkboxes in column

* persistent advanced filters

* blog draft

* prepr

* support soft deletion

* reload instead of optimistically updating

* update blog

* TOS update + minor verbiage adjustment

* 45 day grace

---------

Co-authored-by: sychic <47618543+Sychic@users.noreply.github.com>
Co-authored-by: coolbot <76798835+coolbot100s@users.noreply.github.com>
Co-authored-by: ThatGravyBoat <gravy@thatgravyboat.tech>
Co-authored-by: chyzman <chyzalt@gmail.com>
Co-authored-by: Truman Gao <106889354+tdgao@users.noreply.github.com>
This commit is contained in:
Prospector
2026-08-13 09:01:18 -07:00
committed by GitHub
co-authored by ThatGravyBoat chyzman Truman Gao sychic coolbot
parent 06b7c44dcc
commit 755825b09a
230 changed files with 5802 additions and 1268 deletions
@@ -1,7 +1,8 @@
<template>
<div
:class="[
'relative grid grid-cols-[1.5rem_minmax(0,1fr)_auto] items-start gap-x-2 rounded-2xl border border-solid p-4 text-contrast',
'relative grid grid-cols-[1.5rem_minmax(0,1fr)_auto] gap-x-2 rounded-2xl border border-solid p-4 text-contrast',
centerContent ? 'items-center' : 'items-start',
progress != null ? 'overflow-hidden pb-5' : '',
typeClasses[type],
]"
@@ -13,7 +14,7 @@
class="col-start-2 min-w-0"
:class="
inlineActions && !showActionsUnderneath && $slots.actions
? 'flex flex-wrap items-start gap-x-4 gap-y-3'
? ['flex flex-wrap gap-x-4 gap-y-3', centerContent ? 'items-center' : 'items-start']
: 'flex flex-1 flex-col gap-2'
"
>
@@ -39,7 +40,7 @@
{{ relativeTimeLabel }}
</span>
</div>
<div class="font-normal text-contrast/85 leading-tight">
<div v-if="!!$slots.default || body" class="font-normal text-contrast/85 leading-tight">
<slot>{{ body }}</slot>
</div>
</div>
@@ -119,6 +120,7 @@ const props = withDefaults(
waiting?: boolean
/** Accepts a Date, an ISO string, or a millisecond Unix timestamp. */
timestamp?: Date | string | number
centerContent?: boolean
}>(),
{
type: 'info',
@@ -131,6 +133,7 @@ const props = withDefaults(
progressColor: undefined,
waiting: false,
timestamp: undefined,
centerContent: false,
},
)
@@ -1,11 +1,20 @@
<script setup lang="ts">
import IntlMessageFormat, { type FormatXMLElementFn, type PrimitiveType } from 'intl-messageformat'
import { computed, markRaw, useSlots, type VNode } from 'vue'
import { computed, defineComponent, markRaw, type PropType, useSlots, type VNode } from 'vue'
import type { MessageDescriptor } from '../../composables/i18n'
import { injectI18nDebug } from '../../composables/i18n-debug'
import { injectI18n } from '../../providers/i18n'
const VNodeRenderer = defineComponent({
props: {
node: { type: Object as PropType<VNode>, required: true },
},
setup(props) {
return () => props.node
},
})
const props = defineProps<{
messageId: MessageDescriptor
values?: Record<string, PrimitiveType>
@@ -49,11 +58,13 @@ const formattedParts = computed(() => {
slotHandlers[normalizedName] = (chunks) => {
const slot = slots[slotName]
if (slot) {
return markRaw(
slot({
children: chunks,
}),
) as VNode[]
const nodes = slot({
children: chunks,
})
if (Array.isArray(nodes) && nodes.length === 1) {
return markRaw(nodes[0]) as VNode
}
return markRaw(nodes) as VNode[]
}
return markRaw(chunks) as VNode[]
}
@@ -71,33 +82,35 @@ const formattedParts = computed(() => {
...slotHandlers,
})
// ensure result array items are marked as raw if they're VNodes
// prevents VNodes from entering the reactive system and SSR payload
if (Array.isArray(result)) {
return result.map((part) =>
typeof part === 'object' && part !== null ? markRaw(part) : part,
)
}
return [typeof result === 'object' && result !== null ? markRaw(result) : result]
return toFormattedParts(result)
} catch {
return [msg]
}
})
function toFormattedParts(value: unknown): unknown[] {
if (Array.isArray(value)) {
return value.flatMap(toFormattedParts)
}
if (typeof value === 'object' && value !== null) {
return [markRaw(value)]
}
return [value]
}
function isVNodePart(part: unknown): part is VNode {
return typeof part === 'object' && part !== null
}
</script>
<template>
<span
v-if="debugEnabled && !debugKeyReveal"
:data-i18n-key="messageId.id"
style="display: contents"
:data-i18n-key="debugEnabled && !debugKeyReveal ? messageId.id : undefined"
>
<template v-for="(part, index) in formattedParts" :key="index">
<component :is="() => part" v-if="typeof part === 'object'" />
<VNodeRenderer v-if="isVNodePart(part)" :node="part" />
<template v-else>{{ part }}</template>
</template>
</span>
<template v-for="(part, index) in formattedParts" v-else :key="index">
<component :is="() => part" v-if="typeof part === 'object'" />
<template v-else>{{ part }}</template>
</template>
</template>
@@ -0,0 +1,53 @@
<script setup lang="ts">
import { defineMessage, useVIntl } from '../../composables/i18n'
const { formatMessage } = useVIntl()
withDefaults(
defineProps<{
title?: string
titleFor?: string
description?: string
optional?: boolean
}>(),
{
optional: false,
},
)
const optionalMessage = defineMessage({
id: 'settings.form.group.optional',
defaultMessage: '(optional)',
})
</script>
<template>
<div class="flex flex-col gap-2">
<div
v-if="title || description || $slots.title || $slots.description"
:class="description || $slots.description ? 'flex flex-col gap-1' : undefined"
>
<label
v-if="title && titleFor"
:for="titleFor"
class="m-0 leading-normal text-contrast font-semibold"
>
{{ title }}
<span v-if="optional" class="text-secondary font-normal">
{{ formatMessage(optionalMessage) }}
</span>
</label>
<p v-else-if="title" class="m-0 leading-normal text-contrast font-semibold">
{{ title }}
<span v-if="optional" class="text-secondary font-normal">
{{ formatMessage(optionalMessage) }}
</span>
</p>
<slot name="title" />
<p v-if="description" class="m-0 leading-normal text-primary">
{{ description }}
</p>
<slot name="description" />
</div>
<slot />
</div>
</template>
@@ -0,0 +1,12 @@
<script setup lang="ts">
import { TriangleAlertIcon } from '@modrinth/assets'
</script>
<template>
<div class="flex flex-wrap items-center gap-1.5 text-orange">
<TriangleAlertIcon aria-hidden="true" class="size-4 shrink-0" />
<div class="min-w-0">
<slot />
</div>
</div>
</template>
@@ -0,0 +1,32 @@
<script setup lang="ts">
import type { Component } from 'vue'
defineProps<{
title: string
icon?: Component
description?: string
}>()
</script>
<template>
<div
class="grid w-full grid-cols-[1fr_auto] items-center gap-6 p-4 rounded-2xl border border-solid border-surface-4 bg-surface-3"
>
<div>
<h3 class="mb-1 mt-0 flex items-center gap-2 text-lg font-semibold text-contrast">
<component :is="icon" v-if="icon" class="size-5 shrink-0 text-primary" aria-hidden="true" />
{{ title }}
</h3>
<div
v-if="description || $slots.default"
class="flex flex-col gap-2 leading-normal [&>p]:m-0"
>
<p v-if="description">{{ description }}</p>
<slot />
</div>
</div>
<div>
<slot name="actions" />
</div>
</div>
</template>
@@ -0,0 +1,36 @@
<script setup lang="ts">
import type { Component } from 'vue'
import ToggleCard from './ToggleCard.vue'
defineProps<{
title: string
icon?: Component
description?: string
disabled?: boolean
toggleDisabled?: boolean
}>()
const enabled = defineModel<boolean>({ required: true })
</script>
<template>
<ToggleCard v-model="enabled" :disabled="disabled" :toggle-disabled="toggleDisabled">
<h3 class="mb-1 mt-0 flex items-center gap-2 text-lg font-semibold text-contrast">
<component :is="icon" v-if="icon" class="size-5 shrink-0 text-primary" aria-hidden="true" />
{{ title }}
</h3>
<div v-if="description || $slots.default" class="flex flex-col gap-2 leading-normal [&>p]:m-0">
<p v-if="description">{{ description }}</p>
<slot />
</div>
<template v-if="$slots.expanded" #expanded>
<div class="flex flex-col gap-4">
<slot name="expanded" />
</div>
</template>
<template v-if="$slots.footer" #footer>
<slot name="footer" />
</template>
</ToggleCard>
</template>
@@ -5,7 +5,8 @@
v-bind="$attrs"
class="smart-clickable__contents"
:class="{
'pointer-events-none': !!$slots.clickable,
'smart-clickable__contents--disabled': disabled,
'pointer-events-none': !!$slots.clickable && !disabled,
}"
>
<slot />
@@ -14,6 +15,10 @@
</template>
<script setup lang="ts">
defineProps<{
disabled?: boolean
}>()
defineOptions({
inheritAttrs: false,
})
@@ -49,8 +54,7 @@ defineOptions({
// When clickable is being hovered or focus-visible, give contents an effect
:first-child:hover + .smart-clickable__contents,
:first-child:focus-visible + .smart-clickable__contents,
.smart-clickable__contents:hover,
.smart-clickable__contents:focus-within {
.smart-clickable__contents:hover {
// Utility classes for contents
:deep(.smart-clickable\:underline-on-hover) {
text-decoration: underline;
@@ -78,8 +82,8 @@ defineOptions({
}
// When clickable is being clicked, give contents an effect
:first-child:active + .smart-clickable__contents {
scale: 0.97;
:first-child:active + .smart-clickable__contents:not(.smart-clickable__contents--disabled) {
scale: 0.98;
}
}
</style>
@@ -0,0 +1,85 @@
<script setup lang="ts">
import { SmartClickable, Toggle } from '@modrinth/ui'
import { computed, useId } from 'vue'
const props = defineProps<{
disabled?: boolean
toggleDisabled?: boolean
}>()
const value = defineModel<boolean>({ required: true })
const baseId = useId()
const toggleId = computed(() => `toggle-card-toggle-${baseId}`)
const isToggleLocked = computed(() => !!props.disabled || (!!props.toggleDisabled && value.value))
function toggle() {
if (isToggleLocked.value) return
value.value = !value.value
}
</script>
<template>
<SmartClickable
class="flex w-full flex-col overflow-clip rounded-2xl border border-solid border-surface-4 bg-surface-3"
:disabled="isToggleLocked"
>
<template #clickable>
<button
aria-hidden="true"
tabindex="-1"
:disabled="isToggleLocked"
class="flex h-full w-full"
:class="isToggleLocked ? 'cursor-not-allowed' : 'cursor-pointer'"
@click="toggle"
/>
</template>
<div class="grid w-full grid-cols-[1fr_auto] items-center gap-6 p-4">
<div>
<slot :toggle-id="toggleId" />
</div>
<div>
<slot name="toggle" :disabled="isToggleLocked">
<Toggle
:id="toggleId"
v-model="value"
:disabled="isToggleLocked"
class="smart-clickable:allow-pointer-events"
/>
</slot>
</div>
</div>
<Transition name="toggle-card-content">
<div v-if="value && $slots.expanded" class="smart-clickable:allow-pointer-events">
<div>
<div class="border-0 border-t border-solid border-surface-4 bg-surface-2 p-4">
<slot name="expanded" />
</div>
</div>
</div>
</Transition>
<div
v-if="$slots.footer"
class="smart-clickable:allow-pointer-events border-0 border-t bg-surface-3 border-solid border-surface-4 px-4 py-3"
>
<slot name="footer" />
</div>
</SmartClickable>
</template>
<style scoped>
.toggle-card-content-enter-active,
.toggle-card-content-leave-active {
display: grid;
grid-template-rows: 1fr;
transition: grid-template-rows 0.25s var(--ease-out-expo);
& > div {
grid-row: 1 / span 2;
}
}
.toggle-card-content-enter-from,
.toggle-card-content-leave-to {
grid-template-rows: 0fr;
}
</style>
@@ -48,8 +48,10 @@ const props = withDefaults(
},
)
const shown = computed(() =>
Object.keys(props.modified).some((key) => !isEqual(props.original[key], props.modified[key])),
const shown = computed(
() =>
props.saving ||
Object.keys(props.modified).some((key) => !isEqual(props.original[key], props.modified[key])),
)
function localizeIfPossible(message: MessageDescriptor | string) {
@@ -45,6 +45,8 @@ const typeClasses: Record<ButtonType, string> = {
base: 'button-frame--base bg-surface-4 text-contrast [&>svg]:text-primary',
colored:
'button-frame--colored bg-[--button-color] text-[var(--color-accent-contrast)] [&>svg]:text-inherit',
'colored-text':
'button-frame--colored-text bg-surface-4 text-[--button-color] [&>svg]:text-inherit',
outlined:
'button-frame--outlined bg-transparent text-[var(--button-color,var(--color-contrast))] [&>svg]:text-[var(--button-color,var(--color-base))]',
quiet: 'button-frame--quiet bg-transparent [&>svg]:text-inherit',
@@ -100,7 +102,12 @@ const classes = computed(() => [
])
const style = computed((): CSSProperties | undefined => {
if ((props.type === 'outlined' || props.type === 'quiet') && !props.color) return undefined
if (props.type !== 'colored' && props.type !== 'outlined' && props.type !== 'quiet')
if (
props.type !== 'colored' &&
props.type !== 'colored-text' &&
props.type !== 'outlined' &&
props.type !== 'quiet'
)
return undefined
return {
@@ -125,7 +132,8 @@ defineExpose({ element })
</template>
<style scoped>
.button-frame--base {
.button-frame--base,
.button-frame--colored-text {
box-shadow:
inset 0 0 0 1px var(--surface-5),
0 1px 1px rgba(0, 0, 0, 0.12);
@@ -3,7 +3,7 @@ import type { RouteLocationRaw } from 'vue-router'
import type { AnchoredTeleportPlacement } from '../../../utils/use-anchored-teleport'
export type ButtonType = 'base' | 'colored' | 'outlined' | 'quiet'
export type ButtonType = 'base' | 'colored' | 'colored-text' | 'outlined' | 'quiet'
export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
@@ -35,6 +35,10 @@ export type ButtonVisualProps = {
type: 'colored'
color?: ButtonColor
}
| {
type: 'colored-text'
color?: ButtonColor
}
| {
type: 'quiet'
color?: ButtonColor
+5
View File
@@ -107,7 +107,11 @@ export { default as RadioButtons } from './RadioButtons.vue'
export { default as ReadyTransition } from './ReadyTransition.vue'
export { default as ScrollablePanel } from './ScrollablePanel.vue'
export { default as ServerNotice } from './ServerNotice.vue'
export { default as SettingsFormGroup } from './SettingsFormGroup.vue'
export { default as SettingsInlineWarning } from './SettingsInlineWarning.vue'
export { default as SettingsLabel } from './SettingsLabel.vue'
export { default as SettingsOptionCard } from './SettingsOptionCard.vue'
export { default as SettingsToggleCard } from './SettingsToggleCard.vue'
export { default as SimpleBadge } from './SimpleBadge.vue'
export { default as Slider } from './Slider.vue'
export { default as SmartClickable } from './SmartClickable.vue'
@@ -132,4 +136,5 @@ export type {
export { default as TimeFramePicker } from './TimeFramePicker.vue'
export { default as Timeline } from './Timeline.vue'
export { default as Toggle } from './Toggle.vue'
export { default as ToggleCard } from './ToggleCard.vue'
export { default as UnsavedChangesPopup } from './UnsavedChangesPopup.vue'