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 @@