Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
39ef913
feat: implement profanity and non-standard text validators
tdgao Aug 24, 2026
bc37c6d
Better External Link Validation (#7005)
chyzman Aug 24, 2026
1ec4c6c
fix: link checks merge conflict issues
tdgao Aug 24, 2026
a23df8a
feat: hook up validators with input fields
tdgao Aug 24, 2026
56b8cae
feat: implement profanity and non-standard text validators
tdgao Aug 24, 2026
4b2f8de
Better External Link Validation (#7005)
chyzman Aug 24, 2026
e618c16
fix: link checks merge conflict issues
tdgao Aug 24, 2026
eb22882
feat: hook up validators with input fields
tdgao Aug 24, 2026
8665419
prepr + typos whitelist
Prospector Aug 24, 2026
b080829
typo
Prospector Aug 24, 2026
fe34ed5
Merge branch 'truman/content-moderation' of github.com:modrinth/code …
tdgao Aug 25, 2026
9ddd17d
fix: imports
tdgao Aug 25, 2026
3fee68d
prepr
Prospector Aug 25, 2026
ce64f73
Merge remote-tracking branch 'origin/main' into truman/content-modera…
Prospector Aug 25, 2026
68f50cb
prepr
Prospector Aug 25, 2026
b9a2e95
feat: add slug suggestions
tdgao Aug 26, 2026
0d61960
feat: implement more project field validators
tdgao Aug 26, 2026
a348240
feat: update nags with field validation, splitting between required/w…
tdgao Aug 26, 2026
c3704d8
refactor: publishing checklist use accordion
tdgao Aug 26, 2026
d1c541d
feat: when project is processing, keep required and warning nags
tdgao Aug 26, 2026
621d189
feat: add filter moderation queue by project id
tdgao Aug 26, 2026
89b392d
fix: allow admin role to by pass error validator check
tdgao Aug 26, 2026
ae4519c
feat: polish profanity validation
tdgao Aug 26, 2026
31e405e
fix: not using whole words
tdgao Aug 26, 2026
b1d3337
fix: less false positives in profanity and fix gallary nag
tdgao Aug 26, 2026
6b0ae54
fix: grab cursor when nothing to drag
tdgao Aug 26, 2026
ed85927
feat: remove loader detection from project name and set version as re…
tdgao Aug 26, 2026
5dcd66f
feat: change links in summary to be a blocking error and combined wit…
tdgao Aug 27, 2026
3ec0dfc
feat: move clean up summary as a blocking error
tdgao Aug 27, 2026
dc613b4
fix: checking for banned banned external links outside of external links
tdgao Aug 27, 2026
b7b276c
feat: change project id filter to moderate by id
tdgao Aug 27, 2026
1f3f37c
refactor: links validator
tdgao Aug 27, 2026
18c1f31
refactor: centralize project validation and nags into validation rules
tdgao Aug 28, 2026
d7442e8
feat: clean up link detection in summary and description
tdgao Aug 28, 2026
0d90c20
feat: add allowed non-standard text
tdgao Aug 28, 2026
d3146a6
remove: image heavy description warning
tdgao Aug 28, 2026
215f9a1
feat: bump name minecraft check to error
tdgao Aug 28, 2026
c8499db
feat: bump summary too short to errro
tdgao Aug 28, 2026
725b851
feat: add show validation message with debounce
tdgao Aug 28, 2026
a926cff
feat: bump description too short to error, add spam validation for de…
tdgao Aug 28, 2026
84a16d7
feat: get header length by rendering markdown to html
tdgao Aug 28, 2026
df58a2e
feat: check summary formatting by move link detection into its own va…
tdgao Aug 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions _typos.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ extend-exclude = [
"packages/utils/",
"packages/ui/",
"packages/blog/",
# contains licenses like `CC-BY-ND-4.0`
"packages/moderation/src/data/stages/license.ts",
"packages/moderation/src/utils.ts",
# contains profanity word lists with deliberate misspellings
"packages/moderation/src/validators/profanity/",
# contains payment card IDs like `IY1VMST1MOXS` which are flagged
"apps/labrinth/src/queue/payouts/mod.rs",
# contains domain names with deliberate typos
"apps/labrinth/assets/disposable_email_blocklist.txt"
]

[default]
Expand Down
82 changes: 82 additions & 0 deletions apps/frontend/src/components/ValidationMessage.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<template>
<div v-if="validations.length > 0" class="flex w-full flex-col gap-1.5">
<div
v-for="(validation, index) in validations"
:key="validation.code ?? validation.message?.id ?? index"
class="flex w-full items-center gap-1.5"
:class="{
'text-red': validation.severity === 'error',
'text-orange': validation.severity === 'warn' || validation.severity === 'warning',
'text-purple': validation.severity === 'suggestion',
}"
>
<component
:is="
validation.severity === 'error'
? XCircleIcon
: validation.severity === 'suggestion'
? LightBulbIcon
: TriangleAlertIcon
"
class="my-auto"
/>
{{ validation.message ? formatMessage(validation.message, validation.values) : undefined }}
</div>
</div>
</template>

<script setup lang="ts">
import { LightBulbIcon, TriangleAlertIcon, XCircleIcon } from '@modrinth/assets'
import { type MessageDescriptor, useVIntl } from '@modrinth/ui'
import { computed, onScopeDispose, shallowRef, watch } from 'vue'

interface ValidationCheck {
code?: string
severity: 'valid' | 'warn' | 'warning' | 'suggestion' | 'error'
message?: MessageDescriptor
values?: Record<string, unknown>
}

type ValidationCheckInput = ValidationCheck | ValidationCheck[] | null

const props = withDefaults(
defineProps<{
check?: ValidationCheckInput
debounce?: number
}>(),
{
check: null,
debounce: 300,
},
)

const { formatMessage } = useVIntl()
const displayedCheck = shallowRef<ValidationCheckInput>(props.check)
let debounceTimer: ReturnType<typeof setTimeout> | undefined

watch(
() => props.check,
(check) => {
clearTimeout(debounceTimer)
if (props.debounce <= 0) {
displayedCheck.value = check
return
}

debounceTimer = setTimeout(() => {
displayedCheck.value = check
}, props.debounce)
},
)

onScopeDispose(() => clearTimeout(debounceTimer))

const validations = computed(() =>
(Array.isArray(displayedCheck.value)
? displayedCheck.value
: displayedCheck.value
? [displayedCheck.value]
: []
).filter((validation) => validation.severity !== 'valid'),
)
</script>
63 changes: 63 additions & 0 deletions apps/frontend/src/components/ui/SlugSuggestions.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<template>
<Transition name="slug-suggestions">
<div v-if="visible && hasSuggestions" class="mt-2 grid grid-rows-[1fr]">
<div class="flex min-h-0 flex-wrap items-center gap-2 overflow-hidden">
<span class="text-sm text-secondary">{{ formatMessage(messages.label) }}</span>
<TagItem
v-for="suggestion in suggestions"
:key="suggestion"
:action="() => emit('select', suggestion)"
@mousedown.prevent
>
<CheckIcon v-if="suggestion === selected" aria-hidden="true" />
{{ suggestion }}
</TagItem>
</div>
</div>
</Transition>
</template>

<script setup lang="ts">
import { CheckIcon } from '@modrinth/assets'
import { defineMessages, TagItem, useVIntl } from '@modrinth/ui'
import { computed } from 'vue'

const props = defineProps<{
selected: string
suggestions: string[]
visible: boolean
}>()

const emit = defineEmits<{
select: [suggestion: string]
}>()

const hasSuggestions = computed(() =>
props.suggestions.some((suggestion) => suggestion !== props.selected),
)

const { formatMessage } = useVIntl()
const messages = defineMessages({
label: {
id: 'project.slug-suggestions.label',
defaultMessage: 'Suggestions:',
},
})
</script>

<style scoped>
.slug-suggestions-enter-active,
.slug-suggestions-leave-active {
transition:
grid-template-rows 150ms ease,
opacity 150ms ease,
transform 150ms ease;
}

.slug-suggestions-enter-from,
.slug-suggestions-leave-to {
grid-template-rows: 0fr;
opacity: 0;
transform: translateY(-0.25rem);
}
</style>
61 changes: 57 additions & 4 deletions apps/frontend/src/components/ui/create/ProjectCreateModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,16 @@
:disabled="hasHitLimit"
@update:model-value="updatedName()"
/>
<ValidationMessage :check="nameValidation" />
</div>
<label for="slug" class="flex flex-col gap-2.5">
<span class="text-md font-semibold text-contrast">
<div
class="flex flex-col gap-2.5"
@focusin="onSlugSuggestionFocusIn"
@focusout="onSlugSuggestionFocusOut"
>
<label for="slug" class="text-md font-semibold text-contrast">
{{ formatMessage(messages.urlLabel) }}
</span>
</label>
<Input
id="slug"
v-model="slug"
Expand All @@ -58,7 +63,13 @@
>
<template #prefix>https://modrinth.com/project/</template>
</Input>
</label>
<SlugSuggestions
:selected="slug"
:suggestions="slugSuggestions"
:visible="showSlugSuggestions"
@select="selectSlugSuggestion"
/>
</div>
<div class="flex flex-col gap-2.5">
<label for="owner">
<span class="text-md font-semibold text-contrast">
Expand Down Expand Up @@ -106,6 +117,7 @@
:placeholder="formatMessage(messages.summaryPlaceholder)"
:disabled="hasHitLimit"
/>
<ValidationMessage :check="summaryValidation" />
<span>{{ formatMessage(messages.summaryDescription) }}</span>
</div>
<div class="flex justify-end gap-2.5">
Expand Down Expand Up @@ -147,6 +159,16 @@ import {
} from '@modrinth/ui'
import { computed, defineAsyncComponent, h } from 'vue'

import SlugSuggestions from '~/components/ui/SlugSuggestions.vue'
import ValidationMessage from '~/components/ValidationMessage.vue'
import {
useProjectSummaryValidation,
useProjectTitleValidation,
} from '~/composables/project-field-validation'
import {
useProjectSlugSuggestions,
useSlugSuggestionVisibility,
} from '~/composables/project-slug-suggestions'
import { generateUrlSlug } from '~/utils/slugs'

import CreateLimitAlert from './CreateLimitAlert.vue'
Expand Down Expand Up @@ -272,6 +294,16 @@ const name = ref('')
const slug = ref('')
const description = ref('')
const manualSlug = ref(false)
const {
onFocusIn: onSlugSuggestionFocusIn,
onFocusOut: onSlugSuggestionFocusOut,
visible: showSlugSuggestions,
} = useSlugSuggestionVisibility()
const { checking: checkingSlugSuggestions, suggestions: slugSuggestions } =
useProjectSlugSuggestions({
title: name,
username: () => auth.value.user?.username,
})
const projectType = ref<ProjectTypes>('project')
const projectTypeOptions = computed<ComboboxOption<ProjectTypes>[]>(() => [
{
Expand Down Expand Up @@ -302,9 +334,19 @@ const visibilities = ref<VisibilityOption[]>([
])
const visibility = ref<VisibilityOption>(visibilities.value[0])

const nameValidation = useProjectTitleValidation(name)
const summaryValidation = useProjectSummaryValidation(description, name)

const disableCreate = computed(() => {
if (hasHitLimit.value) return true
if (
nameValidation.value.some((validation) => validation.severity === 'error') ||
summaryValidation.value.some((validation) => validation.severity === 'error')
)
return true
if (!name.value.trim() || !slug.value.trim()) return true
if (!manualSlug.value && checkingSlugSuggestions.value) return true
if (!manualSlug.value && !slugSuggestions.value.includes(slug.value)) return true
if (description.value.trim().length < 3) return true
if (owner.value !== 'self' && !organizations.value.find((org) => org.id === owner.value))
return true
Expand Down Expand Up @@ -391,6 +433,7 @@ async function fetchOrganizations() {
}

async function createProject() {
if (disableCreate.value) return
startLoading()

const formData = new FormData()
Expand Down Expand Up @@ -474,6 +517,7 @@ async function show(event?: MouseEvent, options?: ShowOptions) {
slug.value = ''
description.value = ''
manualSlug.value = false
showSlugSuggestions.value = false
owner.value = 'self'
projectType.value = options?.type ?? 'project'
await fetchOrganizations()
Expand All @@ -485,4 +529,13 @@ function updatedName() {
slug.value = generateUrlSlug(name.value)
}
}

function selectSlugSuggestion(suggestion: string) {
slug.value = suggestion
manualSlug.value = true
}

watch([slugSuggestions, checkingSlugSuggestions], ([suggestions, checking]) => {
if (!manualSlug.value && !checking) slug.value = suggestions[0] ?? ''
})
</script>
88 changes: 88 additions & 0 deletions apps/frontend/src/components/ui/moderation/ModerateByIdsModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<script setup lang="ts">
import { CheckIcon, XIcon } from '@modrinth/assets'
import { Button, NewModal, Textarea } from '@modrinth/ui'
import { nextTick, ref, useTemplateRef } from 'vue'

const emit = defineEmits<{
apply: [projectIds: string[]]
}>()

const modalRef = useTemplateRef<InstanceType<typeof NewModal>>('modalRef')
const textareaRef = useTemplateRef<InstanceType<typeof Textarea>>('textareaRef')
const input = ref('')
const error = ref('')

function parseProjectIds(value: string): string[] {
return [
...new Set(
value
.split(/[,\r\n]+/)
.map((id) => id.replace(/\s+/g, ''))
.filter(Boolean),
),
]
}

async function show() {
input.value = ''
error.value = ''
modalRef.value?.show()
await nextTick()
textareaRef.value?.focus()
}

function hide() {
modalRef.value?.hide()
}

function apply() {
const projectIds = parseProjectIds(input.value)
if (projectIds.length === 0) {
error.value = 'Enter at least one project ID.'
return
}

emit('apply', projectIds)
hide()
}

defineExpose({ show, hide })
</script>

<template>
<NewModal ref="modalRef" header="Moderate by IDs" width="36rem" max-width="calc(100vw - 2rem)">
<form class="flex flex-col gap-4" @submit.prevent="apply">
<div class="flex flex-col gap-2">
<label class="font-semibold text-contrast" for="moderation-project-ids">
Project IDs to moderate
</label>
<Textarea
id="moderation-project-ids"
ref="textareaRef"
v-model="input"
:rows="10"
:error="!!error"
resize="vertical"
placeholder="Enter project IDs separated by commas or new lines"
wrapper-class="min-h-48"
@input="error = ''"
/>
<span v-if="error" class="text-sm font-semibold text-red">{{ error }}</span>
<span v-else class="text-sm text-secondary">
Separate IDs with commas or new lines. Whitespace and duplicate IDs are removed.
</span>
</div>

<div class="flex justify-end gap-2">
<Button native-type="button" @click="hide">
<XIcon aria-hidden="true" />
Cancel
</Button>
<Button type="colored" color="brand" native-type="submit">
<CheckIcon aria-hidden="true" />
Apply
</Button>
</div>
</form>
</NewModal>
</template>
Loading