project disclosures frontend (#6955)

* begin project disclosures

* new project settings header

* begin disclosure settings, edit content rules

* togglecards

* update phrasing of the rules, prepr

* more disclosure settings structuring

* add functionality toggle

* implement functionality with staging api

* improve project settings head titles

* feat(labrinth): project disclosures model

* feat(labrinth): project disclosures database model

* feat(labrinth): project disclosures get endpoint

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

* feat(labrinth): wrap disclosures in struct

* feat(labrinth): edit project disclosures endpoint

* style(labrinth): cargo fmt

* style(labrinth): fix typo

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

* fix(labrinth): field typo

* chore(labrinth): update query cache

* feat(labrinth): index disclosures in search

* refactor(labrinth): use enum instead of bools

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

* feat(labrinth): change archived status to disclosure

* feat(labrinth): use disclosure for archival status

* fix(labrinth): error type for deserialization

* fix(labrinth): migration timestamp

* fix(labrinth): add disclosures to elasticsearch schema

* disclosures in search

* update photosensitivity warning copy

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

* blue archive banner

* display AI use types

* improve labels

* add modrinth to link

* add placeholder missing disclosure report type

* add AI metadata checking to block image uploads with notice

* update copy

* update section 4 of rules

* Update rule 6 layout

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

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

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

* Update moderation checklist (#7056)

* fix: missing delphi severity (#7054)

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

* add prefix to collect

* Finish up new disclosures stage

* update r4 messages

* new r4 msg, update showcase clarity message

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

* fix: mrpack exporting with zip64 (#7064)

* fix: action bar max width (#7048)

* fix: action bar max width

* fix: width

* changelog

* Rule placeholders with subsections & anchors, update messages.

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

* Add nag for content disclosures

---------

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

* prepr

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

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

* fix(labrinth): dedupe ai usages

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

* feat(labrinth): add interactions field

* style(labrinth): cargo fmt

* feat(labrinth): granular disclosure locking

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

* move disclosures stage earlier

* include consent model info in telemetry disclosure msgs

* prepr + update date

* typo

* qa pass

* search suboptions

* add empty state

* checkboxes in column

* persistent advanced filters

* blog draft

* prepr

* support soft deletion

* reload instead of optimistically updating

* update blog

* TOS update + minor verbiage adjustment

* 45 day grace

---------

Co-authored-by: sychic <47618543+Sychic@users.noreply.github.com>
Co-authored-by: coolbot <76798835+coolbot100s@users.noreply.github.com>
Co-authored-by: ThatGravyBoat <gravy@thatgravyboat.tech>
Co-authored-by: chyzman <chyzalt@gmail.com>
Co-authored-by: Truman Gao <106889354+tdgao@users.noreply.github.com>
This commit is contained in:
Prospector
2026-08-13 09:01:18 -07:00
committed by GitHub
co-authored by ThatGravyBoat chyzman Truman Gao sychic coolbot
parent 06b7c44dcc
commit 755825b09a
230 changed files with 5802 additions and 1268 deletions
@@ -0,0 +1,22 @@
<script setup lang="ts">
import { InfoIcon } from '@modrinth/assets'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
const { formatMessage } = useVIntl()
const messages = defineMessages({
persistenceNote: {
id: 'browse.advanced-filters.persistence-note',
defaultMessage:
'Advanced exclusions are saved across sessions. Opening a shared link with filters will override them.',
},
})
</script>
<template>
<div class="mb-2 flex gap-2 px-3 text-sm text-secondary opacity-80">
<InfoIcon class="mt-0.5 size-4 shrink-0" />
<span>{{ formatMessage(messages.persistenceNote) }}</span>
</div>
</template>
@@ -4,6 +4,15 @@ import { computed, nextTick, ref, shallowRef, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useDebugLogger } from '#ui/composables/debug-logger'
import {
compatibleAdvancedFilters,
getAdvancedOptionIds,
getAvailableAdvancedIds,
mergeAdvancedPrefs,
replaceAdvancedFilters,
sameOptionIds,
useAdvancedPrefs,
} from '#ui/utils/advanced-filter-preferences'
import type {
EnvironmentSearchOverride,
FilterType,
@@ -66,6 +75,9 @@ export interface BrowseSearchState {
setPage: (page: number) => Promise<void>
clearSearch: () => void
onFilterChange: () => void
linkOverridesAdvancedPrefs: Ref<boolean>
applySavedAdvancedPrefs: () => void
}
export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchState {
@@ -197,6 +209,76 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
const effectiveCurrentFilters = computed(() =>
isServerType.value ? serverCurrentFilters.value : currentFilters.value,
)
const effectiveFilterTypes = computed(() =>
isServerType.value ? serverFilterTypes.value : filters.value,
)
const advancedPrefs = useAdvancedPrefs()
const linkOverridesAdvancedPrefs = ref(false)
const selectedAdvancedIds = computed(() => getAdvancedOptionIds(effectiveCurrentFilters.value))
function hasSearchQuery(): boolean {
return Object.keys(route.query).some((key) => !options.persistentQueryParams.includes(key))
}
function getCompatiblePrefs(): FilterValue[] {
return compatibleAdvancedFilters(advancedPrefs.value, effectiveFilterTypes.value)
}
function setEffectiveFilters(nextFilters: FilterValue[]) {
if (isServerType.value) {
serverCurrentFilters.value = nextFilters
} else {
currentFilters.value = nextFilters
}
}
function applyAdvancedPrefs(prefs: FilterValue[]) {
setEffectiveFilters(replaceAdvancedFilters(effectiveCurrentFilters.value, prefs))
}
function syncLinkOverride() {
const prefIds = getAdvancedOptionIds(getCompatiblePrefs())
linkOverridesAdvancedPrefs.value =
prefIds.length > 0 && !sameOptionIds(selectedAdvancedIds.value, prefIds)
}
function initAdvancedPrefs() {
const prefs = getCompatiblePrefs()
if (!hasSearchQuery()) {
if (prefs.length > 0) {
applyAdvancedPrefs(prefs)
}
linkOverridesAdvancedPrefs.value = false
return
}
syncLinkOverride()
}
function applySavedAdvancedPrefs() {
applyAdvancedPrefs(getCompatiblePrefs())
linkOverridesAdvancedPrefs.value = false
}
initAdvancedPrefs()
watch(selectedAdvancedIds, (selected, previous) => {
if (previous && sameOptionIds(selected, previous)) {
return
}
const nextPrefs = mergeAdvancedPrefs(
advancedPrefs.value,
selected,
getAvailableAdvancedIds(effectiveFilterTypes.value),
)
if (!sameOptionIds(advancedPrefs.value, nextPrefs)) {
advancedPrefs.value = nextPrefs
}
linkOverridesAdvancedPrefs.value = false
})
watch(
[
@@ -340,6 +422,10 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
effectiveSortTypes.value.find((sortType) => sortType.name === 'relevance') ??
effectiveSortTypes.value[0]
query.value = ''
void nextTick(() => {
initAdvancedPrefs()
})
},
)
@@ -369,5 +455,7 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
setPage,
clearSearch,
onFilterChange,
linkOverridesAdvancedPrefs,
applySavedAdvancedPrefs,
}
}
@@ -1,8 +1,9 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { SearchIcon } from '@modrinth/assets'
import { RotateCounterClockwiseIcon, SearchIcon } from '@modrinth/assets'
import { computed, ref, toValue } from 'vue'
import Admonition from '#ui/components/base/Admonition.vue'
import { Button, IconButton } from '#ui/components/base/buttons'
import Combobox, { type ComboboxOption } from '#ui/components/base/Combobox.vue'
import LoadingIndicator from '#ui/components/base/LoadingIndicator.vue'
@@ -66,6 +67,14 @@ const messages = defineMessages({
id: 'browse.no-results',
defaultMessage: 'No results found for your query!',
},
linkOverridingPreferences: {
id: 'browse.advanced-filters.link-overriding-preferences',
defaultMessage: "This link's filters differ from your saved advanced exclusions",
},
applySavedPreferences: {
id: 'browse.advanced-filters.apply-saved-preferences',
defaultMessage: 'Apply saved preferences',
},
})
function cardActionType(action: CardAction) {
@@ -151,6 +160,21 @@ function getProjectCardTags(result: Labrinth.Search.v3.ResultSearchProject, disp
@clear="ctx.clearSearch()"
/>
<Admonition
v-if="ctx.linkOverridesAdvancedPrefs.value"
type="info"
:header="formatMessage(messages.linkOverridingPreferences)"
inline-actions
center-content
>
<template #actions>
<Button type="colored" color="blue" @click="ctx.applySavedAdvancedPrefs()">
<RotateCounterClockwiseIcon />
{{ formatMessage(messages.applySavedPreferences) }}
</Button>
</template>
</Admonition>
<div class="flex flex-wrap items-center gap-2">
<Combobox
:model-value="ctx.effectiveCurrentSortType.value"
@@ -45,6 +45,8 @@ export interface BrowseManagerContext {
setPage: (page: number) => Promise<void>
clearSearch: () => void
onFilterChange: () => void
linkOverridesAdvancedPrefs: Ref<boolean>
applySavedAdvancedPrefs: () => void
getProjectLink: (result: Labrinth.Search.v3.ResultSearchProject) => string | RouteLocationRaw
getServerProjectLink: (
@@ -74,6 +76,7 @@ export interface BrowseManagerContext {
serverOnlyLabel?: ComputedRef<string>
hiddenFilterTypes?: ComputedRef<string[]>
advancedFiltersCollapsed?: Ref<boolean>
dismissedPhotosensitivityFilterWarning?: Ref<boolean>
onInstalled?: (projectId: string) => void
displayMode?: Ref<'list' | 'grid' | 'gallery'> | ComputedRef<'list' | 'grid' | 'gallery'>
@@ -1,23 +1,30 @@
<script setup lang="ts">
import { InfoIcon, XIcon } from '@modrinth/assets'
import { computed, toValue } from 'vue'
import { computed, nextTick, toValue, useTemplateRef, watch } from 'vue'
import { IconButton } from '#ui/components/base/buttons'
import Toggle from '#ui/components/base/Toggle.vue'
import PhotosensitivityWarningModal from '#ui/components/modal/PhotosensitivityWarningModal.vue'
import SearchSidebarFilter from '#ui/components/search/SearchSidebarFilter.vue'
import { useVIntl } from '#ui/composables/i18n'
import { useAdvancedPrefs } from '#ui/utils/advanced-filter-preferences'
import { commonMessages } from '#ui/utils/common-messages'
import AdvancedFiltersPersistenceNote from './components/AdvancedFiltersPersistenceNote.vue'
import { injectBrowseManager } from './providers/browse-manager'
const PHOTOSENSITIVITY_FILTER_OPTION = 'epilepsy_triggers'
const ctx = injectBrowseManager()
const { formatMessage } = useVIntl()
const advancedPrefs = useAdvancedPrefs()
const isApp = computed(() => ctx.variant === 'app')
const lockedMessages = computed(() => toValue(ctx.lockedFilterMessages))
const hiddenFilterTypes = computed(() => ctx.hiddenFilterTypes?.value ?? [])
const advancedFiltersCollapsed = computed(() => ctx.advancedFiltersCollapsed?.value ?? true)
const photosensitivityWarningModal = useTemplateRef('photosensitivityWarningModal')
function setAdvancedFiltersCollapsed(collapsed: boolean) {
if (ctx.advancedFiltersCollapsed) {
@@ -25,6 +32,40 @@ function setAdvancedFiltersCollapsed(collapsed: boolean) {
}
}
function isPhotosensitivityExclusionSelected(filters: { type: string; option: string }[]) {
return filters.some(
(filter) => filter.type === 'advanced' && filter.option === PHOTOSENSITIVITY_FILTER_OPTION,
)
}
watch(
() => ({
selected: isPhotosensitivityExclusionSelected(
ctx.isServerType.value ? ctx.serverCurrentFilters.value : ctx.currentFilters.value,
),
saved: advancedPrefs.value.includes(PHOTOSENSITIVITY_FILTER_OPTION),
}),
(current, previous) => {
if (!previous) {
return
}
const selected = current.selected && !previous.selected
const alreadySaved = previous.saved
const dismissed = ctx.dismissedPhotosensitivityFilterWarning?.value
if (selected && !alreadySaved && !dismissed) {
nextTick(() => photosensitivityWarningModal.value?.show())
}
},
)
function onPhotosensitivityWarningDismiss(dontShowAgain: boolean) {
if (dontShowAgain && ctx.dismissedPhotosensitivityFilterWarning) {
ctx.dismissedPhotosensitivityFilterWarning.value = true
}
}
function closeFiltersMenu() {
if (ctx.filtersMenuOpen) {
ctx.filtersMenuOpen.value = false
@@ -88,6 +129,11 @@ function getFilterOpenByDefault(filterId: string): boolean {
<template>
<slot name="prepend" />
<PhotosensitivityWarningModal
ref="photosensitivityWarningModal"
@dismiss="onPhotosensitivityWarningDismiss"
/>
<div v-if="ctx.filtersMenuOpen?.value" class="fixed inset-0 z-40 bg-bg" />
<div
@@ -173,12 +219,17 @@ function getFilterOpenByDefault(filterId: string): boolean {
:content-class="contentClass"
:inner-panel-class="innerPanelClass"
:open-by-default="getFilterOpenByDefault(filterType.id)"
@on-open="() => filterType.id === 'advanced' && setAdvancedFiltersCollapsed(false)"
@on-close="() => filterType.id === 'advanced' && setAdvancedFiltersCollapsed(true)"
>
<template #header>
<h3 :class="isApp ? 'text-base m-0' : 'm-0 text-base font-semibold'">
{{ filterType.formatted_name }}
</h3>
</template>
<template v-if="filterType.id === 'advanced'" #prefix>
<AdvancedFiltersPersistenceNote />
</template>
</SearchSidebarFilter>
</template>
<template v-else>
@@ -205,8 +256,11 @@ function getFilterOpenByDefault(filterId: string): boolean {
{{ filter.formatted_name }}
</h3>
</template>
<template v-if="filter.id === 'advanced'" #prefix>
<AdvancedFiltersPersistenceNote />
</template>
<template
v-if="
v-else-if="
lockedMessages?.gameVersionShaderMessage &&
ctx.projectType.value === 'shader' &&
filter.id === 'game_version'
@@ -215,7 +269,7 @@ function getFilterOpenByDefault(filterId: string): boolean {
>
<div class="mb-4 grid grid-cols-[auto_1fr] gap-2 px-3 text-sm font-medium text-blue">
<InfoIcon class="mt-1 size-4" />
<span>{{ lockedMessages.gameVersionShaderMessage }}</span>
<span>{{ lockedMessages?.gameVersionShaderMessage }}</span>
</div>
</template>
<template v-if="lockedMessages?.gameVersion" #locked-game_version>