+
+
https://modrinth.com/project/
-
+
+
-
-
-
-
- {{ getFormattedMessage(nag.title) }}
-
- {{ getNagDescription(nag) }}
-
+
+
+
- {{ getFormattedMessage(nag.link.title) }}
-
-
-
+
+
+
+ {{ getFormattedMessage(nag.title) }}
+
+
+
+
+
+ {{ getFormattedMessage(nag.link.title) }}
+
+
+
+
+
+
-
+
@@ -93,22 +133,37 @@ import {
TriangleAlertIcon,
} from '@modrinth/assets'
import type { Nag, NagContext, NagStatus } from '@modrinth/moderation'
-import { nags } from '@modrinth/moderation'
-import { Button, IconButton } from '@modrinth/ui'
-import { defineMessages, type MessageDescriptor, useVIntl } from '@modrinth/ui'
+import { nagDestinations, normalizeProjectNagKind, toProjectNag } from '@modrinth/moderation'
+import { Accordion, Button, IconButton } from '@modrinth/ui'
+import {
+ commonMessages,
+ defineMessages,
+ injectNotificationManager,
+ type MessageDescriptor,
+ useVIntl,
+} from '@modrinth/ui'
import type { Component } from 'vue'
-import { computed } from 'vue'
+import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
interface Tags {
+ categories?: Labrinth.Tags.v2.Category[]
rejectedStatuses: string[]
+ gameVersions: { version: string }[]
+ loaders: { name: string }[]
}
interface Props {
project: Labrinth.Projects.v2.Project
projectV3: Labrinth.Projects.v3.Project
versions?: Labrinth.Versions.v3.Version[]
+ nags?: Nag[]
+ validationNags?: Labrinth.Projects.v3.ProjectNag[]
+ validationLoading?: boolean
+ validationAvailable?: boolean
+ refreshValidation?: () => Promise
currentMember?: Labrinth.Projects.v3.TeamMember | null
collapsed?: boolean
+ disableHorizontalScroll?: boolean
routeName?: string
tags: Tags
}
@@ -160,15 +215,24 @@ const messages = defineMessages({
id: 'project-moderation-nags.suggestion',
defaultMessage: 'Suggestion',
},
+ projectSubmittedForReview: {
+ id: 'project-moderation-nags.project-submitted-for-review',
+ defaultMessage: 'Your project has been submitted for review!',
+ },
})
const { formatMessage } = useVIntl()
+const { addNotification } = injectNotificationManager()
const props = withDefaults(defineProps(), {
versions: () => [],
currentMember: null,
collapsed: false,
+ disableHorizontalScroll: false,
routeName: '',
+ validationNags: () => [],
+ validationLoading: false,
+ validationAvailable: true,
})
const emit = defineEmits<{
@@ -176,33 +240,176 @@ const emit = defineEmits<{
setProcessing: [processing: boolean]
}>()
+const isProcessing = computed(() => props.project.status === 'processing')
+
+const nagScroller = ref(null)
+const canScrollNags = ref(false)
+const showLeftNagShadow = ref(false)
+const showRightNagShadow = ref(false)
+const draggingNags = ref(false)
+
+let nagScrollerResizeObserver: ResizeObserver | null = null
+let nagDragPointerId: number | null = null
+let nagDragCaptureTarget: Element | null = null
+let nagDragStartX = 0
+let nagDragStartScrollLeft = 0
+let suppressNagClick = false
+let suppressNagClickTimeout: ReturnType | null = null
+
+function updateNagScrollShadows() {
+ const el = nagScroller.value
+ if (!el || props.disableHorizontalScroll) {
+ canScrollNags.value = false
+ showLeftNagShadow.value = false
+ showRightNagShadow.value = false
+ return
+ }
+
+ canScrollNags.value = el.scrollWidth > el.clientWidth + 1
+ showLeftNagShadow.value = canScrollNags.value && el.scrollLeft > 0
+ showRightNagShadow.value =
+ canScrollNags.value && el.scrollLeft < el.scrollWidth - el.clientWidth - 1
+}
+
+function onNagWheel(event: WheelEvent) {
+ const el = nagScroller.value
+ if (props.disableHorizontalScroll || !el || el.scrollWidth <= el.clientWidth) return
+
+ const delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY
+ event.preventDefault()
+ el.scrollLeft += delta
+}
+
+function onNagPointerDown(event: PointerEvent) {
+ const el = nagScroller.value
+ if (
+ props.disableHorizontalScroll ||
+ !el ||
+ el.scrollWidth <= el.clientWidth + 1 ||
+ event.pointerType === 'touch' ||
+ event.button !== 0
+ )
+ return
+
+ nagDragPointerId = event.pointerId
+ nagDragStartX = event.clientX
+ nagDragStartScrollLeft = el.scrollLeft
+ suppressNagClick = false
+ nagDragCaptureTarget =
+ event.target instanceof Element ? (event.target.closest('a, button') ?? el) : el
+ nagDragCaptureTarget.setPointerCapture(event.pointerId)
+}
+
+function onNagPointerMove(event: PointerEvent) {
+ const el = nagScroller.value
+ if (!el || event.pointerId !== nagDragPointerId) return
+
+ const distance = event.clientX - nagDragStartX
+ if (!draggingNags.value && Math.abs(distance) < 4) return
+
+ draggingNags.value = true
+ suppressNagClick = true
+ event.preventDefault()
+ el.scrollLeft = nagDragStartScrollLeft - distance
+}
+
+function finishNagDrag(event: PointerEvent) {
+ if (event.pointerId !== nagDragPointerId) return
+
+ if (nagDragCaptureTarget?.hasPointerCapture(event.pointerId)) {
+ nagDragCaptureTarget.releasePointerCapture(event.pointerId)
+ }
+ nagDragPointerId = null
+ nagDragCaptureTarget = null
+ draggingNags.value = false
+
+ if (suppressNagClick) {
+ if (suppressNagClickTimeout) clearTimeout(suppressNagClickTimeout)
+ suppressNagClickTimeout = setTimeout(() => {
+ suppressNagClick = false
+ suppressNagClickTimeout = null
+ }, 0)
+ }
+}
+
+function onNagClick(event: MouseEvent) {
+ if (!suppressNagClick) return
+
+ event.preventDefault()
+ event.stopPropagation()
+ suppressNagClick = false
+ if (suppressNagClickTimeout) clearTimeout(suppressNagClickTimeout)
+ suppressNagClickTimeout = null
+}
+
+onMounted(() => {
+ nagScrollerResizeObserver = new ResizeObserver(updateNagScrollShadows)
+ if (nagScroller.value) nagScrollerResizeObserver.observe(nagScroller.value)
+ nextTick(updateNagScrollShadows)
+})
+
+onBeforeUnmount(() => {
+ nagScrollerResizeObserver?.disconnect()
+ if (suppressNagClickTimeout) clearTimeout(suppressNagClickTimeout)
+})
+
+watch(nagScroller, (el, previousEl) => {
+ if (previousEl) nagScrollerResizeObserver?.unobserve(previousEl)
+ if (el) nagScrollerResizeObserver?.observe(el)
+ nextTick(updateNagScrollShadows)
+})
+
+watch(
+ () => props.disableHorizontalScroll,
+ () => nextTick(updateNagScrollShadows),
+)
+
const nagContext = computed(() => ({
project: props.project,
projectV3: props.projectV3,
versions: props.versions,
- currentMember: props.currentMember?.user as Labrinth.Users.v2.User,
+ currentMember: props.currentMember?.user,
currentRoute: props.routeName,
tags: props.tags,
- submitProject: submitForReview,
}))
const canSubmitForReview = computed(() => {
return (
- applicableNags.value.filter((nag) => nag.status === 'required' && !isNagComplete(nag))
- .length === 0
+ !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
+ if (!props.collapsed) emit('toggleCollapsed')
+ emit('setProcessing', true)
+ await navigateTo(
+ `/${props.project.project_type}/${props.project.slug ?? props.project.id}/${nagDestinations.moderation.path}`,
+ )
+ addNotification({
+ type: 'success',
+ title: formatMessage(commonMessages.successLabel),
+ text: formatMessage(messages.projectSubmittedForReview),
+ })
}
const applicableNags = computed(() => {
- return nags.filter((nag) => {
- return nag.shouldShow(nagContext.value)
- })
+ if (props.nags) return props.nags
+
+ 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 {
@@ -232,9 +439,8 @@ const visibleNags = computed(() => {
status: 'special-submit-action',
shouldShow: (ctx) => ctx.tags.rejectedStatuses.includes(ctx.project.status),
link: {
- path: 'moderation',
+ ...nagDestinations.moderation,
title: messages.visitModerationPage,
- shouldShow: () => props.routeName !== 'type-project-moderation',
},
})
}
@@ -247,8 +453,50 @@ const visibleNags = computed(() => {
return finalNags
})
+watch(visibleNags, () => nextTick(updateNagScrollShadows))
+
+watch(isProcessing, (processing) => {
+ if (processing && !props.collapsed) emit('toggleCollapsed')
+})
+
+let validationProjectId = props.project.id
+let previousActionableNagKeys: Set | null = null
+
+watch(
+ [
+ () => props.project.id,
+ () => props.validationLoading,
+ () => props.validationAvailable,
+ () => props.validationNags,
+ ],
+ ([projectId, validationLoading, validationAvailable, validationNags]) => {
+ if (projectId !== validationProjectId) {
+ validationProjectId = projectId
+ previousActionableNagKeys = null
+ }
+
+ if (validationLoading || !validationAvailable) return
+
+ const actionableNagKeys = new Set(
+ validationNags
+ .filter((nag) => nag.severity === 'required' || nag.severity === 'warning')
+ .map((nag) => `${nag.severity}:${nag.kind}`),
+ )
+ const previousNagKeys = previousActionableNagKeys
+ const hasNewActionableNag =
+ previousNagKeys !== null && [...actionableNagKeys].some((key) => !previousNagKeys.has(key))
+
+ previousActionableNagKeys = actionableNagKeys
+
+ if (isProcessing.value && props.collapsed && hasNewActionableNag) {
+ emit('toggleCollapsed')
+ }
+ },
+ { deep: true, immediate: true },
+)
+
function shouldShowLink(nag: Nag): boolean {
- return nag.link?.shouldShow ? nag.link.shouldShow(nagContext.value) : false
+ return nag.link?.shouldShow(nagContext.value) ?? false
}
function getDefaultIcon(status: NagStatus): Component {
@@ -283,7 +531,14 @@ 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 }[] {
+ return getNagDescription(nag)
+ .split(/(https?:\/\/[^\s"'<>“”]+)/gi)
+ .filter(Boolean)
+ .map((text) => ({ text, isUrl: /^https?:\/\//i.test(text) }))
}
function getFormattedMessage(message: string | MessageDescriptor): string {
@@ -295,7 +550,18 @@ function getFormattedMessage(message: string | MessageDescriptor): string {
diff --git a/apps/frontend/src/composables/project-nag-validation.ts b/apps/frontend/src/composables/project-nag-validation.ts
new file mode 100644
index 0000000000..2bf81e6edf
--- /dev/null
+++ b/apps/frontend/src/composables/project-nag-validation.ts
@@ -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(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)),
+ )
+}
diff --git a/apps/frontend/src/composables/project-slug-suggestions.ts b/apps/frontend/src/composables/project-slug-suggestions.ts
new file mode 100644
index 0000000000..4c0476864a
--- /dev/null
+++ b/apps/frontend/src/composables/project-slug-suggestions.ts
@@ -0,0 +1,139 @@
+import { ModrinthApiError } from '@modrinth/api-client'
+import { injectModrinthClient } from '@modrinth/ui'
+import { useQueryClient } from '@tanstack/vue-query'
+import { type MaybeRefOrGetter, onScopeDispose, ref, toValue, watch } from 'vue'
+
+const STALE_TIME = 1000 * 60 * 5
+const CHECK_DEBOUNCE = 300
+const PROJECT_SLUG_UNSAFE_CHARS = /[^a-zA-Z0-9._-]/g
+const PROJECT_SLUG_REGEX = /^[a-zA-Z0-9._-]{3,64}$/
+
+interface ProjectSlugSuggestionOptions {
+ title: MaybeRefOrGetter
+ username?: MaybeRefOrGetter
+ currentProjectId?: MaybeRefOrGetter
+ enabled?: MaybeRefOrGetter
+}
+
+export function generateUrlSlug(value: string) {
+ return value
+ .trim()
+ .toLowerCase()
+ .replaceAll(' ', '-')
+ .replaceAll(PROJECT_SLUG_UNSAFE_CHARS, '')
+ .replaceAll(/--+/gm, '-')
+}
+
+function isValidProjectSlug(value: string) {
+ return PROJECT_SLUG_REGEX.test(value)
+}
+
+function generateProjectSlugSuggestions(title: string, username?: string | null) {
+ const titleSlug = generateUrlSlug(title)
+ const titleWords = title
+ .trim()
+ .split(/\s+/)
+ .map((word) => generateUrlSlug(word))
+ .filter(Boolean)
+ const acronym = titleWords.length > 1 ? titleWords.map((word) => word[0]).join('') : ''
+ const withoutDashes = titleSlug.replaceAll('-', '')
+ const usernameSlug = username ? generateUrlSlug(username) : ''
+ let withUsername = ''
+
+ if (titleSlug && usernameSlug) {
+ const availableTitleLength = 64 - usernameSlug.length - 1
+ const truncatedTitle = titleSlug.slice(0, availableTitleLength).replace(/-+$/, '')
+ if (truncatedTitle) withUsername = `${truncatedTitle}-${usernameSlug}`
+ }
+
+ return [...new Set([titleSlug, acronym, withoutDashes, withUsername])].filter(isValidProjectSlug)
+}
+
+export function useSlugSuggestionVisibility() {
+ const visible = ref(false)
+
+ function onFocusIn() {
+ visible.value = true
+ }
+
+ function onFocusOut(event: FocusEvent) {
+ const container = event.currentTarget as HTMLElement
+ if (!container.contains(event.relatedTarget as Node | null)) visible.value = false
+ }
+
+ return {
+ onFocusIn,
+ onFocusOut,
+ visible,
+ }
+}
+
+export function useProjectSlugSuggestions({
+ title,
+ username,
+ currentProjectId,
+ enabled = true,
+}: ProjectSlugSuggestionOptions) {
+ const client = injectModrinthClient()
+ const queryClient = useQueryClient()
+ const suggestions = ref([])
+ const checking = ref(false)
+ let debounceTimer: ReturnType | undefined
+ let requestId = 0
+
+ async function isAvailable(slug: string, projectId?: string | null) {
+ return queryClient.fetchQuery({
+ queryKey: ['project', 'slug-available', slug, projectId ?? null],
+ queryFn: async () => {
+ try {
+ const result = await client.labrinth.projects_v2.check(slug)
+ return result.id === projectId
+ } catch (error) {
+ if (error instanceof ModrinthApiError && error.statusCode === 404) return true
+ throw error
+ }
+ },
+ staleTime: STALE_TIME,
+ retry: false,
+ })
+ }
+
+ watch(
+ () => [toValue(title), toValue(username), toValue(currentProjectId), toValue(enabled)] as const,
+ ([newTitle, newUsername, projectId, isEnabled]) => {
+ if (import.meta.server) return
+
+ clearTimeout(debounceTimer)
+ const currentRequestId = ++requestId
+ suggestions.value = []
+ checking.value = false
+
+ if (!isEnabled) return
+
+ const candidates = generateProjectSlugSuggestions(newTitle, newUsername)
+
+ if (candidates.length === 0) {
+ return
+ }
+
+ checking.value = true
+ debounceTimer = setTimeout(async () => {
+ const availability = await Promise.all(
+ candidates.map((candidate) => isAvailable(candidate, projectId)),
+ )
+ if (currentRequestId !== requestId) return
+
+ suggestions.value = candidates.filter((_, index) => availability[index])
+ checking.value = false
+ }, CHECK_DEBOUNCE)
+ },
+ { immediate: true },
+ )
+
+ onScopeDispose(() => clearTimeout(debounceTimer))
+
+ return {
+ checking,
+ suggestions,
+ }
+}
diff --git a/apps/frontend/src/locales/en-US/index.json b/apps/frontend/src/locales/en-US/index.json
index 85e2195dd9..ab65b19ac0 100644
--- a/apps/frontend/src/locales/en-US/index.json
+++ b/apps/frontend/src/locales/en-US/index.json
@@ -3422,6 +3422,9 @@
"project-member-header.success-join": {
"message": "You have joined the project team"
},
+ "project-moderation-nags.project-submitted-for-review": {
+ "message": "Your project has been submitted for review!"
+ },
"project-moderation-nags.publishing-checklist": {
"message": "Publishing checklist"
},
@@ -4085,6 +4088,12 @@
"project.settings.general.url.title": {
"message": "URL"
},
+ "project.settings.links.donation.duplicate-type": {
+ "message": "You already have another {platform} link."
+ },
+ "project.settings.links.donation.no-type": {
+ "message": "Please select a platform for this Donation link."
+ },
"project.settings.monetization.description": {
"message": "Projects on Modrinth are automatically enrolled in the Rewards Program. If you don't want to (or can't for legal reasons) earn revenue from this project, you can turn it off here."
},
@@ -4289,6 +4298,9 @@
"project.settings.tags.upload-version-first.heading": {
"message": "Upload versions before adding tags"
},
+ "project.slug-suggestions.label": {
+ "message": "Suggestions:"
+ },
"project.versions.copy-id-option": {
"message": "Copy ID"
},
diff --git a/apps/frontend/src/pages/[type]/[project].vue b/apps/frontend/src/pages/[type]/[project].vue
index bbea264f67..71f4d8b9ef 100644
--- a/apps/frontend/src/pages/[type]/[project].vue
+++ b/apps/frontend/src/pages/[type]/[project].vue
@@ -144,7 +144,9 @@
v-if="
projectV3 &&
currentMember &&
- (projectV3.status === 'draft' || tags.rejectedStatuses.includes(projectV3.status))
+ (projectV3.status === 'draft' ||
+ projectV3.status === 'processing' ||
+ tags.rejectedStatuses.includes(projectV3.status))
"
:project="project"
:project-v3="projectV3"
@@ -153,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"
/>
@@ -836,6 +842,14 @@ const messages = defineMessages({
id: 'project.notification.updated.message',
defaultMessage: 'Your project has been updated.',
},
+ projectReviewSaveFailed: {
+ id: 'project.notification.review-save-failed.title',
+ defaultMessage: 'Failed to save project in review',
+ },
+ projectReviewSaveFailedDescription: {
+ id: 'project.notification.review-save-failed.description',
+ defaultMessage: 'You cannot save edits to your project which result in failing validation.',
+ },
reviewEnvironmentSettings: {
id: 'project.environment.migration.review-button',
defaultMessage: 'Review environment settings',
@@ -1371,6 +1385,30 @@ function mergeV3ProjectPatch(old, data) {
return merged
}
+const PROJECT_REVIEW_VALIDATION_ERROR =
+ 'project must have no required validation nags before or while under review'
+
+function addProjectMutationErrorNotification(error) {
+ const description =
+ error?.v1Error?.description ??
+ error?.responseData?.description ??
+ error?.data?.description ??
+ error?.message
+ const isProjectReviewValidationError = description === PROJECT_REVIEW_VALIDATION_ERROR
+
+ addNotification({
+ title: formatMessage(
+ isProjectReviewValidationError
+ ? messages.projectReviewSaveFailed
+ : commonMessages.errorNotificationTitle,
+ ),
+ text: isProjectReviewValidationError
+ ? formatMessage(messages.projectReviewSaveFailedDescription)
+ : description,
+ type: 'error',
+ })
+}
+
// Mutation for patching project data
const patchProjectMutation = useMutation({
mutationFn: async ({ projectId, data }) => {
@@ -1406,11 +1444,7 @@ const patchProjectMutation = useMutation({
if (context?.previousV3) {
queryClient.setQueryData(['project', 'v3', context.projectId], context.previousV3)
}
- addNotification({
- title: formatMessage(commonMessages.errorNotificationTitle),
- text: err.data ? err.data.description : err.message,
- type: 'error',
- })
+ addProjectMutationErrorNotification(err)
},
onSettled: async () => {
@@ -1441,11 +1475,7 @@ const patchStatusMutation = useMutation({
if (context?.previousProject) {
queryClient.setQueryData(['project', 'v2', context.projectId], context.previousProject)
}
- addNotification({
- title: formatMessage(commonMessages.errorNotificationTitle),
- text: err.data ? err.data.description : err.message,
- type: 'error',
- })
+ addProjectMutationErrorNotification(err)
},
onSettled: async () => {
@@ -1485,15 +1515,11 @@ const patchProjectV3Mutation = useMutation({
if (context?.previousV2) {
queryClient.setQueryData(['project', 'v2', context.projectId], context.previousV2)
}
- addNotification({
- title: formatMessage(commonMessages.errorNotificationTitle),
- text: err.data ? err.data.description : err.message,
- type: 'error',
- })
+ addProjectMutationErrorNotification(err)
},
- onSettled: async () => {
- await invalidateProject()
+ onSettled: () => {
+ void invalidateProject()
},
})
@@ -1513,11 +1539,7 @@ const patchIconMutation = useMutation({
},
onError: (err) => {
- addNotification({
- title: formatMessage(commonMessages.errorNotificationTitle),
- text: err.data ? err.data.description : err.message,
- type: 'error',
- })
+ addProjectMutationErrorNotification(err)
},
onSettled: async () => {
@@ -1566,11 +1588,7 @@ const createGalleryItemMutation = useMutation({
if (context?.previousProject) {
queryClient.setQueryData(['project', 'v2', context.projectId], context.previousProject)
}
- addNotification({
- title: formatMessage(commonMessages.errorNotificationTitle),
- text: err.data ? err.data.description : err.message,
- type: 'error',
- })
+ addProjectMutationErrorNotification(err)
},
onSettled: async () => {
@@ -1619,11 +1637,7 @@ const editGalleryItemMutation = useMutation({
if (context?.previousProject) {
queryClient.setQueryData(['project', 'v2', context.projectId], context.previousProject)
}
- addNotification({
- title: formatMessage(commonMessages.errorNotificationTitle),
- text: err.data ? err.data.description : err.message,
- type: 'error',
- })
+ addProjectMutationErrorNotification(err)
},
onSettled: async () => {
@@ -1656,11 +1670,7 @@ const deleteGalleryItemMutation = useMutation({
if (context?.previousProject) {
queryClient.setQueryData(['project', 'v2', context.projectId], context.previousProject)
}
- addNotification({
- title: formatMessage(commonMessages.errorNotificationTitle),
- text: err.data ? err.data.description : err.message,
- type: 'error',
- })
+ addProjectMutationErrorNotification(err)
},
onSettled: async () => {
@@ -1726,6 +1736,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(() => {
@@ -2254,7 +2282,7 @@ async function copyPermalink() {
await navigator.clipboard.writeText(`${config.public.siteUrl}/project/${project.value.id}`)
}
-const collapsedChecklist = ref(false)
+const collapsedChecklist = useLocalStorage(`project-checklist-collapsed-${project.value.id}`, false)
const showModerationChecklist = ref(false)
const collapsedModerationChecklist = useLocalStorage('collapsed-moderation-checklist', false)
@@ -2429,6 +2457,8 @@ provideProjectPageContext({
currentMember,
allMembers,
organization,
+ projectValidation,
+ projectValidationLoading,
// Lazy version loading
versions,
versionsLoading,
@@ -2442,6 +2472,7 @@ provideProjectPageContext({
// Invalidate all project queries (auto-refetches active ones)
invalidate: invalidateProject,
+ refreshProjectValidation,
// Lazy loading
loadVersions,
diff --git a/apps/frontend/src/pages/[type]/[project]/gallery.vue b/apps/frontend/src/pages/[type]/[project]/gallery.vue
index 9f392dcf7d..adb0181e3b 100644
--- a/apps/frontend/src/pages/[type]/[project]/gallery.vue
+++ b/apps/frontend/src/pages/[type]/[project]/gallery.vue
@@ -44,6 +44,11 @@
:maxlength="64"
placeholder="Enter title..."
/>
+
@@ -53,6 +58,11 @@
:maxlength="255"
placeholder="Enter description..."
/>
+
@@ -90,7 +100,7 @@
v-if="editIndex === -1"
type="colored"
color="brand"
- :disabled="shouldPreventActions"
+ :disabled="shouldPreventActions || !canSaveGalleryFields"
@click="createGalleryItem"
>
@@ -100,7 +110,7 @@
v-else
type="colored"
color="brand"
- :disabled="shouldPreventActions"
+ :disabled="shouldPreventActions || !canSaveGalleryFields"
@click="editGalleryItem"
>
@@ -252,6 +262,8 @@ import {
} from '@modrinth/ui'
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'
@@ -304,7 +316,6 @@ const previewImage = ref(null)
// UI state
const shouldPreventActions = ref(false)
-
// 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'
@@ -312,6 +323,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) => ({
@@ -383,6 +409,7 @@ function showPreviewImage() {
// CRUD operations
async function createGalleryItem() {
+ if (!canSaveGalleryFields.value) return
shouldPreventActions.value = true
startLoading()
@@ -403,6 +430,7 @@ async function createGalleryItem() {
}
async function editGalleryItem() {
+ if (!canSaveGalleryFields.value) return
shouldPreventActions.value = true
startLoading()
diff --git a/apps/frontend/src/pages/[type]/[project]/settings.vue b/apps/frontend/src/pages/[type]/[project]/settings.vue
index 19f469effd..9f5c4bafba 100644
--- a/apps/frontend/src/pages/[type]/[project]/settings.vue
+++ b/apps/frontend/src/pages/[type]/[project]/settings.vue
@@ -36,6 +36,9 @@ const {
versions,
currentMember,
setProcessing,
+ projectValidation,
+ projectValidationLoading,
+ refreshProjectValidation,
} = injectProjectPageContext()
const flags = useFeatureFlags()
@@ -166,7 +169,7 @@ const moderatorSeeUserUi = computed({
({
: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"
/>
diff --git a/apps/frontend/src/pages/[type]/[project]/settings/description.vue b/apps/frontend/src/pages/[type]/[project]/settings/description.vue
index 6cd1c44bef..147d7c6ded 100644
--- a/apps/frontend/src/pages/[type]/[project]/settings/description.vue
+++ b/apps/frontend/src/pages/[type]/[project]/settings/description.vue
@@ -17,23 +17,21 @@
-
-
- {{ descriptionWarning }}
-
-
+
@@ -41,22 +39,22 @@