From 0b7e910299cb7830acb440d84e3d654845f395e1 Mon Sep 17 00:00:00 2001
From: tdgao
Date: Tue, 8 Sep 2026 13:04:32 -0600
Subject: [PATCH 1/5] feat: allow user to withdraw project from review, setting
it back to draft status
---
.../ui/moderation/ModerationProjectNags.vue | 86 +++++++++++++++++--
.../components/ui/thread/ThreadMessage.vue | 15 ++++
apps/frontend/src/pages/[type]/[project].vue | 30 ++++++-
.../src/pages/[type]/[project]/settings.vue | 2 +
apps/labrinth/src/routes/v3/projects/mod.rs | 2 +
packages/ui/src/providers/project-page.ts | 1 +
6 files changed, 126 insertions(+), 10 deletions(-)
diff --git a/apps/frontend/src/components/ui/moderation/ModerationProjectNags.vue b/apps/frontend/src/components/ui/moderation/ModerationProjectNags.vue
index daa416ad0ad..a93decec313 100644
--- a/apps/frontend/src/components/ui/moderation/ModerationProjectNags.vue
+++ b/apps/frontend/src/components/ui/moderation/ModerationProjectNags.vue
@@ -77,12 +77,30 @@
{{ getFormattedMessage(nag.title) }}
-
+
+
+
+
+
+
+
+
+
+
Promise
refreshValidation?: () => Promise
currentMember?: Labrinth.Projects.v3.TeamMember | null
collapsed?: boolean
@@ -186,6 +208,23 @@ const messages = defineMessages({
id: 'project-moderation-nags.submit-for-review-button',
defaultMessage: 'Submit for review',
},
+ submittedForReview: {
+ id: 'project-moderation-nags.submitted-for-review',
+ defaultMessage: 'Pending moderator review',
+ },
+ submittedForReviewDesc: {
+ id: 'project-moderation-nags.submitted-for-review-desc',
+ defaultMessage: 'Your project has been submitted for moderator review.',
+ },
+ submittedForReviewWithWithdrawal: {
+ id: 'project-moderation-nags.submitted-for-review-with-withdrawal',
+ defaultMessage:
+ 'Your project has been submitted for review. If your project is not ready for review, you can withdraw your submission.',
+ },
+ submissionWithdrawn: {
+ id: 'project-moderation-nags.submission-withdrawn',
+ defaultMessage: 'Your submission has been withdrawn. ',
+ },
resubmitForReview: {
id: 'project-moderation-nags.resubmit-for-review',
defaultMessage: 'Resubmit for review',
@@ -381,6 +420,31 @@ const canSubmitForReview = computed(() => {
)
})
+const withdrawingSubmission = ref(false)
+const canWithdrawSubmission = computed(
+ () =>
+ !!props.withdrawSubmission &&
+ (isStaff(props.currentMember?.user) ||
+ ((props.currentMember?.permissions ?? 0) & (1 << 2)) !== 0),
+)
+
+async function withdrawSubmission() {
+ if (!isProcessing.value || !canWithdrawSubmission.value || withdrawingSubmission.value) return
+
+ withdrawingSubmission.value = true
+ try {
+ if (!(await props.withdrawSubmission?.())) return
+ if (props.collapsed) emit('toggleCollapsed')
+ addNotification({
+ type: 'success',
+ title: formatMessage(commonMessages.successLabel),
+ text: formatMessage(messages.submissionWithdrawn),
+ })
+ } finally {
+ withdrawingSubmission.value = false
+ }
+}
+
async function submitForReview() {
if (!canSubmitForReview.value) return
const validation = await props.refreshValidation?.()
@@ -420,6 +484,16 @@ function isNagComplete(nag: Nag): boolean {
const visibleNags = computed(() => {
const finalNags = applicableNags.value.filter((nag) => !isNagComplete(nag))
+ if (isProcessing.value) {
+ finalNags.push({
+ id: 'submitted-for-review',
+ title: messages.submittedForReview,
+ description: messages.submittedForReviewDesc,
+ status: 'special-submit-action',
+ shouldShow: (ctx) => ctx.project.status === 'processing',
+ })
+ }
+
if (props.project.status === 'draft') {
finalNags.push({
id: 'submit-for-review',
diff --git a/apps/frontend/src/components/ui/thread/ThreadMessage.vue b/apps/frontend/src/components/ui/thread/ThreadMessage.vue
index 08dd9399b96..8c41b459c2b 100644
--- a/apps/frontend/src/components/ui/thread/ThreadMessage.vue
+++ b/apps/frontend/src/components/ui/thread/ThreadMessage.vue
@@ -96,6 +96,11 @@
submitted the project for review.
+
+ {{ formatMessage(messages.withdrewFromReview) }}
+
reviewed the project and set its status to .
@@ -166,14 +171,24 @@ import {
AutoLink,
Avatar,
Badge,
+ defineMessages,
TeleportOverflowMenu,
useFormatDateTime,
useRelativeTime,
+ useVIntl,
} from '@modrinth/ui'
import { renderString } from '@modrinth/utils'
import { isStaff } from '~/helpers/users.js'
+const { formatMessage } = useVIntl()
+const messages = defineMessages({
+ withdrewFromReview: {
+ id: 'thread-message.withdrew-from-review',
+ defaultMessage: 'withdrew the project from review',
+ },
+})
+
const props = defineProps({
message: {
type: Object,
diff --git a/apps/frontend/src/pages/[type]/[project].vue b/apps/frontend/src/pages/[type]/[project].vue
index 948e98b6f11..f65fd8d95e6 100644
--- a/apps/frontend/src/pages/[type]/[project].vue
+++ b/apps/frontend/src/pages/[type]/[project].vue
@@ -159,6 +159,7 @@
:validation-loading="projectValidationLoading"
:validation-available="projectValidation !== null"
:refresh-validation="refreshProjectValidation"
+ :withdraw-submission="withdrawSubmission"
@toggle-collapsed="() => (collapsedChecklist = !collapsedChecklist)"
@set-processing="setProcessing"
/>
@@ -1463,10 +1464,12 @@ const patchStatusMutation = useMutation({
const previousProject = queryClient.getQueryData(['project', 'v2', projectId])
- queryClient.setQueryData(['project', 'v2', projectId], (old) => {
- if (!old) return old
- return { ...old, status }
- })
+ if (status !== 'draft') {
+ queryClient.setQueryData(['project', 'v2', projectId], (old) => {
+ if (!old) return old
+ return { ...old, status }
+ })
+ }
return { previousProject, projectId }
},
@@ -2161,6 +2164,24 @@ watch(
{ immediate: true },
)
+async function withdrawSubmission() {
+ if (patchStatusMutation.isPending.value || project.value.status !== 'processing') return false
+
+ startLoading()
+ try {
+ await patchStatusMutation.mutateAsync({
+ projectId: project.value.id,
+ status: 'draft',
+ threadId: project.value.thread_id,
+ })
+ return true
+ } catch {
+ return false
+ } finally {
+ stopLoading()
+ }
+}
+
async function setProcessing() {
// Guard against multiple submissions while mutation is pending
if (patchStatusMutation.isPending.value) return
@@ -2493,6 +2514,7 @@ provideProjectPageContext({
patchProjectV3,
patchIcon,
setProcessing,
+ withdrawSubmission,
// Gallery mutation functions
createGalleryItem,
diff --git a/apps/frontend/src/pages/[type]/[project]/settings.vue b/apps/frontend/src/pages/[type]/[project]/settings.vue
index 9f5c4bafba3..69cbf563ce9 100644
--- a/apps/frontend/src/pages/[type]/[project]/settings.vue
+++ b/apps/frontend/src/pages/[type]/[project]/settings.vue
@@ -36,6 +36,7 @@ const {
versions,
currentMember,
setProcessing,
+ withdrawSubmission,
projectValidation,
projectValidationLoading,
refreshProjectValidation,
@@ -183,6 +184,7 @@ const moderatorSeeUserUi = computed({
:validation-loading="projectValidationLoading"
:validation-available="projectValidation !== null"
:refresh-validation="refreshProjectValidation"
+ :withdraw-submission="withdrawSubmission"
@toggle-collapsed="() => (collapsedChecklist = !collapsedChecklist)"
@set-processing="setProcessing"
/>
diff --git a/apps/labrinth/src/routes/v3/projects/mod.rs b/apps/labrinth/src/routes/v3/projects/mod.rs
index 5acae44de2e..aaf6f240051 100644
--- a/apps/labrinth/src/routes/v3/projects/mod.rs
+++ b/apps/labrinth/src/routes/v3/projects/mod.rs
@@ -613,6 +613,8 @@ pub async fn project_edit_internal(
if !(user.role.is_mod()
|| !project_item.inner.status.is_approved()
&& status == &ProjectStatus::Processing
+ || project_item.inner.status == ProjectStatus::Processing
+ && status == &ProjectStatus::Draft
|| project_item.inner.status.is_approved()
&& status.can_be_requested())
{
diff --git a/packages/ui/src/providers/project-page.ts b/packages/ui/src/providers/project-page.ts
index 7ca4d5086aa..db311e911d9 100644
--- a/packages/ui/src/providers/project-page.ts
+++ b/packages/ui/src/providers/project-page.ts
@@ -41,6 +41,7 @@ export interface ProjectPageContext {
patchProjectV3: (data: Record, quiet?: boolean) => Promise
patchIcon: (icon: File) => Promise
setProcessing: () => Promise
+ withdrawSubmission: () => Promise
createGalleryItem: (
file: File,
title?: string,
From 798141489b9db493cc76ac5637805ea3d04ecb5a Mon Sep 17 00:00:00 2001
From: tdgao
Date: Tue, 8 Sep 2026 13:06:49 -0600
Subject: [PATCH 2/5] prepr
---
.../ui/moderation/ModerationProjectNags.vue | 9 ++++++---
.../src/components/ui/thread/ThreadMessage.vue | 4 +++-
apps/frontend/src/locales/en-US/index.json | 15 +++++++++++++++
3 files changed, 24 insertions(+), 4 deletions(-)
diff --git a/apps/frontend/src/components/ui/moderation/ModerationProjectNags.vue b/apps/frontend/src/components/ui/moderation/ModerationProjectNags.vue
index a93decec313..8571b1a8183 100644
--- a/apps/frontend/src/components/ui/moderation/ModerationProjectNags.vue
+++ b/apps/frontend/src/components/ui/moderation/ModerationProjectNags.vue
@@ -87,7 +87,7 @@
class="text-link"
:class="{ 'cursor-wait opacity-50': withdrawingSubmission }"
:aria-disabled="withdrawingSubmission"
- @click.prevent="withdrawSubmission"
+ @click.prevent="handleWithdrawSubmission"
>
@@ -158,8 +158,8 @@ import {
defineMessages,
injectNotificationManager,
IntlFormatted,
- normalizeChildren,
type MessageDescriptor,
+ normalizeChildren,
useVIntl,
} from '@modrinth/ui'
import { isStaff } from '@modrinth/utils'
@@ -272,6 +272,9 @@ const props = withDefaults(defineProps(), {
validationNags: () => [],
validationLoading: false,
validationAvailable: true,
+ nags: undefined,
+ withdrawSubmission: undefined,
+ refreshValidation: undefined,
})
const emit = defineEmits<{
@@ -428,7 +431,7 @@ const canWithdrawSubmission = computed(
((props.currentMember?.permissions ?? 0) & (1 << 2)) !== 0),
)
-async function withdrawSubmission() {
+async function handleWithdrawSubmission() {
if (!isProcessing.value || !canWithdrawSubmission.value || withdrawingSubmission.value) return
withdrawingSubmission.value = true
diff --git a/apps/frontend/src/components/ui/thread/ThreadMessage.vue b/apps/frontend/src/components/ui/thread/ThreadMessage.vue
index 8c41b459c2b..7639ee8b9e9 100644
--- a/apps/frontend/src/components/ui/thread/ThreadMessage.vue
+++ b/apps/frontend/src/components/ui/thread/ThreadMessage.vue
@@ -97,7 +97,9 @@
submitted the project for review.
{{ formatMessage(messages.withdrewFromReview) }}
diff --git a/apps/frontend/src/locales/en-US/index.json b/apps/frontend/src/locales/en-US/index.json
index 5b3aca399d7..941c5702f08 100644
--- a/apps/frontend/src/locales/en-US/index.json
+++ b/apps/frontend/src/locales/en-US/index.json
@@ -3446,6 +3446,9 @@
"project-moderation-nags.resubmit-for-review-desc": {
"message": "Your project has been {status, select, rejected {rejected} withheld {withheld} other {{status}}} by Modrinth's staff. In most cases, you can resubmit for review after addressing the staff's message."
},
+ "project-moderation-nags.submission-withdrawn": {
+ "message": "Your submission has been withdrawn. "
+ },
"project-moderation-nags.submit-checklist-tooltip": {
"message": "You must complete the required steps in the publishing checklist!"
},
@@ -3458,6 +3461,15 @@
"project-moderation-nags.submit-for-review-desc": {
"message": "Your project is only viewable by members of the project. It must be reviewed by moderators in order to be published."
},
+ "project-moderation-nags.submitted-for-review": {
+ "message": "Pending moderator review"
+ },
+ "project-moderation-nags.submitted-for-review-desc": {
+ "message": "Your project has been submitted for moderator review."
+ },
+ "project-moderation-nags.submitted-for-review-with-withdrawal": {
+ "message": "Your project has been submitted for review. If your project is not ready for review, you can withdraw your submission."
+ },
"project-moderation-nags.suggestion": {
"message": "Suggestion"
},
@@ -5489,6 +5501,9 @@
"shared-instance.invite.unavailable.title": {
"message": "This invite isn't available"
},
+ "thread-message.withdrew-from-review": {
+ "message": "withdrew the project from review"
+ },
"ui.latest-news-row.latest-news": {
"message": "Latest news from Modrinth"
},
From 2d167d2cc206a0d320ee8b22bd96ea3f77c8abc0 Mon Sep 17 00:00:00 2001
From: tdgao
Date: Wed, 9 Sep 2026 10:40:21 -0600
Subject: [PATCH 3/5] feat: move withdraw from review button
---
.../ui/moderation/ModerationProjectNags.vue | 76 ++++---------------
apps/frontend/src/pages/[type]/[project].vue | 1 -
.../src/pages/[type]/[project]/moderation.vue | 67 +++++++++++++++-
3 files changed, 79 insertions(+), 65 deletions(-)
diff --git a/apps/frontend/src/components/ui/moderation/ModerationProjectNags.vue b/apps/frontend/src/components/ui/moderation/ModerationProjectNags.vue
index 8571b1a8183..6156d4be906 100644
--- a/apps/frontend/src/components/ui/moderation/ModerationProjectNags.vue
+++ b/apps/frontend/src/components/ui/moderation/ModerationProjectNags.vue
@@ -77,30 +77,12 @@
{{ getFormattedMessage(nag.title) }}
-
-
-
-
-
-
-
-
-
-
+
Promise
refreshValidation?: () => Promise
currentMember?: Labrinth.Projects.v3.TeamMember | null
collapsed?: boolean
@@ -214,16 +192,12 @@ const messages = defineMessages({
},
submittedForReviewDesc: {
id: 'project-moderation-nags.submitted-for-review-desc',
- defaultMessage: 'Your project has been submitted for moderator review.',
- },
- submittedForReviewWithWithdrawal: {
- id: 'project-moderation-nags.submitted-for-review-with-withdrawal',
defaultMessage:
- 'Your project has been submitted for review. If your project is not ready for review, you can withdraw your submission.',
+ "Your project has been submitted to be reviewed by Modrinth's moderation team.",
},
- submissionWithdrawn: {
- id: 'project-moderation-nags.submission-withdrawn',
- defaultMessage: 'Your submission has been withdrawn. ',
+ visitModerationMessages: {
+ id: 'project-moderation-nags.visit-moderation-messages',
+ defaultMessage: 'Visit moderation messages',
},
resubmitForReview: {
id: 'project-moderation-nags.resubmit-for-review',
@@ -273,7 +247,6 @@ const props = withDefaults(defineProps(), {
validationLoading: false,
validationAvailable: true,
nags: undefined,
- withdrawSubmission: undefined,
refreshValidation: undefined,
})
@@ -423,31 +396,6 @@ const canSubmitForReview = computed(() => {
)
})
-const withdrawingSubmission = ref(false)
-const canWithdrawSubmission = computed(
- () =>
- !!props.withdrawSubmission &&
- (isStaff(props.currentMember?.user) ||
- ((props.currentMember?.permissions ?? 0) & (1 << 2)) !== 0),
-)
-
-async function handleWithdrawSubmission() {
- if (!isProcessing.value || !canWithdrawSubmission.value || withdrawingSubmission.value) return
-
- withdrawingSubmission.value = true
- try {
- if (!(await props.withdrawSubmission?.())) return
- if (props.collapsed) emit('toggleCollapsed')
- addNotification({
- type: 'success',
- title: formatMessage(commonMessages.successLabel),
- text: formatMessage(messages.submissionWithdrawn),
- })
- } finally {
- withdrawingSubmission.value = false
- }
-}
-
async function submitForReview() {
if (!canSubmitForReview.value) return
const validation = await props.refreshValidation?.()
@@ -494,6 +442,10 @@ const visibleNags = computed(() => {
description: messages.submittedForReviewDesc,
status: 'special-submit-action',
shouldShow: (ctx) => ctx.project.status === 'processing',
+ link: {
+ ...nagDestinations.moderation,
+ title: messages.visitModerationMessages,
+ },
})
}
diff --git a/apps/frontend/src/pages/[type]/[project].vue b/apps/frontend/src/pages/[type]/[project].vue
index f65fd8d95e6..9e4a93be10a 100644
--- a/apps/frontend/src/pages/[type]/[project].vue
+++ b/apps/frontend/src/pages/[type]/[project].vue
@@ -159,7 +159,6 @@
:validation-loading="projectValidationLoading"
:validation-available="projectValidation !== null"
:refresh-validation="refreshProjectValidation"
- :withdraw-submission="withdrawSubmission"
@toggle-collapsed="() => (collapsedChecklist = !collapsedChecklist)"
@set-processing="setProcessing"
/>
diff --git a/apps/frontend/src/pages/[type]/[project]/moderation.vue b/apps/frontend/src/pages/[type]/[project]/moderation.vue
index ed80d87a333..33e410a7d9c 100644
--- a/apps/frontend/src/pages/[type]/[project]/moderation.vue
+++ b/apps/frontend/src/pages/[type]/[project]/moderation.vue
@@ -1,5 +1,14 @@
+
+