mirror of
https://github.com/modrinth/code.git
synced 2026-09-05 06:19:11 +00:00
feat: move validation logic to use labrinth
This commit is contained in:
@@ -3,10 +3,10 @@
|
||||
<div
|
||||
v-for="(validation, index) in validations"
|
||||
:key="validation.code ?? validation.message?.id ?? index"
|
||||
class="flex w-full items-center gap-1.5"
|
||||
class="flex w-full items-start gap-1.5"
|
||||
:class="{
|
||||
'text-red': validation.severity === 'error',
|
||||
'text-orange': validation.severity === 'warn' || validation.severity === 'warning',
|
||||
'text-orange': validation.severity === 'warning',
|
||||
'text-purple': validation.severity === 'suggestion',
|
||||
}"
|
||||
>
|
||||
@@ -18,7 +18,7 @@
|
||||
? LightBulbIcon
|
||||
: TriangleAlertIcon
|
||||
"
|
||||
class="my-auto"
|
||||
class="mt-0.5"
|
||||
/>
|
||||
{{ validation.message ? formatMessage(validation.message, validation.values) : undefined }}
|
||||
</div>
|
||||
@@ -27,15 +27,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { LightBulbIcon, TriangleAlertIcon, XCircleIcon } from '@modrinth/assets'
|
||||
import { type MessageDescriptor, useVIntl } from '@modrinth/ui'
|
||||
import type { FieldValidationMessage } from '@modrinth/moderation'
|
||||
import { 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 ValidationCheck = Omit<FieldValidationMessage, 'code'> & { code?: string }
|
||||
|
||||
type ValidationCheckInput = ValidationCheck | ValidationCheck[] | null
|
||||
|
||||
@@ -72,11 +68,10 @@ watch(
|
||||
onScopeDispose(() => clearTimeout(debounceTimer))
|
||||
|
||||
const validations = computed(() =>
|
||||
(Array.isArray(displayedCheck.value)
|
||||
Array.isArray(displayedCheck.value)
|
||||
? displayedCheck.value
|
||||
: displayedCheck.value
|
||||
? [displayedCheck.value]
|
||||
: []
|
||||
).filter((validation) => validation.severity !== 'valid'),
|
||||
: [],
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
:disabled="hasHitLimit"
|
||||
@update:model-value="updatedName()"
|
||||
/>
|
||||
<ValidationMessage :check="nameValidation" />
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col gap-2.5"
|
||||
@@ -117,7 +116,6 @@
|
||||
:placeholder="formatMessage(messages.summaryPlaceholder)"
|
||||
:disabled="hasHitLimit"
|
||||
/>
|
||||
<ValidationMessage :check="summaryValidation" />
|
||||
<span>{{ formatMessage(messages.summaryDescription) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2.5">
|
||||
@@ -161,11 +159,6 @@ import {
|
||||
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,
|
||||
@@ -336,16 +329,8 @@ 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
|
||||
|
||||
@@ -1,24 +1,5 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
type Nag,
|
||||
projectDescriptionValidationRules,
|
||||
projectDisclosureTextValidationRules,
|
||||
projectDisclosureValidationRules,
|
||||
projectGalleryTextValidationRules,
|
||||
projectGalleryValidationRules,
|
||||
projectIconValidationRules,
|
||||
projectLicenseValidationRules,
|
||||
projectLinksValidationRules,
|
||||
projectModerationValidationRules,
|
||||
projectNameValidationRules,
|
||||
projectPermissionsValidationRules,
|
||||
projectServerSettingsValidationRules,
|
||||
projectSummaryValidationRules,
|
||||
projectTagsValidationRules,
|
||||
projectVersionValidationRules,
|
||||
toNags,
|
||||
type ValidationRuleDefinition,
|
||||
} from '@modrinth/moderation'
|
||||
import { type Nag, nagDefinitions, toProjectNag } from '@modrinth/moderation'
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
@@ -127,39 +108,41 @@ const previewValues = {
|
||||
value: 'example',
|
||||
}
|
||||
|
||||
function createPreviewNags(rules: Readonly<Record<string, ValidationRuleDefinition>>): Nag[] {
|
||||
return Object.entries(rules).flatMap(([code, rule]) =>
|
||||
toNags([
|
||||
{
|
||||
code,
|
||||
message: rule.presentation.message,
|
||||
rule,
|
||||
values: previewValues,
|
||||
},
|
||||
]),
|
||||
)
|
||||
}
|
||||
const suggestionKinds = new Set<Labrinth.Projects.v3.NormalizedProjectNagKind>([
|
||||
'add-icon',
|
||||
'feature-gallery-image',
|
||||
'add-links',
|
||||
'add-links-server',
|
||||
'select-language',
|
||||
'select-tags',
|
||||
'check-disclosures',
|
||||
])
|
||||
|
||||
const validationRuleSets = [
|
||||
projectNameValidationRules,
|
||||
projectSummaryValidationRules,
|
||||
projectIconValidationRules,
|
||||
projectGalleryTextValidationRules,
|
||||
projectGalleryValidationRules,
|
||||
projectDescriptionValidationRules,
|
||||
projectLicenseValidationRules,
|
||||
projectLinksValidationRules,
|
||||
projectPermissionsValidationRules,
|
||||
projectServerSettingsValidationRules,
|
||||
projectTagsValidationRules,
|
||||
projectVersionValidationRules,
|
||||
projectDisclosureTextValidationRules,
|
||||
projectDisclosureValidationRules,
|
||||
projectModerationValidationRules,
|
||||
]
|
||||
const warningKinds = new Set<Labrinth.Projects.v3.NormalizedProjectNagKind>([
|
||||
'missing-alt-text',
|
||||
'verify-external-links',
|
||||
'too-many-languages',
|
||||
'too-many-tags',
|
||||
'multiple-resolution-tags',
|
||||
'moderator-feedback',
|
||||
])
|
||||
|
||||
const previewNags = Object.keys(nagDefinitions).map((kind) => {
|
||||
const normalizedKind = kind as Labrinth.Projects.v3.NormalizedProjectNagKind
|
||||
const projectNagKind = kind.replaceAll('-', '_') as Labrinth.Projects.v3.ProjectNagKind
|
||||
const severity: Labrinth.Projects.v3.ProjectNagSeverity = suggestionKinds.has(normalizedKind)
|
||||
? 'suggestion'
|
||||
: warningKinds.has(normalizedKind)
|
||||
? 'warning'
|
||||
: 'required'
|
||||
return toProjectNag(
|
||||
{ kind: projectNagKind, severity, details: previewValues },
|
||||
previewValues.projectType,
|
||||
)
|
||||
})
|
||||
|
||||
const everyNag: Nag[] = [
|
||||
...validationRuleSets.flatMap(createPreviewNags),
|
||||
...previewNags,
|
||||
{
|
||||
id: 'resubmit-for-review-preview',
|
||||
title: 'Resubmit for review',
|
||||
|
||||
@@ -135,7 +135,7 @@ import {
|
||||
TriangleAlertIcon,
|
||||
} from '@modrinth/assets'
|
||||
import type { Nag, NagContext, NagStatus } from '@modrinth/moderation'
|
||||
import { getNags, nagDestinations, validateProject } from '@modrinth/moderation'
|
||||
import { nagDestinations, normalizeProjectNagKind, toProjectNag } from '@modrinth/moderation'
|
||||
import { Accordion, Button, IconButton } from '@modrinth/ui'
|
||||
import { defineMessages, type MessageDescriptor, useVIntl } from '@modrinth/ui'
|
||||
import type { Component } from 'vue'
|
||||
@@ -153,6 +153,10 @@ interface Props {
|
||||
projectV3: Labrinth.Projects.v3.Project
|
||||
versions?: Labrinth.Versions.v3.Version[]
|
||||
nags?: Nag[]
|
||||
validationNags?: Labrinth.Projects.v3.ProjectNag[]
|
||||
validationLoading?: boolean
|
||||
validationAvailable?: boolean
|
||||
refreshValidation?: () => Promise<Labrinth.Projects.v3.ProjectValidationResponse | null>
|
||||
currentMember?: Labrinth.Projects.v3.TeamMember | null
|
||||
collapsed?: boolean
|
||||
disableHorizontalScroll?: boolean
|
||||
@@ -217,6 +221,9 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
collapsed: false,
|
||||
disableHorizontalScroll: false,
|
||||
routeName: '',
|
||||
validationNags: () => [],
|
||||
validationLoading: false,
|
||||
validationAvailable: true,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -358,21 +365,33 @@ const nagContext = computed<NagContext>(() => ({
|
||||
}))
|
||||
|
||||
const canSubmitForReview = computed(() => {
|
||||
return validateProject(nagContext.value).valid
|
||||
return (
|
||||
!props.validationLoading &&
|
||||
props.validationAvailable &&
|
||||
!props.validationNags.some((nag) => nag.severity === 'required')
|
||||
)
|
||||
})
|
||||
|
||||
async function submitForReview() {
|
||||
if (canSubmitForReview.value) {
|
||||
emit('setProcessing', true)
|
||||
}
|
||||
if (!canSubmitForReview.value) return
|
||||
const validation = await props.refreshValidation?.()
|
||||
if (!validation || validation.nags.some((nag) => nag.severity === 'required')) return
|
||||
emit('setProcessing', true)
|
||||
}
|
||||
|
||||
const applicableNags = computed<Nag[]>(() => {
|
||||
if (props.nags) return props.nags
|
||||
|
||||
return getNags(nagContext.value).filter((nag) => {
|
||||
return nag.shouldShow(nagContext.value)
|
||||
})
|
||||
const nagsByKind = new Map<
|
||||
Labrinth.Projects.v3.NormalizedProjectNagKind,
|
||||
Labrinth.Projects.v3.ProjectNag
|
||||
>()
|
||||
for (const nag of props.validationNags) {
|
||||
const kind = normalizeProjectNagKind(nag.kind)
|
||||
if (kind && !nagsByKind.has(kind)) nagsByKind.set(kind, nag)
|
||||
}
|
||||
|
||||
return [...nagsByKind.values()].map((nag) => toProjectNag(nag, props.project.project_type))
|
||||
})
|
||||
|
||||
function isNagComplete(nag: Nag): boolean {
|
||||
@@ -458,7 +477,7 @@ function getNagDescription(nag: Nag): string {
|
||||
if (typeof nag.description === 'function') {
|
||||
return nag.description(nagContext.value)
|
||||
}
|
||||
return formatMessage(nag.description)
|
||||
return formatMessage(nag.description, nag.values)
|
||||
}
|
||||
|
||||
function getNagDescriptionSegments(nag: Nag): { text: string; isUrl: boolean }[] {
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
import {
|
||||
extractDescriptionLinks,
|
||||
type FieldValidationMessage,
|
||||
findBannedDescriptionLink,
|
||||
type LinkCheckContext,
|
||||
type LinkCheckResult,
|
||||
validateLink,
|
||||
validateProjectDescription,
|
||||
validateProjectNameField,
|
||||
validateProjectSummary,
|
||||
} 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.',
|
||||
},
|
||||
})
|
||||
|
||||
export function useProjectTitleValidation(text: MaybeRefOrGetter<string | null | undefined>) {
|
||||
return computed(() => validateProjectNameField(toValue(text) ?? ''))
|
||||
}
|
||||
|
||||
export function useProjectSummaryValidation(
|
||||
summary: MaybeRefOrGetter<string | null | undefined>,
|
||||
title: MaybeRefOrGetter<string | null | undefined>,
|
||||
) {
|
||||
return computed(() =>
|
||||
validateProjectSummary({
|
||||
summary: toValue(summary),
|
||||
name: toValue(title),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function useLinkValidation(context: MaybeRefOrGetter<LinkCheckContext>) {
|
||||
const result = ref<LinkCheckResult | null>(null)
|
||||
const pending = ref(false)
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let requestId = 0
|
||||
|
||||
watch(
|
||||
() => toValue(context),
|
||||
(value) => {
|
||||
clearTimeout(debounceTimer)
|
||||
const currentRequestId = ++requestId
|
||||
result.value = null
|
||||
|
||||
if (import.meta.server || !value.url) {
|
||||
pending.value = false
|
||||
return
|
||||
}
|
||||
|
||||
pending.value = true
|
||||
debounceTimer = setTimeout(async () => {
|
||||
try {
|
||||
const validation = await validateLink(value)
|
||||
if (currentRequestId === requestId) result.value = validation ?? null
|
||||
} catch {
|
||||
if (currentRequestId === requestId) result.value = null
|
||||
} finally {
|
||||
if (currentRequestId === requestId) pending.value = false
|
||||
}
|
||||
}, 500)
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
)
|
||||
|
||||
onScopeDispose(() => {
|
||||
clearTimeout(debounceTimer)
|
||||
requestId++
|
||||
})
|
||||
|
||||
return { pending, result }
|
||||
}
|
||||
|
||||
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
|
||||
if (findBannedDescriptionLink(text ?? '')) {
|
||||
pending.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const links = extractDescriptionLinks(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 {
|
||||
const checks = (
|
||||
await Promise.all(contexts.map((context) => validateLink(context)))
|
||||
).filter((check): check is LinkCheckResult => check !== undefined)
|
||||
if (currentRequestId !== requestId) return
|
||||
|
||||
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<Array<FieldValidationMessage | LinkCheckResult>>(() => [
|
||||
...validateProjectDescription(toValue(description)),
|
||||
...(linkValidation.value ? [linkValidation.value] : []),
|
||||
])
|
||||
|
||||
return {
|
||||
pending,
|
||||
validation,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { normalizeProjectNagKind, toProjectFieldMessage } from '@modrinth/moderation'
|
||||
import { injectProjectPageContext } from '@modrinth/ui'
|
||||
import { computed } from 'vue'
|
||||
|
||||
export type ProjectSettingsField =
|
||||
| 'name'
|
||||
| 'summary'
|
||||
| 'icon'
|
||||
| 'description'
|
||||
| 'gallery-text'
|
||||
| 'gallery-images'
|
||||
| 'license'
|
||||
| 'custom-license'
|
||||
| 'license-url'
|
||||
| 'external-links'
|
||||
| 'source-issues-discord-links'
|
||||
| 'non-discord-link-fields'
|
||||
| 'source-availability'
|
||||
| 'permissions'
|
||||
| 'server-region'
|
||||
| 'server-languages'
|
||||
| 'java-address'
|
||||
| 'server-compatibility'
|
||||
| 'tags'
|
||||
| 'versions'
|
||||
| 'version-environment'
|
||||
| 'disclosure-text'
|
||||
| 'disclosures'
|
||||
| 'moderation'
|
||||
|
||||
export const projectNagFields = {
|
||||
name: [
|
||||
'project-name-slur',
|
||||
'project-name-profanity',
|
||||
'project-name-non-standard-text',
|
||||
'project-name-version',
|
||||
'minecraft-title-clause',
|
||||
],
|
||||
summary: [
|
||||
'project-summary-slur',
|
||||
'project-summary-profanity',
|
||||
'project-summary-non-standard-text',
|
||||
'project-summary-non-english',
|
||||
'project-summary-matches-title',
|
||||
'summary-too-short',
|
||||
'project-summary-spam',
|
||||
'summary-special-formatting',
|
||||
'project-summary-links',
|
||||
],
|
||||
icon: ['add-icon'],
|
||||
description: [
|
||||
'project-description-slur',
|
||||
'project-description-profanity',
|
||||
'project-description-non-standard-text',
|
||||
'project-description-non-english',
|
||||
'add-description',
|
||||
'description-too-short',
|
||||
'project-description-spam',
|
||||
'project-description-banned-link',
|
||||
'long-headers',
|
||||
'description-ends-with-header',
|
||||
'adjacent-headers',
|
||||
'missing-alt-text',
|
||||
],
|
||||
'gallery-text': ['gallery-text-slur', 'gallery-text-profanity', 'gallery-text-non-standard'],
|
||||
'gallery-images': ['upload-gallery-image', 'feature-gallery-image'],
|
||||
license: ['select-license'],
|
||||
'custom-license': ['add-custom-license-details'],
|
||||
'license-url': ['invalid-license-url'],
|
||||
'external-links': ['add-links', 'add-links-server', 'identical-links', 'banned-link-usage'],
|
||||
'source-issues-discord-links': ['verify-external-links'],
|
||||
'non-discord-link-fields': ['misused-discord-link'],
|
||||
'source-availability': ['gpl-license-source-required'],
|
||||
permissions: ['review-permissions'],
|
||||
'server-region': ['select-country'],
|
||||
'server-languages': ['all-languages', 'too-many-languages', 'select-language'],
|
||||
'java-address': ['add-java-address'],
|
||||
'server-compatibility': ['select-compatibility'],
|
||||
tags: [
|
||||
'select-tags',
|
||||
'too-many-tags',
|
||||
'too-many-tags-server',
|
||||
'multiple-resolution-tags',
|
||||
'all-tags-selected',
|
||||
],
|
||||
versions: ['upload-version'],
|
||||
'version-environment': ['select-environment'],
|
||||
'disclosure-text': ['disclosures-special-formatting'],
|
||||
disclosures: ['check-disclosures'],
|
||||
moderation: ['moderator-feedback'],
|
||||
} as const satisfies Record<
|
||||
ProjectSettingsField,
|
||||
readonly Labrinth.Projects.v3.NormalizedProjectNagKind[]
|
||||
>
|
||||
|
||||
function appliesToDetails(
|
||||
nag: Labrinth.Projects.v3.ProjectNag,
|
||||
detailField?: string,
|
||||
detailIndex?: number,
|
||||
) {
|
||||
if (!detailField) return true
|
||||
const field = nag.details?.field
|
||||
const fields = nag.details?.fields
|
||||
if (typeof field === 'string' && field !== detailField) return false
|
||||
if (Array.isArray(fields) && !fields.includes(detailField)) return false
|
||||
if (typeof nag.details?.gallery_index === 'number' && nag.details.gallery_index !== detailIndex) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function useProjectNagMessages(
|
||||
field: ProjectSettingsField,
|
||||
detailField?: string,
|
||||
detailIndex?: () => number,
|
||||
) {
|
||||
const { projectValidation, projectV2 } = injectProjectPageContext()
|
||||
const kinds = new Set<string>(projectNagFields[field])
|
||||
|
||||
return computed(() =>
|
||||
(projectValidation.value?.nags ?? [])
|
||||
.filter((nag) => {
|
||||
if (nag.severity === 'suggestion') return false
|
||||
const kind = normalizeProjectNagKind(nag.kind)
|
||||
return (
|
||||
kind !== null && kinds.has(kind) && appliesToDetails(nag, detailField, detailIndex?.())
|
||||
)
|
||||
})
|
||||
.map((nag) => toProjectFieldMessage(nag, projectV2.value.project_type)),
|
||||
)
|
||||
}
|
||||
@@ -155,6 +155,10 @@
|
||||
:collapsed="collapsedChecklist"
|
||||
:route-name="route.name"
|
||||
:tags="tags"
|
||||
:validation-nags="projectValidation?.nags ?? []"
|
||||
:validation-loading="projectValidationLoading"
|
||||
:validation-available="projectValidation !== null"
|
||||
:refresh-validation="refreshProjectValidation"
|
||||
@toggle-collapsed="() => (collapsedChecklist = !collapsedChecklist)"
|
||||
@set-processing="setProcessing"
|
||||
/>
|
||||
@@ -1736,6 +1740,24 @@ const currentMember = computed(() => {
|
||||
return val
|
||||
})
|
||||
|
||||
const {
|
||||
data: projectValidationResponse,
|
||||
isFetching: projectValidationLoading,
|
||||
refetch: refetchProjectValidation,
|
||||
} = useQuery({
|
||||
queryKey: computed(() => ['project', projectId.value, 'validation', 'v3']),
|
||||
queryFn: () => client.labrinth.projects_v3.validate(projectId.value),
|
||||
staleTime: 0,
|
||||
enabled: computed(() => !!projectId.value && !!currentMember.value?.accepted),
|
||||
})
|
||||
|
||||
const projectValidation = computed(() => projectValidationResponse.value ?? null)
|
||||
|
||||
async function refreshProjectValidation() {
|
||||
const result = await refetchProjectValidation()
|
||||
return result.data ?? null
|
||||
}
|
||||
|
||||
const canAccessSettings = computed(() => !!currentMember.value?.accepted)
|
||||
|
||||
const hasEditDetailsPermission = computed(() => {
|
||||
@@ -2439,6 +2461,8 @@ provideProjectPageContext({
|
||||
currentMember,
|
||||
allMembers,
|
||||
organization,
|
||||
projectValidation,
|
||||
projectValidationLoading,
|
||||
// Lazy version loading
|
||||
versions,
|
||||
versionsLoading,
|
||||
@@ -2452,6 +2476,7 @@ provideProjectPageContext({
|
||||
|
||||
// Invalidate all project queries (auto-refetches active ones)
|
||||
invalidate: invalidateProject,
|
||||
refreshProjectValidation,
|
||||
|
||||
// Lazy loading
|
||||
loadVersions,
|
||||
|
||||
@@ -239,7 +239,6 @@ import {
|
||||
UploadIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { validateProjectGalleryDescription, validateProjectGalleryName } from '@modrinth/moderation'
|
||||
import {
|
||||
Button,
|
||||
ConfirmModal,
|
||||
@@ -253,10 +252,10 @@ import {
|
||||
useFormatDateTime,
|
||||
useFullImageContextMenu,
|
||||
} from '@modrinth/ui'
|
||||
import { isAdmin } from '@modrinth/utils'
|
||||
|
||||
import AiImageWarningModal from '~/components/ui/AiImageWarningModal.vue'
|
||||
import ValidationMessage from '~/components/ValidationMessage.vue'
|
||||
import { useProjectNagMessages } from '~/composables/project-nag-validation'
|
||||
import { fileDeclaresAi } from '~/helpers/c2pa'
|
||||
import { isPermission } from '~/utils/permissions.ts'
|
||||
|
||||
@@ -309,18 +308,6 @@ const previewImage = ref<string | null>(null)
|
||||
|
||||
// UI state
|
||||
const shouldPreventActions = ref(false)
|
||||
const galleryTitleValidation = computed(() => validateProjectGalleryName(editTitle.value))
|
||||
const galleryDescriptionValidation = computed(() =>
|
||||
validateProjectGalleryDescription(editDescription.value),
|
||||
)
|
||||
const galleryFieldsInvalid = computed(
|
||||
() =>
|
||||
galleryTitleValidation.value.some((validation) => validation.severity === 'error') ||
|
||||
galleryDescriptionValidation.value.some((validation) => validation.severity === 'error'),
|
||||
)
|
||||
const isAdminUser = computed(() => isAdmin(currentMember.value?.user))
|
||||
const canSaveGalleryFields = computed(() => isAdminUser.value || !galleryFieldsInvalid.value)
|
||||
|
||||
// Constant for accepted file types
|
||||
const MC_SERVER_BANNER_NAME = '__mc_server_banner__'
|
||||
const acceptFileTypes = 'image/png,image/jpeg,image/gif,image/webp,.png,.jpeg,.gif,.webp'
|
||||
@@ -328,6 +315,21 @@ const acceptFileTypes = 'image/png,image/jpeg,image/gif,image/webp,.png,.jpeg,.g
|
||||
const filteredGallery = computed(
|
||||
() => project.value.gallery?.filter((img) => img.title !== MC_SERVER_BANNER_NAME) ?? [],
|
||||
)
|
||||
const selectedGalleryIndex = computed(() => {
|
||||
const selectedItem = filteredGallery.value[editIndex.value]
|
||||
return selectedItem ? (project.value.gallery ?? []).indexOf(selectedItem) : -1
|
||||
})
|
||||
const galleryTitleValidation = useProjectNagMessages(
|
||||
'gallery-text',
|
||||
'name',
|
||||
() => selectedGalleryIndex.value,
|
||||
)
|
||||
const galleryDescriptionValidation = useProjectNagMessages(
|
||||
'gallery-text',
|
||||
'description',
|
||||
() => selectedGalleryIndex.value,
|
||||
)
|
||||
const canSaveGalleryFields = computed(() => true)
|
||||
|
||||
const galleryViewerItems = computed(() =>
|
||||
filteredGallery.value.map((image) => ({
|
||||
|
||||
@@ -36,6 +36,9 @@ const {
|
||||
versions,
|
||||
currentMember,
|
||||
setProcessing,
|
||||
projectValidation,
|
||||
projectValidationLoading,
|
||||
refreshProjectValidation,
|
||||
} = injectProjectPageContext()
|
||||
|
||||
const flags = useFeatureFlags()
|
||||
@@ -176,6 +179,10 @@ const moderatorSeeUserUi = computed<boolean>({
|
||||
:collapsed="collapsedChecklist"
|
||||
:route-name="route.name as string"
|
||||
:tags="tags"
|
||||
:validation-nags="projectValidation?.nags ?? []"
|
||||
:validation-loading="projectValidationLoading"
|
||||
:validation-available="projectValidation !== null"
|
||||
:refresh-validation="refreshProjectValidation"
|
||||
@toggle-collapsed="() => (collapsedChecklist = !collapsedChecklist)"
|
||||
@set-processing="setProcessing"
|
||||
/>
|
||||
|
||||
@@ -27,11 +27,6 @@
|
||||
:modified="current"
|
||||
:saving="saving"
|
||||
:can-save="canSave"
|
||||
:save-disabled-reason="
|
||||
hasPermission && hasValidationIssues
|
||||
? projectTextValidationMessages.resolveIssuesToSave
|
||||
: undefined
|
||||
"
|
||||
@reset="reset"
|
||||
@save="save"
|
||||
/>
|
||||
@@ -54,10 +49,7 @@ 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 {
|
||||
projectTextValidationMessages,
|
||||
useProjectDescriptionValidation,
|
||||
} from '~/composables/project-field-validation'
|
||||
import { useProjectNagMessages } from '~/composables/project-nag-validation'
|
||||
import { fileDeclaresAi } from '~/helpers/c2pa'
|
||||
|
||||
const { projectV2: project, currentMember, patchProject } = injectProjectPageContext()
|
||||
@@ -89,16 +81,8 @@ const hasPermission = computed(
|
||||
(currentMember.value.permissions & TeamMemberPermission.EDIT_BODY) ===
|
||||
TeamMemberPermission.EDIT_BODY),
|
||||
)
|
||||
const { pending: descriptionLinksPending, validation: descriptionValidation } =
|
||||
useProjectDescriptionValidation(() => current.value.description)
|
||||
const hasValidationIssues = computed(() =>
|
||||
descriptionValidation.value.some((validation) => validation.severity === 'error'),
|
||||
)
|
||||
const canSave = computed(
|
||||
() =>
|
||||
hasPermission.value &&
|
||||
(isAdminUser.value || (!hasValidationIssues.value && !descriptionLinksPending.value)),
|
||||
)
|
||||
const descriptionValidation = useProjectNagMessages('description')
|
||||
const canSave = computed(() => hasPermission.value)
|
||||
|
||||
async function save() {
|
||||
if (!canSave.value) return
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { validateProjectDisclosures } from '@modrinth/moderation'
|
||||
import {
|
||||
commonMessages,
|
||||
ConfirmLeaveModal,
|
||||
@@ -31,7 +30,6 @@ import {
|
||||
type DisclosureType,
|
||||
type DisclosureUpdatedByUser,
|
||||
findDisclosureData,
|
||||
formToDisclosures,
|
||||
getDisclosureFormIssues,
|
||||
getDisclosureFormSnapshot,
|
||||
PaidFeaturesDisclosureCard,
|
||||
@@ -40,13 +38,20 @@ import {
|
||||
TelemetryDisclosureCard,
|
||||
toModifyRequests,
|
||||
} from '~/components/ui/project-settings/disclosures'
|
||||
import ValidationMessage from '~/components/ValidationMessage.vue'
|
||||
import { useAuth } from '~/composables/auth'
|
||||
import { useProjectNagMessages } from '~/composables/project-nag-validation'
|
||||
|
||||
const DISCLOSURE_QUERY_STALE_TIME = 1000 * 60 * 5
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { labrinth } = injectModrinthClient()
|
||||
const { projectV2: project, projectV3, currentMember } = injectProjectPageContext()
|
||||
const {
|
||||
projectV2: project,
|
||||
projectV3,
|
||||
currentMember,
|
||||
refreshProjectValidation,
|
||||
} = injectProjectPageContext()
|
||||
const queryClient = useQueryClient()
|
||||
const flags = useFeatureFlags()
|
||||
const auth = await useAuth()
|
||||
@@ -214,6 +219,7 @@ const hasChanges = computed(
|
||||
async function save() {
|
||||
if (!hasChanges.value) return
|
||||
await saveForm()
|
||||
await refreshProjectValidation()
|
||||
}
|
||||
|
||||
function disclosureUpdateProps(type: DisclosureType) {
|
||||
@@ -253,15 +259,11 @@ watch(
|
||||
)
|
||||
|
||||
const issues = computed(() => getDisclosureFormIssues(current.value, projectTypes.value))
|
||||
const disclosureTextValidation = computed(() =>
|
||||
validateProjectDisclosures(formToDisclosures(current.value)),
|
||||
)
|
||||
const disclosureTextValidation = useProjectNagMessages('disclosure-text')
|
||||
const disclosureValidation = useProjectNagMessages('disclosures')
|
||||
|
||||
const canSave = computed(
|
||||
() =>
|
||||
hasPermission.value &&
|
||||
disclosureTextValidation.value.length === 0 &&
|
||||
(isAdminUser.value || issues.value.length === 0),
|
||||
() => hasPermission.value && (isAdminUser.value || issues.value.length === 0),
|
||||
)
|
||||
|
||||
const saveDisabledReason = computed(() => {
|
||||
@@ -269,10 +271,7 @@ const saveDisabledReason = computed(() => {
|
||||
// should never come up but y'never know
|
||||
return formatMessage(messages.noPermission)
|
||||
}
|
||||
return [
|
||||
...issues.value.map((issue) => formatMessage(issueMessages[issue])),
|
||||
...disclosureTextValidation.value.map(({ message, values }) => formatMessage(message, values)),
|
||||
]
|
||||
return [...issues.value.map((issue) => formatMessage(issueMessages[issue]))]
|
||||
})
|
||||
|
||||
const { confirmLeaveModal } = usePageLeaveSafety(hasChanges)
|
||||
@@ -293,6 +292,10 @@ const { confirmLeaveModal } = usePageLeaveSafety(hasChanges)
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</p>
|
||||
<ValidationMessage
|
||||
:check="[...disclosureValidation, ...disclosureTextValidation]"
|
||||
class="mb-4"
|
||||
/>
|
||||
<EmptyState
|
||||
v-if="!canEditDisclosures"
|
||||
type="no-documents"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<ValidationMessage :check="galleryImagesValidation" class="mb-4" />
|
||||
<AiImageWarningModal ref="aiImageWarningModal" />
|
||||
<Modal
|
||||
v-if="currentMember"
|
||||
@@ -44,7 +45,7 @@
|
||||
:maxlength="64"
|
||||
placeholder="Enter title..."
|
||||
/>
|
||||
<ValidationMessage :check="galleryTitleValidation" />
|
||||
<ValidationMessage class="mt-2" :check="galleryTitleValidation" />
|
||||
<label for="gallery-image-desc">
|
||||
<span class="label__title">Description</span>
|
||||
</label>
|
||||
@@ -54,7 +55,7 @@
|
||||
:maxlength="255"
|
||||
placeholder="Enter description..."
|
||||
/>
|
||||
<ValidationMessage :check="galleryDescriptionValidation" />
|
||||
<ValidationMessage class="mt-2" :check="galleryDescriptionValidation" />
|
||||
<label for="gallery-image-ordering">
|
||||
<span class="label__title">Order Index</span>
|
||||
</label>
|
||||
@@ -289,7 +290,6 @@ import {
|
||||
UploadIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { validateProjectGalleryDescription, validateProjectGalleryName } from '@modrinth/moderation'
|
||||
import {
|
||||
Button,
|
||||
ButtonLink,
|
||||
@@ -305,10 +305,10 @@ import {
|
||||
useFormatDateTime,
|
||||
useFullImageContextMenu,
|
||||
} from '@modrinth/ui'
|
||||
import { isAdmin } from '@modrinth/utils'
|
||||
|
||||
import AiImageWarningModal from '~/components/ui/AiImageWarningModal.vue'
|
||||
import ValidationMessage from '~/components/ValidationMessage.vue'
|
||||
import { useProjectNagMessages } from '~/composables/project-nag-validation'
|
||||
import { fileDeclaresAi } from '~/helpers/c2pa'
|
||||
import { isPermission } from '~/utils/permissions.ts'
|
||||
|
||||
@@ -347,24 +347,28 @@ const editOrder = ref(null)
|
||||
const editFile = ref(null)
|
||||
const previewImage = ref(null)
|
||||
const shouldPreventActions = ref(false)
|
||||
const galleryTitleValidation = computed(() => validateProjectGalleryName(editTitle.value))
|
||||
const galleryDescriptionValidation = computed(() =>
|
||||
validateProjectGalleryDescription(editDescription.value),
|
||||
)
|
||||
const galleryFieldsInvalid = computed(
|
||||
() =>
|
||||
galleryTitleValidation.value.some((validation) => validation.severity === 'error') ||
|
||||
galleryDescriptionValidation.value.some((validation) => validation.severity === 'error'),
|
||||
)
|
||||
const isAdminUser = computed(() => isAdmin(currentMember.value?.user))
|
||||
const canSaveGalleryFields = computed(() => isAdminUser.value || !galleryFieldsInvalid.value)
|
||||
|
||||
const MC_SERVER_BANNER_NAME = '__mc_server_banner__'
|
||||
const acceptFileTypes = 'image/png,image/jpeg,image/gif,image/webp,.png,.jpeg,.gif,.webp'
|
||||
|
||||
const filteredGallery = computed(
|
||||
() => project.value.gallery?.filter((img) => img.title !== MC_SERVER_BANNER_NAME) ?? [],
|
||||
)
|
||||
const selectedGalleryIndex = computed(() => {
|
||||
const selectedItem = filteredGallery.value[editIndex.value]
|
||||
return selectedItem ? (project.value.gallery ?? []).indexOf(selectedItem) : -1
|
||||
})
|
||||
const galleryTitleValidation = useProjectNagMessages(
|
||||
'gallery-text',
|
||||
'name',
|
||||
() => selectedGalleryIndex.value,
|
||||
)
|
||||
const galleryDescriptionValidation = useProjectNagMessages(
|
||||
'gallery-text',
|
||||
'description',
|
||||
() => selectedGalleryIndex.value,
|
||||
)
|
||||
const galleryImagesValidation = useProjectNagMessages('gallery-images')
|
||||
const canSaveGalleryFields = computed(() => true)
|
||||
|
||||
const nextImage = () => {
|
||||
expandedGalleryIndex.value++
|
||||
|
||||
@@ -13,14 +13,10 @@ import {
|
||||
useSavable,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { isAdmin } from '@modrinth/utils'
|
||||
|
||||
import SlugSuggestions from '~/components/ui/SlugSuggestions.vue'
|
||||
import ValidationMessage from '~/components/ValidationMessage.vue'
|
||||
import {
|
||||
useProjectSummaryValidation,
|
||||
useProjectTitleValidation,
|
||||
} from '~/composables/project-field-validation'
|
||||
import { useProjectNagMessages } from '~/composables/project-nag-validation'
|
||||
import {
|
||||
useProjectSlugSuggestions,
|
||||
useSlugSuggestionVisibility,
|
||||
@@ -28,7 +24,7 @@ import {
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const { allMembers, currentMember, projectV2: project, patchProject } = injectProjectPageContext()
|
||||
const { allMembers, projectV2: project, patchProject } = injectProjectPageContext()
|
||||
|
||||
useProjectSettingsHeadTitle(commonProjectSettingsMessages.general)
|
||||
|
||||
@@ -57,15 +53,10 @@ const {
|
||||
|
||||
const { confirmLeaveModal } = usePageLeaveSafety(hasChanges)
|
||||
|
||||
const titleValidation = useProjectTitleValidation(() => current.value.title)
|
||||
const taglineValidation = useProjectSummaryValidation(
|
||||
() => current.value.tagline,
|
||||
() => current.value.title,
|
||||
)
|
||||
const isAdminUser = computed(() => isAdmin(currentMember.value?.user))
|
||||
const canSave = computed(
|
||||
() => isAdminUser.value || (!titleValidation.value && !taglineValidation.value),
|
||||
)
|
||||
const titleValidation = useProjectNagMessages('name')
|
||||
const taglineValidation = useProjectNagMessages('summary')
|
||||
const iconValidation = useProjectNagMessages('icon')
|
||||
const canSave = computed(() => true)
|
||||
const {
|
||||
onFocusIn: onSlugSuggestionFocusIn,
|
||||
onFocusOut: onSlugSuggestionFocusOut,
|
||||
@@ -187,6 +178,7 @@ const placeholder = computed(() => placeholders[placeholderIndex.value] ?? place
|
||||
<div class="base-card block">
|
||||
<div class="group relative float-end ml-4">
|
||||
<IconSelect v-model="current.icon" />
|
||||
<ValidationMessage :check="iconValidation" class="mt-2" />
|
||||
</div>
|
||||
<div>
|
||||
<SettingsLabel
|
||||
|
||||
@@ -109,6 +109,7 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ValidationMessage :check="iconValidation" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<!-- Server Project Settings -->
|
||||
@@ -295,11 +296,6 @@
|
||||
:modified="modified"
|
||||
:saving="saving"
|
||||
:can-save="canSave"
|
||||
:save-disabled-reason="
|
||||
hasPermission && hasBlockingValidationIssues
|
||||
? projectTextValidationMessages.resolveIssuesToSave
|
||||
: undefined
|
||||
"
|
||||
@reset="resetChanges"
|
||||
@save="handleSave"
|
||||
/>
|
||||
@@ -339,11 +335,7 @@ 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 {
|
||||
projectTextValidationMessages,
|
||||
useProjectSummaryValidation,
|
||||
useProjectTitleValidation,
|
||||
} from '~/composables/project-field-validation'
|
||||
import { useProjectNagMessages } from '~/composables/project-nag-validation'
|
||||
import {
|
||||
useProjectSlugSuggestions,
|
||||
useSlugSuggestionVisibility,
|
||||
@@ -436,15 +428,10 @@ const hasPermission = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
const nameValidation = useProjectTitleValidation(name)
|
||||
const summaryValidation = useProjectSummaryValidation(summary, name)
|
||||
const hasValidationIssues = computed(
|
||||
() =>
|
||||
nameValidation.value.some((validation) => validation.severity === 'error') ||
|
||||
summaryValidation.value.some((validation) => validation.severity === 'error'),
|
||||
)
|
||||
const hasBlockingValidationIssues = computed(() => hasValidationIssues.value && !isStaff.value)
|
||||
const canSave = computed(() => hasPermission.value && !hasBlockingValidationIssues.value)
|
||||
const nameValidation = useProjectNagMessages('name')
|
||||
const summaryValidation = useProjectNagMessages('summary')
|
||||
const iconValidation = useProjectNagMessages('icon')
|
||||
const canSave = computed(() => hasPermission.value)
|
||||
|
||||
const monetizationToggleDisabled = computed(() => !hasPermission.value || isForceDemonetized.value)
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
:disabled="!hasPermission"
|
||||
trigger-type="base"
|
||||
/>
|
||||
<ValidationMessage :check="licenseSelectionValidation" class="mt-2" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -84,6 +85,7 @@
|
||||
:disabled="!hasPermission || licenseId === 'LicenseRef-Unknown'"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
<ValidationMessage :check="customLicenseValidation" />
|
||||
<ValidationMessage :check="effectiveLicenseCheck" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -165,6 +167,7 @@ import { builtinLicenses, formatProjectType, isAdmin, TeamMemberPermission } fro
|
||||
import { computed } from 'vue'
|
||||
|
||||
import ValidationMessage from '@/components/ValidationMessage.vue'
|
||||
import { useProjectNagMessages } from '~/composables/project-nag-validation'
|
||||
|
||||
const { projectV2: project, currentMember, patchProject } = injectProjectPageContext()
|
||||
|
||||
@@ -231,14 +234,9 @@ const {
|
||||
},
|
||||
)
|
||||
|
||||
const licenseContext = computed(() => ({
|
||||
field: 'license',
|
||||
url: current.value.licenseUrl,
|
||||
expectedLicense: current.value.license.short,
|
||||
isCustom: current.value.license.friendly === 'Custom',
|
||||
}))
|
||||
const licenseValidation = useLinkValidation(licenseContext)
|
||||
const effectiveLicenseCheck = licenseValidation.result
|
||||
const licenseSelectionValidation = useProjectNagMessages('license')
|
||||
const customLicenseValidation = useProjectNagMessages('custom-license')
|
||||
const effectiveLicenseCheck = useProjectNagMessages('license-url')
|
||||
|
||||
const { confirmLeaveModal } = usePageLeaveSafety(hasChanges)
|
||||
|
||||
@@ -257,17 +255,7 @@ const hasPermission = computed(
|
||||
Boolean((currentMember.value?.permissions ?? 0) & TeamMemberPermission.EDIT_DETAILS),
|
||||
)
|
||||
|
||||
const canSave = computed(
|
||||
() =>
|
||||
hasPermission.value &&
|
||||
(isAdminUser.value ||
|
||||
(!(
|
||||
current.value.license.friendly === 'Custom' &&
|
||||
(current.value.license.short === '' || current.value.licenseUrl === '')
|
||||
) &&
|
||||
effectiveLicenseCheck.value?.severity !== 'error' &&
|
||||
!licenseValidation.pending.value)),
|
||||
)
|
||||
const canSave = computed(() => hasPermission.value)
|
||||
|
||||
async function save() {
|
||||
if (!canSave.value) return
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<!-- Server Project Links -->
|
||||
<section v-if="isServerProject" class="universal-card">
|
||||
<h2>External links</h2>
|
||||
<ValidationMessage :check="externalLinksValidation" class="mb-4" />
|
||||
<div class="adjacent-input">
|
||||
<label id="server-website" title="Your server's website.">
|
||||
<span class="label__title">Website</span>
|
||||
@@ -74,6 +75,7 @@
|
||||
<!-- Standard Project Links -->
|
||||
<section v-if="!isServerProject" class="universal-card">
|
||||
<h2>External links</h2>
|
||||
<ValidationMessage :check="externalLinksValidation" class="mb-4" />
|
||||
<div class="adjacent-input">
|
||||
<label
|
||||
id="project-issue-tracker"
|
||||
@@ -195,7 +197,6 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { type LinkCheckContext, type LinkCheckResult, validateLink } from '@modrinth/moderation'
|
||||
import {
|
||||
Combobox,
|
||||
commonProjectSettingsMessages,
|
||||
@@ -212,6 +213,7 @@ import {
|
||||
import { isAdmin } from '@modrinth/utils'
|
||||
|
||||
import ValidationMessage from '@/components/ValidationMessage.vue'
|
||||
import { useProjectNagMessages } from '~/composables/project-nag-validation'
|
||||
|
||||
type EditableLinkField = 'discord' | 'issues' | 'site' | 'source' | 'store' | 'wiki'
|
||||
type EditableLinks = Partial<Record<EditableLinkField, string>>
|
||||
@@ -283,47 +285,27 @@ function reset() {
|
||||
resetDonations()
|
||||
}
|
||||
|
||||
function fieldContext(
|
||||
field: string,
|
||||
getUrl: () => string | undefined,
|
||||
extra: Record<string, unknown> = {},
|
||||
) {
|
||||
return computed(() => ({ field, url: getUrl(), ...extra }))
|
||||
const externalLinksValidation = useProjectNagMessages('external-links')
|
||||
|
||||
function useLinkFieldMessages(field: EditableLinkField, includeSourceRequirement = false) {
|
||||
const verification = useProjectNagMessages('source-issues-discord-links', field)
|
||||
const discordMisuse = useProjectNagMessages('non-discord-link-fields', field)
|
||||
const sourceRequirement = useProjectNagMessages('source-availability', field)
|
||||
return computed(() => [
|
||||
...verification.value,
|
||||
...(field === 'discord' ? [] : discordMisuse.value),
|
||||
...(includeSourceRequirement ? sourceRequirement.value : []),
|
||||
])
|
||||
}
|
||||
|
||||
const discordContext = fieldContext('discord', () => current.value.discord, {
|
||||
platformName: 'Discord',
|
||||
})
|
||||
const issuesContext = fieldContext('issues', () => current.value.issues)
|
||||
const sourceContext = fieldContext('source', () => current.value.source)
|
||||
const wikiContext = fieldContext('wiki', () => current.value.wiki)
|
||||
const siteContext = fieldContext('site', () => current.value.site)
|
||||
const storeContext = fieldContext('store', () => current.value.store)
|
||||
const discordInviteCheck = useLinkFieldMessages('discord')
|
||||
const issuesCheck = useLinkFieldMessages('issues')
|
||||
const sourceCheck = useLinkFieldMessages('source', true)
|
||||
const wikiCheck = useLinkFieldMessages('wiki')
|
||||
const siteCheck = useLinkFieldMessages('site')
|
||||
const storeCheck = useLinkFieldMessages('store')
|
||||
|
||||
const discordInviteValidation = useLinkValidation(discordContext)
|
||||
const issuesValidation = useLinkValidation(issuesContext)
|
||||
const sourceValidation = useLinkValidation(sourceContext)
|
||||
const wikiValidation = useLinkValidation(wikiContext)
|
||||
const siteValidation = useLinkValidation(siteContext)
|
||||
const storeValidation = useLinkValidation(storeContext)
|
||||
|
||||
const discordInviteCheck = discordInviteValidation.result
|
||||
const issuesCheck = issuesValidation.result
|
||||
const sourceCheck = sourceValidation.result
|
||||
const wikiCheck = wikiValidation.result
|
||||
const siteCheck = siteValidation.result
|
||||
const storeCheck = storeValidation.result
|
||||
|
||||
function donationContext(row: DonationRow): LinkCheckContext {
|
||||
return {
|
||||
field: row.id ?? '',
|
||||
url: row.url,
|
||||
isDonation: true,
|
||||
platformName: tags.value.donationPlatforms.find((platform) => platform.short === row.id)?.name,
|
||||
}
|
||||
}
|
||||
|
||||
function donationCheckState(row: DonationRow, index: number): LinkCheckResult | undefined {
|
||||
function donationCheckState(row: DonationRow, index: number) {
|
||||
if (row.url && !row.id) {
|
||||
return {
|
||||
severity: 'error',
|
||||
@@ -352,51 +334,9 @@ function donationCheckState(row: DonationRow, index: number): LinkCheckResult |
|
||||
}
|
||||
}
|
||||
|
||||
return donationCheckResults.get(index)
|
||||
return undefined
|
||||
}
|
||||
|
||||
const donationCheckTimers = reactive(new Map<number, ReturnType<typeof setTimeout>>())
|
||||
const donationCheckResults = reactive(new Map<number, LinkCheckResult>())
|
||||
const donationChecksPending = reactive(new Set<number>())
|
||||
let donationValidationId = 0
|
||||
|
||||
onScopeDispose(() => {
|
||||
for (const timeout of donationCheckTimers.values()) clearTimeout(timeout)
|
||||
donationValidationId++
|
||||
})
|
||||
|
||||
watch(
|
||||
donationLinks,
|
||||
(rows) => {
|
||||
for (const timeout of donationCheckTimers.values()) clearTimeout(timeout)
|
||||
donationCheckTimers.clear()
|
||||
donationCheckResults.clear()
|
||||
donationChecksPending.clear()
|
||||
const currentValidationId = ++donationValidationId
|
||||
|
||||
rows.forEach((row, index) => {
|
||||
if (!row.id || !row.url) return
|
||||
donationChecksPending.add(index)
|
||||
donationCheckTimers.set(
|
||||
index,
|
||||
setTimeout(async () => {
|
||||
donationCheckTimers.delete(index)
|
||||
try {
|
||||
const result = await validateLink(donationContext(row))
|
||||
if (currentValidationId !== donationValidationId) return
|
||||
if (result) donationCheckResults.set(index, result)
|
||||
} finally {
|
||||
if (currentValidationId === donationValidationId) {
|
||||
donationChecksPending.delete(index)
|
||||
}
|
||||
}
|
||||
}, 500),
|
||||
)
|
||||
})
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
)
|
||||
|
||||
const isAdminUser = computed(() => isAdmin(currentMember.value?.user))
|
||||
|
||||
const hasPermission = computed(() => {
|
||||
@@ -485,25 +425,12 @@ const patchData = computed<Record<string, string | null>>(() => {
|
||||
|
||||
const canSave = computed(() => {
|
||||
if (!hasPermission.value || Object.keys(patchData.value).length === 0) return false
|
||||
if (isAdminUser.value) return true
|
||||
|
||||
const checks = isServerProject.value
|
||||
? [siteCheck, storeCheck, wikiCheck, discordInviteCheck]
|
||||
: [issuesCheck, sourceCheck, wikiCheck, discordInviteCheck]
|
||||
const validations = isServerProject.value
|
||||
? [siteValidation, storeValidation, wikiValidation, discordInviteValidation]
|
||||
: [issuesValidation, sourceValidation, wikiValidation, discordInviteValidation]
|
||||
|
||||
const fieldsInvalid = checks.some((check) => check.value?.severity === 'error')
|
||||
const fieldsPending = validations.some((validation) => validation.pending.value)
|
||||
|
||||
const donationsInvalid =
|
||||
!isServerProject.value &&
|
||||
donationLinks.value.some((row, index) => donationCheckState(row, index)?.severity === 'error')
|
||||
const donationsPending =
|
||||
!isServerProject.value && (donationCheckTimers.size > 0 || donationChecksPending.size > 0)
|
||||
|
||||
return !fieldsInvalid && !fieldsPending && !donationsInvalid && !donationsPending
|
||||
return !donationsInvalid
|
||||
})
|
||||
|
||||
const saving = ref(false)
|
||||
|
||||
@@ -37,6 +37,8 @@ import { isStaff } from '@modrinth/utils'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, ref, useTemplateRef, watch } from 'vue'
|
||||
|
||||
import ValidationMessage from '~/components/ValidationMessage.vue'
|
||||
import { useProjectNagMessages } from '~/composables/project-nag-validation'
|
||||
import { setupAttributionModerationProvider } from '~/providers/setup/attribution-moderation'
|
||||
|
||||
setupAttributionModerationProvider()
|
||||
@@ -49,7 +51,8 @@ const isModerator = computed(() => {
|
||||
})
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { projectV2: project } = injectProjectPageContext()
|
||||
const { projectV2: project, refreshProjectValidation } = injectProjectPageContext()
|
||||
const permissionsValidation = useProjectNagMessages('permissions')
|
||||
const { labrinth } = injectModrinthClient()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const queryClient = useQueryClient()
|
||||
@@ -406,6 +409,7 @@ const deleteAllGroupsMutation = useMutation({
|
||||
mutationFn: () => labrinth.attribution_internal.deleteAllGroups(project.value.id),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['project-attribution', project.value.id] })
|
||||
await refreshProjectValidation()
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: formatMessage(messages.deleteAllGroups),
|
||||
@@ -472,6 +476,7 @@ function dismissInfoBanner() {
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<ValidationMessage :check="permissionsValidation" class="mb-4" />
|
||||
<ConfirmModal
|
||||
v-if="isModerator"
|
||||
ref="deleteAllGroupsModalRef"
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
:placeholder="formatMessage(messages.selectRegionPlaceholder)"
|
||||
:disabled="!hasPermission"
|
||||
/>
|
||||
<ValidationMessage :check="regionValidation" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<!-- Language -->
|
||||
@@ -42,6 +43,7 @@
|
||||
:placeholder="formatMessage(messages.selectLanguagesPlaceholder)"
|
||||
:disabled="!hasPermission"
|
||||
/>
|
||||
<ValidationMessage :check="languageValidation" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<!-- Java Address -->
|
||||
@@ -127,6 +129,7 @@
|
||||
/></template>
|
||||
</IntlFormatted>
|
||||
</div>
|
||||
<ValidationMessage :check="javaAddressValidation" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<!-- Bedrock Address -->
|
||||
@@ -151,7 +154,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CompatibilityCard />
|
||||
<div>
|
||||
<CompatibilityCard />
|
||||
<ValidationMessage :check="compatibilityValidation" class="mt-2" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -189,6 +195,8 @@ import {
|
||||
import { isAdmin } from '@modrinth/utils'
|
||||
|
||||
import CompatibilityCard from '~/components/ui/project-settings/CompatibilityCard.vue'
|
||||
import ValidationMessage from '~/components/ValidationMessage.vue'
|
||||
import { useProjectNagMessages } from '~/composables/project-nag-validation'
|
||||
|
||||
const PING_TIMEOUT_MS = 5000
|
||||
|
||||
@@ -273,6 +281,11 @@ const client = injectModrinthClient()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { projectV3, currentMember, patchProjectV3 } = injectProjectPageContext()
|
||||
|
||||
const regionValidation = useProjectNagMessages('server-region')
|
||||
const languageValidation = useProjectNagMessages('server-languages')
|
||||
const javaAddressValidation = useProjectNagMessages('java-address')
|
||||
const compatibilityValidation = useProjectNagMessages('server-compatibility')
|
||||
|
||||
useProjectSettingsHeadTitle(commonProjectSettingsMessages.server)
|
||||
|
||||
const javaAddress = ref('')
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Admonition,
|
||||
Checkbox,
|
||||
commonMessages,
|
||||
ConfirmLeaveModal,
|
||||
@@ -22,6 +21,9 @@ import {
|
||||
import { capitalizeString, isAdmin, sortedCategories } from '@modrinth/utils'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import ValidationMessage from '~/components/ValidationMessage.vue'
|
||||
import { useProjectNagMessages } from '~/composables/project-nag-validation'
|
||||
|
||||
interface Category {
|
||||
name: string
|
||||
header: string
|
||||
@@ -38,8 +40,6 @@ interface CategoryGroup {
|
||||
|
||||
const MAX_FEATURED_TAGS = 3
|
||||
|
||||
const RESOLUTION_TAGS = ['8x-', '16x', '32x', '48x', '64x', '128x', '256x', '512x+']
|
||||
|
||||
const SHARED_CATEGORY_PROJECT_TYPES: Record<string, string> = {
|
||||
plugin: 'mod',
|
||||
datapack: 'mod',
|
||||
@@ -313,46 +313,7 @@ const isFeaturedLimitReached = computed(
|
||||
const isAdminUser = computed(() => isAdmin(currentMember.value?.user))
|
||||
const canSave = computed(() => isAdminUser.value || current.value.featuredTags.length > 0)
|
||||
|
||||
const tooManyTagsWarning = computed(() => {
|
||||
const tagCount = current.value.selectedTags.length
|
||||
if (isServerProject.value) {
|
||||
if (tagCount > 18) {
|
||||
return formatMessage(messages.tooManyTagsServerHardWarning, { count: tagCount })
|
||||
} else if (tagCount > 12) {
|
||||
return formatMessage(messages.tooManyTagsServerSoftWarning, { count: tagCount })
|
||||
}
|
||||
} else if (tagCount > 8) {
|
||||
return formatMessage(messages.tooManyTagsProjectWarning, { count: tagCount })
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const multipleResolutionTagsWarning = computed(() => {
|
||||
if (!projectTypes.value.includes('resourcepack')) return null
|
||||
|
||||
const resolutionTags = current.value.selectedTags.filter((tag) => RESOLUTION_TAGS.includes(tag))
|
||||
if (resolutionTags.length < 2) return null
|
||||
|
||||
return formatMessage(messages.multipleResolutionTagsWarning, {
|
||||
count: resolutionTags.length,
|
||||
tags: resolutionTags
|
||||
.join(', ')
|
||||
.replace('8x-', '8x or lower')
|
||||
.replace('512x+', '512x or higher'),
|
||||
})
|
||||
})
|
||||
|
||||
const allTagsSelectedWarning = computed(() => {
|
||||
if (
|
||||
availableTags.value.length < 1 ||
|
||||
availableTags.value.length < 1 ||
|
||||
current.value.selectedTags.length !== availableTags.value.length
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return formatMessage(messages.allTagsSelectedWarning, { count: availableTags.value.length })
|
||||
})
|
||||
const tagValidation = useProjectNagMessages('tags')
|
||||
|
||||
function toggleTagRaw(selection: string[], tag: string) {
|
||||
if (selection.includes(tag)) {
|
||||
@@ -385,14 +346,6 @@ const toggleFeatured = (tag: string) => {
|
||||
:description="formatMessage(commonMessages.uploadVersionsEmptyStateDescription)"
|
||||
/>
|
||||
<div v-else class="flex flex-col gap-4">
|
||||
<Admonition v-if="allTagsSelectedWarning" type="critical" :body="allTagsSelectedWarning" />
|
||||
<Admonition v-else-if="tooManyTagsWarning" type="warning" :body="tooManyTagsWarning" />
|
||||
<Admonition
|
||||
v-if="multipleResolutionTagsWarning"
|
||||
type="warning"
|
||||
:body="multipleResolutionTagsWarning"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-for="group in categoryGroups"
|
||||
:key="group.id"
|
||||
@@ -456,6 +409,7 @@ const toggleFeatured = (tag: string) => {
|
||||
</Checkbox>
|
||||
</div>
|
||||
</div>
|
||||
<ValidationMessage :check="tagValidation" />
|
||||
</div>
|
||||
<UnsavedChangesPopup
|
||||
:original="saved"
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
:proceed-label="formatMessage(messages.deleteButton)"
|
||||
@proceed="deleteVersion()"
|
||||
/>
|
||||
<ValidationMessage :check="environmentValidation" class="mb-4" />
|
||||
<Admonition
|
||||
v-if="withheldVersions.length > 0"
|
||||
type="circle-warning"
|
||||
@@ -362,7 +363,9 @@ import {
|
||||
import { useTemplateRef, watch } from 'vue'
|
||||
|
||||
import CreateProjectVersionModal from '~/components/ui/create-project-version/CreateProjectVersionModal.vue'
|
||||
import ValidationMessage from '~/components/ValidationMessage.vue'
|
||||
import { getSignInRouteObj } from '~/composables/auth.ts'
|
||||
import { useProjectNagMessages } from '~/composables/project-nag-validation'
|
||||
import { reportVersion } from '~/utils/report-helpers.ts'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -380,6 +383,7 @@ const {
|
||||
loadVersions,
|
||||
cdnDownloadReason,
|
||||
} = injectProjectPageContext()
|
||||
const environmentValidation = useProjectNagMessages('version-environment')
|
||||
|
||||
useProjectSettingsHeadTitle(commonProjectSettingsMessages.versions)
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { CopyIcon, ListFilterIcon, ScaleIcon, SortAscIcon, SortDescIcon } from '@modrinth/assets'
|
||||
import { ListFilterIcon, ScaleIcon, SortAscIcon, SortDescIcon } from '@modrinth/assets'
|
||||
import { Button } from '@modrinth/ui'
|
||||
import {
|
||||
Combobox,
|
||||
@@ -156,12 +156,10 @@ import {
|
||||
injectNotificationManager,
|
||||
Pagination,
|
||||
Toggle,
|
||||
useDebugLogger,
|
||||
useFormatNumber,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import ConfettiExplosion from 'vue-confetti-explosion'
|
||||
|
||||
import ModerateByIdsModal from '~/components/ui/moderation/ModerateByIdsModal.vue'
|
||||
@@ -174,10 +172,6 @@ import { type ModerationProject, toModerationProjects } from '~/helpers/moderati
|
||||
import { getProjectTypeForUrlShorthand } from '~/helpers/projects.js'
|
||||
import { useModerationQueue } from '~/services/moderation/queue.ts'
|
||||
import { findNextEligibleQueueProject } from '~/services/moderation/queue-eligibility.ts'
|
||||
import {
|
||||
scanProjectsWithValidationIssues,
|
||||
type ValidationFilterRequest,
|
||||
} from '~/services/moderation/validation-filter.ts'
|
||||
|
||||
useHead({ title: 'Projects queue - Modrinth' })
|
||||
|
||||
@@ -189,8 +183,6 @@ const moderationQueue = useModerationQueue()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const client = injectModrinthClient()
|
||||
const queryClient = useQueryClient()
|
||||
const debugValidationFilter = useDebugLogger('moderation-validation-filter')
|
||||
const tags = useGeneratedState()
|
||||
|
||||
const queueSummaryModal = ref()
|
||||
@@ -228,17 +220,11 @@ const messages = defineMessages({
|
||||
})
|
||||
|
||||
const query = ref(route.query.q?.toString() || '')
|
||||
const debouncedFilterQuery = ref(query.value)
|
||||
const excludeTechnicalReview = ref(false)
|
||||
|
||||
const updateDebouncedFilterQuery = useDebounceFn((value: string) => {
|
||||
debouncedFilterQuery.value = value
|
||||
}, 500)
|
||||
|
||||
watch(
|
||||
query,
|
||||
(newQuery) => {
|
||||
updateDebouncedFilterQuery(newQuery)
|
||||
const currentQuery = { ...route.query }
|
||||
if (newQuery) {
|
||||
currentQuery.q = newQuery
|
||||
@@ -273,17 +259,12 @@ const filterTypes: ComboboxOption<string>[] = [
|
||||
{ value: 'Plugins', label: 'Plugins' },
|
||||
{ value: 'Shaders', label: 'Shaders' },
|
||||
{ value: 'Servers', label: 'Servers' },
|
||||
{ value: 'Validation errors', label: 'Validation errors' },
|
||||
{ value: 'Validation errors + warnings', label: 'Validation errors + warnings' },
|
||||
{ value: 'Fucked up', label: 'Fucked up' },
|
||||
]
|
||||
const filterTypeValues = filterTypes.map((option) => option.value)
|
||||
const DEFAULT_FILTER_TYPE = filterTypeValues[0]
|
||||
|
||||
const MODPACK_FILTER_TYPE = 'Modpacks'
|
||||
const VALIDATION_ERROR_FILTER_TYPE = 'Validation errors'
|
||||
const VALIDATION_ERROR_AND_WARNING_FILTER_TYPE = 'Validation errors + warnings'
|
||||
const VALIDATION_FILTER_STALE_TIME_MS = 1000 * 60 * 5
|
||||
|
||||
const baseSortTypes: ComboboxOption<string>[] = [
|
||||
{ value: 'Oldest', label: 'Oldest' },
|
||||
@@ -465,16 +446,6 @@ const moderationProjectsQueryKey = computed(
|
||||
() => ['moderation-projects', moderationProjectsRequest.value] as const,
|
||||
)
|
||||
|
||||
const isValidationErrorFilter = computed(
|
||||
() => currentFilterType.value === VALIDATION_ERROR_FILTER_TYPE,
|
||||
)
|
||||
const isValidationErrorAndWarningFilter = computed(
|
||||
() => currentFilterType.value === VALIDATION_ERROR_AND_WARNING_FILTER_TYPE,
|
||||
)
|
||||
const isValidationFilter = computed(
|
||||
() => isValidationErrorFilter.value || isValidationErrorAndWarningFilter.value,
|
||||
)
|
||||
|
||||
const {
|
||||
data: standardProjectsResponse,
|
||||
isPending: standardProjectsPending,
|
||||
@@ -484,100 +455,10 @@ const {
|
||||
queryKey: moderationProjectsQueryKey,
|
||||
queryFn: ({ queryKey }) => client.labrinth.moderation_internal.getProjects(queryKey[1]),
|
||||
placeholderData: (previousData) => previousData,
|
||||
enabled: computed(() => !isValidationFilter.value),
|
||||
})
|
||||
|
||||
const validationFilterRequest = computed<ValidationFilterRequest>(() => ({
|
||||
exclude_technical_review: excludeTechnicalReview.value,
|
||||
query: debouncedFilterQuery.value || undefined,
|
||||
sort: toApiSort(currentSortType.value),
|
||||
}))
|
||||
|
||||
const validationProjectsQueryKey = computed(
|
||||
() =>
|
||||
[
|
||||
'moderation-projects',
|
||||
'validation',
|
||||
isValidationErrorAndWarningFilter.value,
|
||||
validationFilterRequest.value,
|
||||
] as const,
|
||||
)
|
||||
|
||||
let validationScanNotificationId: string | number | undefined
|
||||
|
||||
function showValidationScanCompleteNotification(
|
||||
response: Labrinth.Moderation.Internal.ProjectsResponse,
|
||||
includeWarnings: boolean,
|
||||
) {
|
||||
if (validationScanNotificationId !== undefined) {
|
||||
notificationManager.removeNotification(validationScanNotificationId)
|
||||
}
|
||||
|
||||
const projectIds = response.projects.map((project) => project.id)
|
||||
const notification = addNotification({
|
||||
title: 'Validation scan complete',
|
||||
text: `Found ${response.total} projects with validation ${includeWarnings ? 'errors or warnings' : 'errors'}.`,
|
||||
type: 'success',
|
||||
autoCloseMs: null,
|
||||
copyable: false,
|
||||
buttons: [
|
||||
{
|
||||
label: 'Copy all IDs',
|
||||
icon: CopyIcon,
|
||||
keepOpen: true,
|
||||
action: () => navigator.clipboard.writeText(projectIds.join('\n')),
|
||||
},
|
||||
],
|
||||
})
|
||||
validationScanNotificationId = notification.id
|
||||
}
|
||||
|
||||
const {
|
||||
data: validationProjectsResponse,
|
||||
isPending: validationProjectsPending,
|
||||
error: validationProjectsError,
|
||||
} = useQuery({
|
||||
queryKey: validationProjectsQueryKey,
|
||||
queryFn: async ({ queryKey, signal }) => {
|
||||
const response = await scanProjectsWithValidationIssues({
|
||||
client,
|
||||
request: queryKey[3],
|
||||
includeWarnings: queryKey[2],
|
||||
tags: tags.value,
|
||||
signal,
|
||||
log: debugValidationFilter,
|
||||
})
|
||||
showValidationScanCompleteNotification(response, queryKey[2])
|
||||
return response
|
||||
},
|
||||
enabled: computed(() => import.meta.client && isValidationFilter.value),
|
||||
staleTime: VALIDATION_FILTER_STALE_TIME_MS,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
watch([isValidationFilter, validationProjectsQueryKey], ([isActive]) => {
|
||||
if (!isActive) return
|
||||
const cached = queryClient.getQueryData<Labrinth.Moderation.Internal.ProjectsResponse>(
|
||||
validationProjectsQueryKey.value,
|
||||
)
|
||||
if (cached) {
|
||||
debugValidationFilter(`Using cached scan result with ${cached.total} matching projects`)
|
||||
showValidationScanCompleteNotification(cached, isValidationErrorAndWarningFilter.value)
|
||||
}
|
||||
})
|
||||
|
||||
const usesLocalPagination = computed(() => isValidationFilter.value)
|
||||
const moderationProjectsResponse = computed(() =>
|
||||
isValidationFilter.value ? validationProjectsResponse.value : standardProjectsResponse.value,
|
||||
)
|
||||
const pending = computed(() =>
|
||||
isValidationFilter.value
|
||||
? validationProjectsPending.value
|
||||
: standardProjectsPending.value || standardProjectsPlaceholder.value,
|
||||
)
|
||||
const loadError = computed(() =>
|
||||
isValidationFilter.value ? validationProjectsError.value : standardProjectsError.value,
|
||||
)
|
||||
const moderationProjectsResponse = computed(() => standardProjectsResponse.value)
|
||||
const pending = computed(() => standardProjectsPending.value || standardProjectsPlaceholder.value)
|
||||
const loadError = computed(() => standardProjectsError.value)
|
||||
const loadErrorMessage = computed(
|
||||
() => loadError.value?.message ?? 'An unknown error occurred while loading the moderation queue.',
|
||||
)
|
||||
@@ -586,11 +467,7 @@ const totalPages = computed(() => Math.ceil(totalProjects.value / itemsPerPage.v
|
||||
const filteredProjects = computed(() =>
|
||||
toModerationProjects(moderationProjectsResponse.value?.projects ?? []),
|
||||
)
|
||||
const paginatedProjects = computed(() => {
|
||||
if (!usesLocalPagination.value) return filteredProjects.value
|
||||
const start = (currentPage.value - 1) * itemsPerPage.value
|
||||
return filteredProjects.value.slice(start, start + itemsPerPage.value)
|
||||
})
|
||||
const paginatedProjects = computed(() => filteredProjects.value)
|
||||
const pageStart = computed(() =>
|
||||
totalProjects.value === 0 ? 0 : (currentPage.value - 1) * itemsPerPage.value + 1,
|
||||
)
|
||||
@@ -708,10 +585,6 @@ async function startModeratingByIds(projectIds: string[]) {
|
||||
}
|
||||
|
||||
async function getFilteredProjectIds(): Promise<string[]> {
|
||||
if (usesLocalPagination.value) {
|
||||
return filteredProjects.value.map((project) => project.project.id)
|
||||
}
|
||||
|
||||
const response = await client.labrinth.moderation_internal.getProjectIds({
|
||||
exclude_technical_review: excludeTechnicalReview.value,
|
||||
query: query.value || undefined,
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
import type { AbstractModrinthClient, Labrinth } from '@modrinth/api-client'
|
||||
import { type ProjectValidationContext, validateProject } from '@modrinth/moderation'
|
||||
|
||||
export type ValidationFilterRequest = Omit<
|
||||
Labrinth.Moderation.Internal.ProjectsRequest,
|
||||
'count' | 'offset' | 'project_type'
|
||||
>
|
||||
|
||||
interface ModerationQueueFetchOptions {
|
||||
client: AbstractModrinthClient
|
||||
request: ValidationFilterRequest
|
||||
signal: AbortSignal
|
||||
log: (message: string) => void
|
||||
}
|
||||
|
||||
interface ValidationFilterScanOptions {
|
||||
client: AbstractModrinthClient
|
||||
request: ValidationFilterRequest
|
||||
includeWarnings: boolean
|
||||
tags: ProjectValidationContext['tags']
|
||||
signal: AbortSignal
|
||||
log: (message: string) => void
|
||||
}
|
||||
|
||||
const REQUEST_DELAY_MS = 500
|
||||
const PROJECT_BATCH_SIZE = 100
|
||||
const QUEUE_PAGE_SIZE = 200
|
||||
|
||||
function createPacedFetcher(signal: AbortSignal, log: (message: string) => void) {
|
||||
let requestCount = 0
|
||||
let currentStage = 'starting request'
|
||||
|
||||
return {
|
||||
async fetch<T>(label: string, fetcher: () => Promise<T>): Promise<T> {
|
||||
signal.throwIfAborted()
|
||||
if (requestCount > 0) {
|
||||
log(`Waiting ${REQUEST_DELAY_MS}ms before next request`)
|
||||
await waitForNextRequest(signal)
|
||||
}
|
||||
|
||||
currentStage = label
|
||||
log(label)
|
||||
requestCount++
|
||||
const result = await fetcher()
|
||||
signal.throwIfAborted()
|
||||
return result
|
||||
},
|
||||
getCurrentStage() {
|
||||
return currentStage
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function waitForNextRequest(signal: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal.aborted) {
|
||||
reject(signal.reason ?? new Error('Moderation queue fetch cancelled'))
|
||||
return
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
resolve()
|
||||
}, REQUEST_DELAY_MS)
|
||||
|
||||
function onAbort() {
|
||||
clearTimeout(timeout)
|
||||
reject(signal.reason ?? new Error('Moderation queue fetch cancelled'))
|
||||
}
|
||||
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
async function fetchQueueProjects(
|
||||
{ client, request, log }: ModerationQueueFetchOptions,
|
||||
fetchWithDelay: ReturnType<typeof createPacedFetcher>['fetch'],
|
||||
): Promise<Labrinth.Moderation.Internal.ProjectsResponse> {
|
||||
const firstPage = await fetchWithDelay('Fetching queue page 1', () =>
|
||||
client.labrinth.moderation_internal.getProjects({
|
||||
...request,
|
||||
count: QUEUE_PAGE_SIZE,
|
||||
offset: 0,
|
||||
}),
|
||||
)
|
||||
const projects = [...firstPage.projects]
|
||||
const pageCount = Math.ceil(firstPage.total / QUEUE_PAGE_SIZE)
|
||||
|
||||
log(`Found ${firstPage.total} queue projects`)
|
||||
for (let page = 1; page < pageCount; page++) {
|
||||
const response = await fetchWithDelay(`Fetching queue page ${page + 1}/${pageCount}`, () =>
|
||||
client.labrinth.moderation_internal.getProjects({
|
||||
...request,
|
||||
count: QUEUE_PAGE_SIZE,
|
||||
offset: page * QUEUE_PAGE_SIZE,
|
||||
}),
|
||||
)
|
||||
projects.push(...response.projects)
|
||||
}
|
||||
|
||||
return {
|
||||
total: projects.length,
|
||||
projects,
|
||||
}
|
||||
}
|
||||
|
||||
export async function scanProjectsWithValidationIssues({
|
||||
client,
|
||||
request,
|
||||
includeWarnings,
|
||||
tags,
|
||||
signal,
|
||||
log,
|
||||
}: ValidationFilterScanOptions): Promise<Labrinth.Moderation.Internal.ProjectsResponse> {
|
||||
const pacedFetcher = createPacedFetcher(signal, log)
|
||||
|
||||
try {
|
||||
log('Starting validation scan')
|
||||
const queueResponse = await fetchQueueProjects(
|
||||
{ client, request, signal, log },
|
||||
pacedFetcher.fetch,
|
||||
)
|
||||
const queueProjects = queueResponse.projects
|
||||
log(`Found ${queueProjects.length} projects to scan`)
|
||||
|
||||
const matchingProjectIds = new Set<string>()
|
||||
let validatedProjectCount = 0
|
||||
const projectBatchCount = Math.ceil(queueProjects.length / PROJECT_BATCH_SIZE)
|
||||
|
||||
for (let batchIndex = 0; batchIndex < projectBatchCount; batchIndex++) {
|
||||
const projectIds = queueProjects
|
||||
.slice(batchIndex * PROJECT_BATCH_SIZE, (batchIndex + 1) * PROJECT_BATCH_SIZE)
|
||||
.map((project) => project.id)
|
||||
const projectsV3 = await pacedFetcher.fetch(
|
||||
`Fetching V3 project batch ${batchIndex + 1}/${projectBatchCount}`,
|
||||
() => client.labrinth.projects_v3.getMultiple(projectIds),
|
||||
)
|
||||
const projectsV2 = await pacedFetcher.fetch(
|
||||
`Fetching V2 project batch ${batchIndex + 1}/${projectBatchCount}`,
|
||||
() => client.labrinth.projects_v2.getMultiple(projectIds),
|
||||
)
|
||||
const projectsV3ById = new Map(projectsV3.map((project) => [project.id, project]))
|
||||
const projectsV2ById = new Map(projectsV2.map((project) => [project.id, project]))
|
||||
const missingProjectIds = projectIds.filter(
|
||||
(projectId) => !projectsV3ById.has(projectId) || !projectsV2ById.has(projectId),
|
||||
)
|
||||
|
||||
if (missingProjectIds.length > 0) {
|
||||
throw new Error(`Project responses omitted ${missingProjectIds.length} queued projects`)
|
||||
}
|
||||
|
||||
const versionIds = [...new Set(projectsV3.flatMap((project) => project.versions))]
|
||||
const versions: Labrinth.Versions.v3.Version[] = []
|
||||
const versionBatchCount = Math.ceil(versionIds.length / PROJECT_BATCH_SIZE)
|
||||
for (let versionBatchIndex = 0; versionBatchIndex < versionBatchCount; versionBatchIndex++) {
|
||||
const batchVersionIds = versionIds.slice(
|
||||
versionBatchIndex * PROJECT_BATCH_SIZE,
|
||||
(versionBatchIndex + 1) * PROJECT_BATCH_SIZE,
|
||||
)
|
||||
versions.push(
|
||||
...(await pacedFetcher.fetch(
|
||||
`Fetching version batch ${versionBatchIndex + 1}/${versionBatchCount}`,
|
||||
() => client.labrinth.versions_v3.getVersions(batchVersionIds),
|
||||
)),
|
||||
)
|
||||
}
|
||||
const versionsById = new Map(versions.map((version) => [version.id, version]))
|
||||
const missingVersionIds = versionIds.filter((versionId) => !versionsById.has(versionId))
|
||||
if (missingVersionIds.length > 0) {
|
||||
throw new Error(`Version responses omitted ${missingVersionIds.length} project versions`)
|
||||
}
|
||||
|
||||
for (const projectId of projectIds) {
|
||||
const projectV3 = projectsV3ById.get(projectId)
|
||||
const rawProjectV2 = projectsV2ById.get(projectId)
|
||||
if (!projectV3 || !rawProjectV2) {
|
||||
throw new Error(`Project responses omitted queued project ${projectId}`)
|
||||
}
|
||||
const project = {
|
||||
...rawProjectV2,
|
||||
actualProjectType: rawProjectV2.project_type,
|
||||
}
|
||||
const projectVersions = projectV3.versions.flatMap((versionId) => {
|
||||
const version = versionsById.get(versionId)
|
||||
return version ? [version] : []
|
||||
})
|
||||
const validation = validateProject({
|
||||
project,
|
||||
projectV3,
|
||||
versions: projectVersions,
|
||||
tags,
|
||||
})
|
||||
if (
|
||||
validation.requiredNags.length > 0 ||
|
||||
(includeWarnings && validation.warningNags.length > 0)
|
||||
) {
|
||||
matchingProjectIds.add(projectId)
|
||||
}
|
||||
}
|
||||
|
||||
validatedProjectCount += projectIds.length
|
||||
log(
|
||||
`Validated ${validatedProjectCount}/${queueProjects.length} projects; ${matchingProjectIds.size} matched`,
|
||||
)
|
||||
}
|
||||
|
||||
const projects = queueProjects.filter((project) => matchingProjectIds.has(project.id))
|
||||
log(`Matching project IDs: ${projects.map((project) => project.id).join(', ') || 'none'}`)
|
||||
log(`Scan complete: ${projects.length}/${queueProjects.length} projects matched`)
|
||||
|
||||
return {
|
||||
total: projects.length,
|
||||
projects,
|
||||
}
|
||||
} catch (error) {
|
||||
if (signal.aborted) {
|
||||
log('Scan cancelled')
|
||||
} else {
|
||||
console.error(
|
||||
`[moderation-validation-filter] Scan failed during ${pacedFetcher.getCurrentStage()}`,
|
||||
error,
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user