feat: implement more project field validators

This commit is contained in:
tdgao
2026-08-25 23:18:31 -06:00
parent b9a2e95da9
commit 0d61960855
21 changed files with 601 additions and 77 deletions
@@ -9,13 +9,19 @@
</div>
</template>
<script setup>
<script setup lang="ts">
import { TriangleAlertIcon, XCircleIcon } from '@modrinth/assets'
import { useVIntl } from '@modrinth/ui'
import { type MessageDescriptor, useVIntl } from '@modrinth/ui'
import { computed } from 'vue'
const props = defineProps({
check: { type: Object, default: null },
interface ValidationCheck {
severity: 'valid' | 'warn' | 'error'
message?: MessageDescriptor
values?: Record<string, unknown>
}
const props = withDefaults(defineProps<{ check?: ValidationCheck | null }>(), {
check: null,
})
const { formatMessage } = useVIntl()
@@ -161,7 +161,10 @@ import { computed, defineAsyncComponent, h } from 'vue'
import ValidationMessage from '~/components/ValidationMessage.vue'
import SlugSuggestions from '~/components/ui/SlugSuggestions.vue'
import { validateProjectText } from '~/composables/project-text-validation'
import {
useProjectSummaryValidation,
useProjectTitleValidation,
} from '~/composables/project-field-validation'
import {
useProjectSlugSuggestions,
useSlugSuggestionVisibility,
@@ -331,8 +334,8 @@ const visibilities = ref<VisibilityOption[]>([
])
const visibility = ref<VisibilityOption>(visibilities.value[0])
const nameValidation = computed(() => validateProjectText(name.value))
const summaryValidation = computed(() => validateProjectText(description.value))
const nameValidation = useProjectTitleValidation(name)
const summaryValidation = useProjectSummaryValidation(description, name)
const disableCreate = computed(() => {
if (hasHitLimit.value) return true
@@ -0,0 +1,109 @@
import {
checkLink,
extractProjectLinks,
getLinkCheckState,
type LinkCheckContext,
type LinkCheckResult,
type ProjectTextValidationResult,
type ProjectTitleMetadata,
validateProjectDescription,
validateProjectSummary,
validateProjectTitle,
} from '@modrinth/moderation'
import { defineMessages } from '@modrinth/ui'
import { computed, type MaybeRefOrGetter, onScopeDispose, ref, toValue, watch } from 'vue'
export const projectTextValidationMessages = defineMessages({
resolveIssuesToSave: {
id: 'project.text-validation.resolve-issues-to-save',
defaultMessage: 'Resolve the issues from your edits to save.',
},
})
function useProjectTitleMetadata() {
const generatedState = useGeneratedState()
return computed<ProjectTitleMetadata>(() => ({
gameVersions: generatedState.value.gameVersions.map(({ version }) => version),
loaders: generatedState.value.loaders.map(({ name }) => name),
}))
}
export function useProjectTitleValidation(text: MaybeRefOrGetter<string | null | undefined>) {
const metadata = useProjectTitleMetadata()
return computed(() => validateProjectTitle(toValue(text), metadata.value))
}
export function useProjectSummaryValidation(
summary: MaybeRefOrGetter<string | null | undefined>,
title: MaybeRefOrGetter<string | null | undefined>,
) {
return computed(() => validateProjectSummary(toValue(summary), toValue(title)))
}
export function useProjectDescriptionValidation(
description: MaybeRefOrGetter<string | null | undefined>,
) {
const linkValidation = ref<LinkCheckResult | null>(null)
const pending = ref(false)
let debounceTimer: ReturnType<typeof setTimeout> | undefined
let requestId = 0
watch(
() => toValue(description),
(text) => {
clearTimeout(debounceTimer)
const currentRequestId = ++requestId
linkValidation.value = null
if (import.meta.server) return
const links = extractProjectLinks(text ?? '')
if (links.length === 0) {
pending.value = false
return
}
pending.value = true
debounceTimer = setTimeout(async () => {
const contexts: LinkCheckContext[] = links.map((url) => ({
field: 'description',
generalContent: true,
url,
}))
try {
await Promise.all(contexts.map((context) => checkLink(context)))
if (currentRequestId !== requestId) return
const checks = contexts
.map((context) => getLinkCheckState(context))
.filter((check): check is LinkCheckResult => check !== undefined)
linkValidation.value =
checks.find((check) => check.severity === 'error') ??
checks.find((check) => check.severity === 'warn') ??
null
} catch {
if (currentRequestId === requestId) linkValidation.value = null
} finally {
if (currentRequestId === requestId) pending.value = false
}
}, 500)
},
{ immediate: true },
)
onScopeDispose(() => {
clearTimeout(debounceTimer)
requestId++
})
const validation = computed<ProjectTextValidationResult | LinkCheckResult | null>(
() => validateProjectDescription(toValue(description)) ?? linkValidation.value,
)
return {
pending,
validation,
}
}
@@ -1,42 +0,0 @@
import { validateNonStandardText, validateProfanity } from '@modrinth/moderation'
import { defineMessages, type MessageDescriptor } from '@modrinth/ui'
export interface ProjectTextValidationResult {
severity: 'error'
message: MessageDescriptor
}
const messages = defineMessages({
slur: {
id: 'project.text-validation.slur',
defaultMessage: 'Slurs are not allowed.',
},
profanity: {
id: 'project.text-validation.profanity',
defaultMessage: 'Profanity is not allowed.',
},
nonStandardText: {
id: 'project.text-validation.non-standard-text',
defaultMessage: 'Non-standard text characters are not allowed.',
},
})
export function validateProjectText(
text: string | null | undefined,
): ProjectTextValidationResult | null {
if (!text) return null
const profanity = validateProfanity(text)
if (profanity.slurCount > 0) {
return { severity: 'error', message: messages.slur }
}
if (profanity.profanityCount > 0) {
return { severity: 'error', message: messages.profanity }
}
if (!validateNonStandardText(text).valid) {
return { severity: 'error', message: messages.nonStandardText }
}
return null
}
+4 -7
View File
@@ -4247,14 +4247,11 @@
"project.settings.tags.upload-version-first.heading": {
"message": "Upload versions before adding tags"
},
"project.text-validation.non-standard-text": {
"message": "Non-standard text characters are not allowed."
"project.slug-suggestions.label": {
"message": "Suggestions:"
},
"project.text-validation.profanity": {
"message": "Profanity is not allowed."
},
"project.text-validation.slur": {
"message": "Slurs are not allowed."
"project.text-validation.resolve-issues-to-save": {
"message": "Resolve the issues from your edits to save."
},
"project.versions.copy-id-option": {
"message": "Copy ID"
@@ -44,6 +44,7 @@
:maxlength="64"
placeholder="Enter title..."
/>
<ValidationMessage :check="galleryTitleValidation" />
<label for="gallery-image-desc">
<span class="label__title">Description</span>
</label>
@@ -53,6 +54,7 @@
:maxlength="255"
placeholder="Enter description..."
/>
<ValidationMessage :check="galleryDescriptionValidation" />
<label for="gallery-image-ordering">
<span class="label__title">Order Index</span>
</label>
@@ -90,7 +92,7 @@
v-if="editIndex === -1"
type="colored"
color="brand"
:disabled="shouldPreventActions"
:disabled="shouldPreventActions || galleryFieldsInvalid"
@click="createGalleryItem"
>
<PlusIcon aria-hidden="true" />
@@ -100,7 +102,7 @@
v-else
type="colored"
color="brand"
:disabled="shouldPreventActions"
:disabled="shouldPreventActions || galleryFieldsInvalid"
@click="editGalleryItem"
>
<SaveIcon aria-hidden="true" />
@@ -296,6 +298,7 @@ import {
UploadIcon,
XIcon,
} from '@modrinth/assets'
import { validateProjectText } from '@modrinth/moderation'
import {
Button,
ButtonLink,
@@ -312,6 +315,7 @@ import {
import { useEventListener } from '@vueuse/core'
import AiImageWarningModal from '~/components/ui/AiImageWarningModal.vue'
import ValidationMessage from '~/components/ValidationMessage.vue'
import { fileDeclaresAi } from '~/helpers/c2pa'
import { isPermission } from '~/utils/permissions.ts'
@@ -379,6 +383,11 @@ const previewImage = ref<string | null>(null)
// UI state
const shouldPreventActions = ref(false)
const galleryTitleValidation = computed(() => validateProjectText(editTitle.value))
const galleryDescriptionValidation = computed(() => validateProjectText(editDescription.value))
const galleryFieldsInvalid = computed(
() => !!galleryTitleValidation.value || !!galleryDescriptionValidation.value,
)
// Constant for accepted file types
const MC_SERVER_BANNER_NAME = '__mc_server_banner__'
@@ -479,6 +488,7 @@ function showPreviewImage() {
// CRUD operations
async function createGalleryItem() {
if (galleryFieldsInvalid.value) return
shouldPreventActions.value = true
startLoading()
@@ -499,6 +509,7 @@ async function createGalleryItem() {
}
async function editGalleryItem() {
if (galleryFieldsInvalid.value) return
shouldPreventActions.value = true
startLoading()
@@ -32,6 +32,11 @@
:modified="current"
:saving="saving"
:can-save="canSave"
:save-disabled-reason="
hasPermission && hasValidationIssues
? projectTextValidationMessages.resolveIssuesToSave
: undefined
"
@reset="reset"
@save="save"
/>
@@ -56,7 +61,10 @@ import { computed, useTemplateRef } from 'vue'
import AiImageWarningModal from '~/components/ui/AiImageWarningModal.vue'
import ValidationMessage from '~/components/ValidationMessage.vue'
import { useImageUpload } from '~/composables/image-upload.ts'
import { validateProjectText } from '~/composables/project-text-validation'
import {
projectTextValidationMessages,
useProjectDescriptionValidation,
} from '~/composables/project-field-validation'
import { fileDeclaresAi } from '~/helpers/c2pa'
const { projectV2: project, currentMember, patchProject } = injectProjectPageContext()
@@ -86,8 +94,12 @@ const hasPermission = computed(
(currentMember.value.permissions & TeamMemberPermission.EDIT_BODY) ===
TeamMemberPermission.EDIT_BODY,
)
const descriptionValidation = computed(() => validateProjectText(current.value.description))
const canSave = computed(() => hasPermission.value && !descriptionValidation.value)
const { pending: descriptionLinksPending, validation: descriptionValidation } =
useProjectDescriptionValidation(() => current.value.description)
const hasValidationIssues = computed(() => descriptionValidation.value?.severity === 'error')
const canSave = computed(
() => hasPermission.value && !hasValidationIssues.value && !descriptionLinksPending.value,
)
async function save() {
if (!canSave.value) return
@@ -44,6 +44,7 @@
:maxlength="64"
placeholder="Enter title..."
/>
<ValidationMessage :check="galleryTitleValidation" />
<label for="gallery-image-desc">
<span class="label__title">Description</span>
</label>
@@ -53,6 +54,7 @@
:maxlength="255"
placeholder="Enter description..."
/>
<ValidationMessage :check="galleryDescriptionValidation" />
<label for="gallery-image-ordering">
<span class="label__title">Order Index</span>
</label>
@@ -90,7 +92,7 @@
v-if="editIndex === -1"
type="colored"
color="brand"
:disabled="shouldPreventActions"
:disabled="shouldPreventActions || galleryFieldsInvalid"
@click="createGalleryItem"
>
<PlusIcon aria-hidden="true" />
@@ -100,7 +102,7 @@
v-else
type="colored"
color="brand"
:disabled="shouldPreventActions"
:disabled="shouldPreventActions || galleryFieldsInvalid"
@click="editGalleryItem"
>
<SaveIcon aria-hidden="true" />
@@ -286,6 +288,7 @@ import {
UploadIcon,
XIcon,
} from '@modrinth/assets'
import { validateProjectText } from '@modrinth/moderation'
import {
Button,
ButtonLink,
@@ -302,6 +305,7 @@ import {
} from '@modrinth/ui'
import AiImageWarningModal from '~/components/ui/AiImageWarningModal.vue'
import ValidationMessage from '~/components/ValidationMessage.vue'
import { fileDeclaresAi } from '~/helpers/c2pa'
import { isPermission } from '~/utils/permissions.ts'
@@ -339,6 +343,11 @@ const editOrder = ref(null)
const editFile = ref(null)
const previewImage = ref(null)
const shouldPreventActions = ref(false)
const galleryTitleValidation = computed(() => validateProjectText(editTitle.value))
const galleryDescriptionValidation = computed(() => validateProjectText(editDescription.value))
const galleryFieldsInvalid = computed(
() => !!galleryTitleValidation.value || !!galleryDescriptionValidation.value,
)
const MC_SERVER_BANNER_NAME = '__mc_server_banner__'
const acceptFileTypes = 'image/png,image/jpeg,image/gif,image/webp,.png,.jpeg,.gif,.webp'
@@ -419,6 +428,7 @@ const showPreviewImage = () => {
}
const createGalleryItem = async () => {
if (galleryFieldsInvalid.value) return
shouldPreventActions.value = true
const success = await createGalleryItemMutation(
@@ -437,6 +447,7 @@ const createGalleryItem = async () => {
}
const editGalleryItem = async () => {
if (galleryFieldsInvalid.value) return
shouldPreventActions.value = true
const success = await editGalleryItemMutation(
@@ -16,7 +16,10 @@ import {
import ValidationMessage from '~/components/ValidationMessage.vue'
import SlugSuggestions from '~/components/ui/SlugSuggestions.vue'
import { validateProjectText } from '~/composables/project-text-validation'
import {
useProjectSummaryValidation,
useProjectTitleValidation,
} from '~/composables/project-field-validation'
import {
useProjectSlugSuggestions,
useSlugSuggestionVisibility,
@@ -53,8 +56,11 @@ const {
const { confirmLeaveModal } = usePageLeaveSafety(hasChanges)
const titleValidation = computed(() => validateProjectText(current.value.title))
const taglineValidation = computed(() => validateProjectText(current.value.tagline))
const titleValidation = useProjectTitleValidation(() => current.value.title)
const taglineValidation = useProjectSummaryValidation(
() => current.value.tagline,
() => current.value.title,
)
const canSave = computed(() => !titleValidation.value && !taglineValidation.value)
const {
onFocusIn: onSlugSuggestionFocusIn,
@@ -294,6 +294,11 @@
:modified="modified"
:saving="saving"
:can-save="canSave"
:save-disabled-reason="
hasPermission && hasValidationIssues
? projectTextValidationMessages.resolveIssuesToSave
: undefined
"
@reset="resetChanges"
@save="handleSave"
/>
@@ -334,7 +339,11 @@ import AiImageWarningModal from '~/components/ui/AiImageWarningModal.vue'
import SlugSuggestions from '~/components/ui/SlugSuggestions.vue'
import ValidationMessage from '~/components/ValidationMessage.vue'
import { useAuth } from '~/composables/auth.js'
import { validateProjectText } from '~/composables/project-text-validation'
import {
projectTextValidationMessages,
useProjectSummaryValidation,
useProjectTitleValidation,
} from '~/composables/project-field-validation'
import {
useProjectSlugSuggestions,
useSlugSuggestionVisibility,
@@ -424,11 +433,10 @@ const hasPermission = computed(() => {
return ((currentMember.value?.permissions ?? 0) & EDIT_DETAILS) === EDIT_DETAILS
})
const nameValidation = computed(() => validateProjectText(name.value))
const summaryValidation = computed(() => validateProjectText(summary.value))
const canSave = computed(
() => hasPermission.value && !nameValidation.value && !summaryValidation.value,
)
const nameValidation = useProjectTitleValidation(name)
const summaryValidation = useProjectSummaryValidation(summary, name)
const hasValidationIssues = computed(() => !!nameValidation.value || !!summaryValidation.value)
const canSave = computed(() => hasPermission.value && !hasValidationIssues.value)
const monetizationToggleDisabled = computed(() => !hasPermission.value || isForceDemonetized.value)