mirror of
https://github.com/modrinth/code.git
synced 2026-08-31 12:05:53 +00:00
feat: implement more project field validators
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user