chore: improve moderation ux (#6035)

* feat: save project review queue filters

* reduce unnecessary network calls + prepr

* missed file

* ui tweaks

* add fucked up

* add label + prepr

* prepr

* update legacy badge labels

* globe

* fix margin

* be more reasonable

* pending state

* fix double review, prepr

* small badge text
This commit is contained in:
Prospector
2026-05-08 01:40:28 -07:00
committed by GitHub
parent 758ed818c8
commit 9c99518497
9 changed files with 475 additions and 233 deletions
@@ -24,8 +24,16 @@
</Checkbox>
<div class="input-group push-right">
<ButtonStyled color="orange">
<button :disabled="!submissionConfirmation" @click="resubmit()">
<ScaleIcon aria-hidden="true" />
<button
:disabled="!submissionConfirmation || isLoading"
@click="runBlockingAction('resubmit-modal', resubmit)"
>
<SpinnerIcon
v-if="loadingAction === 'resubmit-modal'"
class="animate-spin"
aria-hidden="true"
/>
<ScaleIcon v-else aria-hidden="true" />
Resubmit for review
</button>
</ButtonStyled>
@@ -54,8 +62,16 @@
</Checkbox>
<div class="input-group push-right">
<ButtonStyled color="brand">
<button :disabled="!replyConfirmation" @click="sendReplyFromModal()">
<ReplyIcon aria-hidden="true" />
<button
:disabled="!replyConfirmation || isLoading"
@click="runBlockingAction('reply-modal', () => sendReplyFromModal())"
>
<SpinnerIcon
v-if="loadingAction === 'reply-modal'"
class="animate-spin"
aria-hidden="true"
/>
<ReplyIcon v-else aria-hidden="true" />
Reply to thread
</button>
</ButtonStyled>
@@ -82,8 +98,9 @@
<template v-if="report && report.closed">
<p>This thread is closed and new messages cannot be sent to it.</p>
<ButtonStyled v-if="isStaff(auth.user)">
<button @click="reopenReport()">
<CheckCircleIcon aria-hidden="true" />
<button :disabled="isLoading" @click="runBlockingAction('reopen', () => reopenReport())">
<SpinnerIcon v-if="loadingAction === 'reopen'" class="animate-spin" aria-hidden="true" />
<CheckCircleIcon v-else aria-hidden="true" />
Reopen thread
</button>
</ButtonStyled>
@@ -100,35 +117,53 @@
<ButtonStyled color="brand">
<button
v-if="sortedMessages.length > 0"
:disabled="!replyBody"
@click="isApproved(project) && !isStaff(auth.user) ? openReplyModal() : sendReply()"
:disabled="!replyBody || isLoading"
@click="
isApproved(project) && !isStaff(auth.user)
? openReplyModal()
: runBlockingAction('reply', () => sendReply())
"
>
<ReplyIcon aria-hidden="true" />
<SpinnerIcon v-if="loadingAction === 'reply'" class="animate-spin" aria-hidden="true" />
<ReplyIcon v-else aria-hidden="true" />
Reply
</button>
<button
v-else
:disabled="!replyBody"
@click="isApproved(project) && !isStaff(auth.user) ? openReplyModal() : sendReply()"
:disabled="!replyBody || isLoading"
@click="
isApproved(project) && !isStaff(auth.user)
? openReplyModal()
: runBlockingAction('send', () => sendReply())
"
>
<SendIcon aria-hidden="true" />
<SpinnerIcon v-if="loadingAction === 'send'" class="animate-spin" aria-hidden="true" />
<SendIcon v-else aria-hidden="true" />
Send
</button>
</ButtonStyled>
<ButtonStyled v-if="isStaff(auth.user)">
<button :disabled="!replyBody" @click="sendReply(null, true)">
<ScaleIcon aria-hidden="true" />
<button
:disabled="!replyBody || isLoading"
@click="runBlockingAction('private-note', () => sendReply(null, true))"
>
<SpinnerIcon
v-if="loadingAction === 'private-note'"
class="animate-spin"
aria-hidden="true"
/>
<ScaleIcon v-else aria-hidden="true" />
Add private note
</button>
</ButtonStyled>
<template v-if="currentMember && !isStaff(auth.user)">
<template v-if="isRejected(project)">
<ButtonStyled color="orange">
<button v-if="replyBody" @click="openResubmitModal(true)">
<button v-if="replyBody" :disabled="isLoading" @click="openResubmitModal(true)">
<ScaleIcon aria-hidden="true" />
Resubmit for review with reply
</button>
<button v-else @click="openResubmitModal(false)">
<button v-else :disabled="isLoading" @click="openResubmitModal(false)">
<ScaleIcon aria-hidden="true" />
Resubmit for review
</button>
@@ -140,12 +175,30 @@
<template v-if="report">
<template v-if="isStaff(auth.user)">
<ButtonStyled color="red">
<button v-if="replyBody" @click="closeReport(true)">
<CheckCircleIcon aria-hidden="true" />
<button
v-if="replyBody"
:disabled="isLoading"
@click="runBlockingAction('close-with-reply', () => closeReport(true))"
>
<SpinnerIcon
v-if="loadingAction === 'close-with-reply'"
class="animate-spin"
aria-hidden="true"
/>
<CheckCircleIcon v-else aria-hidden="true" />
Close with reply
</button>
<button v-else @click="closeReport()">
<CheckCircleIcon aria-hidden="true" />
<button
v-else
:disabled="isLoading"
@click="runBlockingAction('close', () => closeReport())"
>
<SpinnerIcon
v-if="loadingAction === 'close'"
class="animate-spin"
aria-hidden="true"
/>
<CheckCircleIcon v-else aria-hidden="true" />
Close thread
</button>
</ButtonStyled>
@@ -154,92 +207,122 @@
<template v-if="project">
<template v-if="isStaff(auth.user)">
<ButtonStyled v-if="replyBody" color="green">
<button :disabled="isApproved(project)" @click="sendReply(requestedStatus)">
<CheckIcon aria-hidden="true" />
<button
:disabled="isApproved(project) || isLoading"
@click="runBlockingAction('approve-with-reply', () => sendReply(requestedStatus))"
>
<SpinnerIcon
v-if="loadingAction === 'approve-with-reply'"
class="animate-spin"
aria-hidden="true"
/>
<CheckIcon v-else aria-hidden="true" />
Approve with reply
</button>
</ButtonStyled>
<ButtonStyled v-else color="green">
<button :disabled="isApproved(project)" @click="setStatus(requestedStatus)">
<CheckIcon aria-hidden="true" />
<button
:disabled="isApproved(project) || isLoading"
@click="runBlockingAction('approve', () => setStatus(requestedStatus))"
>
<SpinnerIcon
v-if="loadingAction === 'approve'"
class="animate-spin"
aria-hidden="true"
/>
<CheckIcon v-else aria-hidden="true" />
Approve
</button>
</ButtonStyled>
<div class="joined-buttons">
<ButtonStyled v-if="replyBody" color="red">
<button :disabled="project.status === 'rejected'" @click="sendReply('rejected')">
<XIcon aria-hidden="true" />
<button
:disabled="project.status === 'rejected' || isLoading"
@click="runBlockingAction('reject-with-reply', () => sendReply('rejected'))"
>
<SpinnerIcon
v-if="loadingAction === 'reject-with-reply'"
class="animate-spin"
aria-hidden="true"
/>
<XIcon v-else aria-hidden="true" />
Reject with reply
</button>
</ButtonStyled>
<ButtonStyled v-else color="red">
<button :disabled="project.status === 'rejected'" @click="setStatus('rejected')">
<XIcon aria-hidden="true" />
<button
:disabled="project.status === 'rejected' || isLoading"
@click="runBlockingAction('reject', () => setStatus('rejected'))"
>
<SpinnerIcon
v-if="loadingAction === 'reject'"
class="animate-spin"
aria-hidden="true"
/>
<XIcon v-else aria-hidden="true" />
Reject
</button>
</ButtonStyled>
<ButtonStyled color="red">
<OverflowMenu
class="btn-dropdown-animation"
:disabled="isLoading"
:options="
replyBody
? [
{
id: 'withhold-reply',
color: 'danger',
action: () => {
sendReply('withheld')
},
action: () =>
runBlockingAction('withhold-reply', () => sendReply('withheld')),
hoverFilled: true,
disabled: project.status === 'withheld',
disabled: project.status === 'withheld' || isLoading,
},
{
id: 'set-to-draft-reply',
action: () => {
sendReply('draft')
},
action: () =>
runBlockingAction('set-to-draft-reply', () => sendReply('draft')),
hoverFilled: true,
disabled: project.status === 'draft',
disabled: project.status === 'draft' || isLoading,
},
{
id: 'send-to-review-reply',
action: () => {
sendReply('processing', true)
},
action: () =>
runBlockingAction('send-to-review-reply', () =>
sendReply('processing', true),
),
hoverFilled: true,
disabled: project.status === 'processing',
disabled: project.status === 'processing' || isLoading,
},
]
: [
{
id: 'withhold',
color: 'danger',
action: () => {
setStatus('withheld')
},
action: () =>
runBlockingAction('withhold', () => setStatus('withheld')),
hoverFilled: true,
disabled: project.status === 'withheld',
disabled: project.status === 'withheld' || isLoading,
},
{
id: 'set-to-draft',
action: () => {
setStatus('draft')
},
action: () =>
runBlockingAction('set-to-draft', () => setStatus('draft')),
hoverFilled: true,
disabled: project.status === 'draft',
disabled: project.status === 'draft' || isLoading,
},
{
id: 'send-to-review',
action: () => {
setStatus('processing')
},
action: () =>
runBlockingAction('send-to-review', () => setStatus('processing')),
hoverFilled: true,
disabled: project.status === 'processing',
disabled: project.status === 'processing' || isLoading,
},
]
"
>
<DropdownIcon aria-hidden="true" />
<SpinnerIcon v-if="isDropdownLoading" class="animate-spin" aria-hidden="true" />
<DropdownIcon v-else aria-hidden="true" />
<template #withhold-reply>
<EyeOffIcon aria-hidden="true" />
Withhold with reply
@@ -285,6 +368,7 @@ import {
ReplyIcon,
ScaleIcon,
SendIcon,
SpinnerIcon,
XIcon,
} from '@modrinth/assets'
import {
@@ -363,6 +447,30 @@ const sortedMessages = computed(() => {
const modalSubmit = ref(null)
const modalReply = ref(null)
const loadingAction = ref(null)
const isLoading = computed(() => loadingAction.value !== null)
const dropdownActionIds = [
'withhold',
'withhold-reply',
'set-to-draft',
'set-to-draft-reply',
'send-to-review',
'send-to-review-reply',
]
const isDropdownLoading = computed(() => dropdownActionIds.includes(loadingAction.value))
async function runBlockingAction(actionId, action) {
if (loadingAction.value !== null) {
return
}
loadingAction.value = actionId
try {
await action()
} finally {
loadingAction.value = null
}
}
async function updateThreadLocal() {
let threadId = null
if (props.project) {
@@ -390,8 +498,8 @@ async function onUploadImage(file) {
}
async function sendReplyFromModal(status = null, privateMessage = false) {
modalReply.value.hide()
await sendReply(status, privateMessage)
modalReply.value.hide()
}
async function sendReply(status = null, privateMessage = false) {