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,7 +1,7 @@
<script setup>
import { WrenchIcon,XIcon } from '@modrinth/assets'
import { WrenchIcon, XIcon } from '@modrinth/assets'
import {
Accordion,
Accordion,
ButtonStyled,
Checkbox,
commonMessages,
@@ -44,7 +44,10 @@ const messages = defineMessages({
defaultMessage: 'Configure which files are included in this export',
},
exportButton: { id: 'app.export-modal.export-button', defaultMessage: 'Export' },
includeFile: { id: 'app.export-modal.include-file-accessibility-label', defaultMessage: 'Include "{file}"?' },
includeFile: {
id: 'app.export-modal.include-file-accessibility-label',
defaultMessage: 'Include "{file}"?',
},
})
const props = defineProps({
@@ -184,7 +187,10 @@ const exportPack = async () => {
:placeholder="formatMessage(messages.descriptionPlaceholder)"
/>
</div>
<Accordion class="w-full bg-surface-4 border border-solid border-surface-5 rounded-2xl overflow-clip" button-class="p-4 w-full border-b border-solid border-b-surface-5 bg-surface-2 -mb-px hover:brightness-[--hover-brightness] group">
<Accordion
class="w-full bg-surface-4 border border-solid border-surface-5 rounded-2xl overflow-clip"
button-class="p-4 w-full border-b border-solid border-b-surface-5 bg-surface-2 -mb-px hover:brightness-[--hover-brightness] group"
>
<template #title>
<span class="flex items-center gap-3 text-contrast group-active:scale-[0.98]">
<WrenchIcon aria-hidden="true" class="size-5 text-secondary" />
@@ -193,11 +199,17 @@ const exportPack = async () => {
</template>
<div class="flex flex-col [&>*:nth-child(even)]:bg-surface-3">
<div v-for="[path, children] in folders" :key="path.name" class="flex flex-col">
<Accordion class="flex flex-col" button-class="flex gap-3 pr-4 hover:bg-surface-5 group">
<Accordion
class="flex flex-col"
button-class="flex gap-3 pr-4 hover:bg-surface-5 group"
>
<template #title>
<Checkbox
:model-value="children.every((child) => child.selected)"
:indeterminate="!children.every((child) => child.selected) && children.some((child) => child.selected)"
:indeterminate="
!children.every((child) => child.selected) &&
children.some((child) => child.selected)
"
:description="formatMessage(messages.includeFile, { file: path.name })"
class="pl-4 py-2"
:disabled="children.every((x) => x.disabled)"
@@ -219,7 +231,8 @@ const exportPack = async () => {
</Accordion>
</div>
<Checkbox
v-for="file in files" :key="file.path"
v-for="file in files"
:key="file.path"
v-model="file.selected"
:label="file.name"
:disabled="file.disabled"

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

View File

@@ -181,56 +181,35 @@ export async function enrichReportBatch(reports: Report[]): Promise<ExtendedRepo
}
// Doesn't need to be in @modrinth/moderation because it is specific to the frontend.
export interface ModerationOwnershipUser {
kind: 'user'
id: string
name: string
icon_url: string | null
}
export interface ModerationOwnershipOrganization {
kind: 'organization'
id: string
name: string
icon_url: string | null
}
export type ModerationOwnership = ModerationOwnershipUser | ModerationOwnershipOrganization
export interface ProjectWithOwnership {
ownership: ModerationOwnership
[key: string]: any
}
export interface ModerationProject {
project: any
owner: TeamMember | null
org: Organization | null
ownership: ModerationOwnership | null
}
export async function enrichProjectBatch(projects: any[]): Promise<ModerationProject[]> {
const teamIds = [...new Set(projects.map((p) => p.team_id).filter(Boolean))]
const orgIds = [...new Set(projects.map((p) => p.organization).filter(Boolean))]
const [teamsData, orgsData]: [TeamMember[][], Organization[]] = await Promise.all([
teamIds.length > 0
? fetchSegmented(teamIds, (ids) => `teams?ids=${asEncodedJsonArray(ids)}`)
: Promise.resolve([]),
orgIds.length > 0
? fetchSegmented(orgIds, (ids) => `organizations?ids=${asEncodedJsonArray(ids)}`, {
apiVersion: 3,
})
: Promise.resolve([]),
])
const cache = useModerationCache()
teamsData.forEach((team) => {
if (team.length > 0) cache.teams.value.set(team[0].team_id, team)
})
orgsData.forEach((org: Organization) => {
cache.orgs.value.set(org.id, org)
})
return projects.map((project) => {
let owner: TeamMember | null = null
let org: Organization | null = null
if (project.team_id) {
const teamMembers = cache.teams.value.get(project.team_id)
if (teamMembers) {
owner = teamMembers.find((member) => member.role === 'Owner') || null
}
}
if (project.organization) {
org = cache.orgs.value.get(project.organization) || null
}
return {
project,
owner,
org,
} as ModerationProject
})
export function toModerationProjects(projects: ProjectWithOwnership[]): ModerationProject[] {
return projects.map(({ ownership, ...project }) => ({
project,
ownership: ownership ?? null,
}))
}

View File

@@ -17,10 +17,10 @@ import {
BrowsePageLayout,
BrowseSidebar,
CreationFlowModal,
PROJECT_DEP_MARKER_QUERY,
defineMessages,
injectModrinthClient,
injectNotificationManager,
PROJECT_DEP_MARKER_QUERY,
provideBrowseManager,
useBrowseSearch,
useDebugLogger,

View File

@@ -8,17 +8,12 @@
autocomplete="off"
:placeholder="formatMessage(commonMessages.searchPlaceholder)"
clearable
wrapper-class="flex-1 lg:max-w-52"
input-class="h-[40px]"
wrapper-class="flex-1"
input-class="h-[40px] w-full"
@input="goToPage(1)"
/>
<div v-if="totalPages > 1" class="hidden flex-1 justify-center lg:flex">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
<ConfettiExplosion v-if="visible" />
</div>
<div class="flex flex-col justify-end gap-2 sm:flex-row lg:flex-shrink-0">
<div class="flex flex-col flex-wrap justify-end gap-2 sm:flex-row lg:flex-shrink-0">
<div class="flex flex-col gap-2 sm:flex-row">
<Combobox
v-model="currentFilterType"
@@ -55,6 +50,20 @@
</span>
</template>
</Combobox>
<Combobox
v-model="itemsPerPage"
class="!w-full flex-grow sm:!w-[160px] sm:flex-grow-0 lg:!w-[140px]"
:options="itemsPerPageOptions"
placeholder="Items per page"
@select="goToPage(1)"
>
<template #selected>
<span class="flex flex-row gap-2 align-middle font-semibold">
<span class="truncate text-contrast">{{ itemsPerPage }} items</span>
</span>
</template>
</Combobox>
</div>
<ButtonStyled color="orange">
@@ -71,25 +80,44 @@
</div>
</div>
<div v-if="totalPages > 1" class="flex justify-center lg:hidden">
<div v-if="totalPages > 1" class="flex items-center justify-between">
<div>
Showing {{ itemsPerPage * (currentPage - 1) + 1 }}{{
itemsPerPage * (currentPage - 1) + Math.min(itemsPerPage, paginatedProjects.length)
}}
of {{ filteredProjects.length }}
{{
currentFilterType === DEFAULT_FILTER_TYPE ? 'projects' : currentFilterType.toLowerCase()
}}
</div>
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
<ConfettiExplosion v-if="visible" />
</div>
<div class="flex flex-col gap-4">
<div v-if="paginatedProjects.length === 0" class="universal-card h-24 animate-pulse"></div>
<div class="flex flex-col gap-3">
<template v-if="pending">
<div
v-for="i in 3"
:key="`loading-skeleton-${i}`"
class="flex h-[98px] w-full animate-pulse rounded-2xl bg-surface-3"
></div>
</template>
<EmptyState
v-else-if="paginatedProjects.length === 0"
:type="!!query ? 'no-search-result' : 'no-tasks'"
:heading="emptyStateHeading"
:description="emptyStateDescription"
/>
<ModerationQueueCard
v-for="item in paginatedProjects"
v-else
:key="item.project.id"
:queue-entry="item"
:owner="item.owner"
:org="item.org"
@start-from-project="startFromProject"
/>
</div>
<div v-if="totalPages > 1" class="mt-4 flex justify-center">
<div v-if="totalPages > 1" class="flex justify-end">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
</div>
</div>
@@ -102,6 +130,7 @@ import {
type ComboboxOption,
commonMessages,
defineMessages,
EmptyState,
injectNotificationManager,
Pagination,
StyledInput,
@@ -111,7 +140,11 @@ import Fuse from 'fuse.js'
import ConfettiExplosion from 'vue-confetti-explosion'
import ModerationQueueCard from '~/components/ui/moderation/ModerationQueueCard.vue'
import { enrichProjectBatch, type ModerationProject } from '~/helpers/moderation.ts'
import {
type ModerationProject,
type ProjectWithOwnership,
toModerationProjects,
} from '~/helpers/moderation.ts'
import { useModerationQueue } from '~/services/moderation-queue.ts'
useHead({ title: 'Projects queue - Modrinth' })
@@ -141,39 +174,26 @@ const messages = defineMessages({
},
})
const { data: allProjects } = await useLazyAsyncData('moderation-projects', async () => {
const { data: allProjects, pending } = await useLazyAsyncData('moderation-projects', async () => {
const startTime = performance.now()
let currentOffset = 0
const PROJECT_ENDPOINT_COUNT = 350
const allProjects: ModerationProject[] = []
const enrichmentPromises: Promise<ModerationProject[]>[] = []
let projects: any[] = []
let projects: ProjectWithOwnership[] = []
do {
projects = (await useBaseFetch(
`moderation/projects?count=${PROJECT_ENDPOINT_COUNT}&offset=${currentOffset}`,
{ internal: true },
)) as any[]
)) as ProjectWithOwnership[]
if (projects.length === 0) break
const enrichmentPromise = enrichProjectBatch(projects)
enrichmentPromises.push(enrichmentPromise)
allProjects.push(...toModerationProjects(projects))
currentOffset += projects.length
if (enrichmentPromises.length >= 3) {
const completed = await Promise.all(enrichmentPromises.splice(0, 2))
allProjects.push(...completed.flat())
}
} while (projects.length === PROJECT_ENDPOINT_COUNT)
const remainingBatches = await Promise.all(enrichmentPromises)
allProjects.push(...remainingBatches.flat())
const endTime = performance.now()
const duration = endTime - startTime
const duration = performance.now() - startTime
console.debug(
`Projects fetched and processed in ${duration.toFixed(2)}ms (${(duration / 1000).toFixed(2)}s)`,
@@ -212,7 +232,6 @@ watch(
},
)
const currentFilterType = ref('All projects')
const filterTypes: ComboboxOption<string>[] = [
{ value: 'All projects', label: 'All projects' },
{ value: 'Modpacks', label: 'Modpacks' },
@@ -222,17 +241,119 @@ const filterTypes: ComboboxOption<string>[] = [
{ value: 'Plugins', label: 'Plugins' },
{ value: 'Shaders', label: 'Shaders' },
{ value: 'Servers', label: 'Servers' },
{ value: 'Fucked up', label: 'Fucked up' },
]
const filterTypeValues = filterTypes.map((option) => option.value)
const DEFAULT_FILTER_TYPE = filterTypeValues[0]
const currentSortType = ref('Oldest')
const sortTypes: ComboboxOption<string>[] = [
{ value: 'Oldest', label: 'Oldest' },
{ value: 'Newest', label: 'Newest' },
]
const sortTypeValues = sortTypes.map((option) => option.value)
const DEFAULT_SORT_TYPE = sortTypeValues[0]
const itemsPerPageOptions: ComboboxOption<number>[] = [
{ value: 20, label: '20' },
{ value: 40, label: '40' },
{ value: 60, label: '60' },
{ value: 80, label: '80' },
{ value: 100, label: '100' },
{ value: 200, label: '200' },
]
const itemsPerPageValues = itemsPerPageOptions.map((option) => option.value)
const DEFAULT_ITEMS_PER_PAGE = 40
function parseFilterTypeFromQuery(value: LocationQueryValue | LocationQueryValue[]): string {
const query = queryAsStringOrEmpty(value)
return filterTypeValues.includes(query) ? query : DEFAULT_FILTER_TYPE
}
function parseSortTypeFromQuery(value: LocationQueryValue | LocationQueryValue[]): string {
const query = queryAsStringOrEmpty(value)
return sortTypeValues.includes(query) ? query : DEFAULT_SORT_TYPE
}
const currentFilterType = ref(parseFilterTypeFromQuery(route.query.filter))
const currentSortType = ref(parseSortTypeFromQuery(route.query.sort))
watch(
currentFilterType,
(newFilter) => {
const currentQuery = { ...route.query }
if (newFilter && newFilter !== DEFAULT_FILTER_TYPE) {
currentQuery.filter = newFilter
} else {
delete currentQuery.filter
}
router.replace({
path: route.path,
query: currentQuery,
})
},
{ immediate: false },
)
watch(
() => route.query.filter,
(newFilterParam) => {
const newValue = parseFilterTypeFromQuery(newFilterParam)
if (currentFilterType.value !== newValue) {
currentFilterType.value = newValue
}
},
)
watch(
currentSortType,
(newSort) => {
const currentQuery = { ...route.query }
if (newSort && newSort !== DEFAULT_SORT_TYPE) {
currentQuery.sort = newSort
} else {
delete currentQuery.sort
}
router.replace({
path: route.path,
query: currentQuery,
})
},
{ immediate: false },
)
watch(
() => route.query.sort,
(newSortParam) => {
const newValue = parseSortTypeFromQuery(newSortParam)
if (currentSortType.value !== newValue) {
currentSortType.value = newValue
}
},
)
const itemsPerPageCookie = useCookie<number>('moderation-items-per-page', {
default: () => DEFAULT_ITEMS_PER_PAGE,
maxAge: 60 * 60 * 24 * 365,
sameSite: 'lax',
path: '/',
})
const itemsPerPage = computed({
get() {
const value = Number(itemsPerPageCookie.value)
return itemsPerPageValues.includes(value) ? value : DEFAULT_ITEMS_PER_PAGE
},
set(value: number) {
itemsPerPageCookie.value = value
},
})
const currentPage = ref(1)
const itemsPerPage = 15
const totalPages = computed(() => Math.ceil((filteredProjects.value?.length || 0) / itemsPerPage))
const totalPages = computed(() =>
Math.ceil((filteredProjects.value?.length || 0) / itemsPerPage.value),
)
const fuse = computed(() => {
if (!allProjects.value || allProjects.value.length === 0) return null
@@ -254,9 +375,7 @@ const fuse = computed(() => {
name: 'project.project_type',
weight: 1,
},
'owner.user.username',
'org.name',
'org.slug',
'ownership.name',
],
includeScore: true,
threshold: 0.4,
@@ -274,7 +393,11 @@ const baseFiltered = computed(() => {
})
const typeFiltered = computed(() => {
if (currentFilterType.value === 'All projects') return baseFiltered.value
if (currentFilterType.value === 'All projects') {
return baseFiltered.value
} else if (currentFilterType.value === 'Fucked up') {
return baseFiltered.value.filter((queueItem) => queueItem.project.project_types.length === 0)
}
const filterMap: Record<string, string> = {
Modpacks: 'modpack',
@@ -319,11 +442,31 @@ const filteredProjects = computed(() => {
const paginatedProjects = computed(() => {
if (!filteredProjects.value) return []
const start = (currentPage.value - 1) * itemsPerPage
const end = start + itemsPerPage
const start = (currentPage.value - 1) * itemsPerPage.value
const end = start + itemsPerPage.value
return filteredProjects.value.slice(start, end)
})
const emptyStateHeading = computed(() => {
if (query.value) {
return 'Not finding anything...'
}
if (currentFilterType.value !== DEFAULT_FILTER_TYPE) {
return 'All done here!'
}
return 'The queue is empty!'
})
const emptyStateDescription = computed(() => {
if (query.value) {
return 'Check that your search query is correct!'
}
if (currentFilterType.value !== DEFAULT_FILTER_TYPE) {
return `There are no ${currentFilterType.value.toLowerCase()} in the queue`
}
return 'you will probably never see this but if you do, congrats!!! :D'
})
function goToPage(page: number) {
currentPage.value = page
}
@@ -368,7 +511,7 @@ async function findFirstUnlockedProject(): Promise<ModerationProject | null> {
async function moderateAllInFilter() {
// Start from the current page - get projects from current page onwards
const startIndex = (currentPage.value - 1) * itemsPerPage
const startIndex = (currentPage.value - 1) * itemsPerPage.value
const projectsFromCurrentPage = filteredProjects.value.slice(startIndex)
const projectIds = projectsFromCurrentPage.map((queueItem) => queueItem.project.id)
await moderationQueue.setQueue(projectIds)

View File

@@ -15,7 +15,7 @@
<!-- Project statuses -->
<template v-else-if="type === 'approved'">
<ListIcon aria-hidden="true" /> {{ formatMessage(messages.listedLabel) }}
<GlobeIcon aria-hidden="true" /> {{ formatMessage(messages.listedLabel) }}
</template>
<template v-else-if="type === 'approved-general'">
<CheckIcon aria-hidden="true" /> {{ formatMessage(messages.approvedLabel) }}
@@ -91,7 +91,7 @@ import {
CheckIcon,
EyeOffIcon,
FileTextIcon,
ListIcon,
GlobeIcon,
LockIcon,
ModrinthIcon,
ScaleIcon,
@@ -134,7 +134,7 @@ const messages = defineMessages({
},
listedLabel: {
id: 'omorphia.component.badge.label.listed',
defaultMessage: 'Listed',
defaultMessage: 'Public',
},
moderatorLabel: {
id: 'omorphia.component.badge.label.moderator',
@@ -186,7 +186,7 @@ const messages = defineMessages({
},
withheldLabel: {
id: 'omorphia.component.badge.label.withheld',
defaultMessage: 'Withheld',
defaultMessage: 'Unlisted by staff',
},
})
const { formatMessage } = useVIntl()

View File

@@ -1998,7 +1998,7 @@
"defaultMessage": "Failed"
},
"omorphia.component.badge.label.listed": {
"defaultMessage": "Listed"
"defaultMessage": "Public"
},
"omorphia.component.badge.label.moderator": {
"defaultMessage": "Moderator"
@@ -2037,7 +2037,7 @@
"defaultMessage": "Fail"
},
"omorphia.component.badge.label.withheld": {
"defaultMessage": "Withheld"
"defaultMessage": "Unlisted by staff"
},
"omorphia.component.copy.action.copy": {
"defaultMessage": "Copy code to clipboard"

View File

@@ -131,6 +131,8 @@ export const formatProjectType = (name, short = false) => {
return 'PLG'
} else if (name === 'datapack') {
return 'DPK'
} else if (name === 'server') {
return 'SRV'
}
}