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

View File

@@ -1,12 +1,19 @@
<template>
<div class="shadow-card rounded-2xl border border-surface-5 bg-surface-3 p-4">
<div class="shadow-card rounded-2xl border border-solid border-surface-5 bg-surface-3 p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<Avatar
:src="queueEntry.project.icon_url"
size="4rem"
class="rounded-2xl border border-surface-5 bg-surface-4 !shadow-none"
/>
<NuxtLink
:to="`/project/${queueEntry.project.slug}`"
target="_blank"
tabindex="-1"
class="flex"
>
<Avatar
:src="queueEntry.project.icon_url"
size="4rem"
class="rounded-2xl border border-surface-5 bg-surface-4 !shadow-none"
/>
</NuxtLink>
<div class="flex flex-col gap-1.5">
<div class="flex items-center gap-2">
<NuxtLink
@@ -22,11 +29,15 @@
<component
:is="getProjectTypeIcon(queueEntry.project.project_types[0] as any)"
aria-hidden="true"
class="h-4 w-4"
class="size-4"
/>
<span class="text-sm font-medium text-secondary">
{{
queueEntry.project.project_types.map((t) => formatProjectType(t, true)).join(', ')
queueEntry.project.project_types.length === 0
? '???'
: queueEntry.project.project_types
.map((t) => formatProjectType(t, true))
.join(', ')
}}
</span>
</div>
@@ -35,37 +46,39 @@
class="flex items-center gap-2 rounded-full border border-solid border-surface-5 bg-surface-4 px-2.5 py-1"
>
<span class="text-sm text-secondary">Requesting</span>
<Badge :type="queueEntry.project.requested_status" class="status" />
<Badge :type="queueEntry.project.requested_status" class="text-sm" />
</div>
</div>
<div v-if="queueEntry.owner" class="flex items-center gap-1">
<Avatar
:src="queueEntry.owner.user.avatar_url"
size="1.5rem"
circle
class="border border-surface-5 bg-surface-4 !shadow-none"
/>
<div v-if="queueEntry.ownership?.kind === 'user'">
<NuxtLink
:to="`/user/${queueEntry.owner.user.username}`"
:to="`/user/${queueEntry.ownership.id}`"
target="_blank"
class="text-sm font-medium text-secondary hover:underline"
class="flex w-fit min-w-40 items-center gap-1 text-sm font-medium text-secondary hover:underline"
>
{{ queueEntry.owner.user.username }}
<Avatar
:src="queueEntry.ownership.icon_url"
size="1.5rem"
circle
class="border border-surface-5 bg-surface-4 !shadow-none"
/>
{{ queueEntry.ownership.name }}
</NuxtLink>
</div>
<div v-else-if="queueEntry.org" class="flex items-center gap-1">
<div
v-else-if="queueEntry.ownership?.kind === 'organization'"
class="flex items-center gap-1"
>
<Avatar
:src="queueEntry.org.icon_url"
:src="queueEntry.ownership.icon_url"
size="1.5rem"
circle
class="border border-surface-5 bg-surface-4 !shadow-none"
/>
<NuxtLink
:to="`/organization/${queueEntry.org.slug}`"
:to="`/organization/${queueEntry.ownership.id}`"
target="_blank"
class="text-sm font-medium text-secondary hover:underline"
>
{{ queueEntry.org.name }}
{{ queueEntry.ownership.name }}
</NuxtLink>
</div>
</div>
@@ -84,25 +97,20 @@
</span>
<div class="flex items-center gap-2">
<ButtonStyled circular color="orange">
<button @click="openProjectForReview">
<ScaleIcon class="size-5" />
<ButtonStyled circular>
<button v-tooltip="'Copy ID'" @click="copyId">
<ClipboardCopyIcon />
</button>
</ButtonStyled>
<ButtonStyled circular>
<OverflowMenu :options="quickActions" :dropdown-id="`${baseId}-quick-actions`">
<template #default>
<EllipsisVerticalIcon class="size-4" />
</template>
<template #copy-id>
<ClipboardCopyIcon />
<span class="hidden sm:inline">Copy ID</span>
</template>
<template #copy-link>
<LinkIcon />
<span class="hidden sm:inline">Copy link</span>
</template>
</OverflowMenu>
<button v-tooltip="'Copy link'" @click="copyLink">
<LinkIcon />
</button>
</ButtonStyled>
<ButtonStyled v-tooltip="'Begin review'" circular color="orange">
<button @click="openProjectForReview">
<ScaleIcon />
</button>
</ButtonStyled>
</div>
</div>
@@ -111,15 +119,13 @@
</template>
<script setup lang="ts">
import { ClipboardCopyIcon, EllipsisVerticalIcon, LinkIcon, ScaleIcon } from '@modrinth/assets'
import { ClipboardCopyIcon, LinkIcon, ScaleIcon } from '@modrinth/assets'
import {
Avatar,
Badge,
ButtonStyled,
getProjectTypeIcon,
injectNotificationManager,
OverflowMenu,
type OverflowMenuOption,
useFormatDateTime,
useRelativeTime,
} from '@modrinth/ui'
@@ -143,8 +149,6 @@ const formatDateTimeFull = useFormatDateTime({
timeZone: 'UTC',
})
const baseId = useId()
const props = defineProps<{
queueEntry: ModerationProject
}>()
@@ -185,34 +189,27 @@ const formattedDate = computed(() => {
}
})
const quickActions: OverflowMenuOption[] = [
{
id: 'copy-link',
action: () => {
const base = window.location.origin
const projectUrl = `${base}/project/${props.queueEntry.project.slug}`
navigator.clipboard.writeText(projectUrl).then(() => {
addNotification({
type: 'success',
title: 'Project link copied',
text: 'The link to this project has been copied to your clipboard.',
})
})
},
},
{
id: 'copy-id',
action: () => {
navigator.clipboard.writeText(props.queueEntry.project.id).then(() => {
addNotification({
type: 'success',
title: 'Project ID copied',
text: 'The ID of this project has been copied to your clipboard.',
})
})
},
},
]
function copyLink() {
const base = window.location.origin
const projectUrl = `${base}/project/${props.queueEntry.project.slug}`
navigator.clipboard.writeText(projectUrl).then(() => {
addNotification({
type: 'success',
title: 'Project link copied',
text: 'The link to this project has been copied to your clipboard.',
})
})
}
function copyId() {
navigator.clipboard.writeText(props.queueEntry.project.id).then(() => {
addNotification({
type: 'success',
title: 'Project ID copied',
text: 'The ID of this project has been copied to your clipboard.',
})
})
}
function openProjectForReview() {
emit('startFromProject', props.queueEntry.project.id)

View File

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