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:
@@ -1,7 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { WrenchIcon,XIcon } from '@modrinth/assets'
|
import { WrenchIcon, XIcon } from '@modrinth/assets'
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
ButtonStyled,
|
ButtonStyled,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
commonMessages,
|
commonMessages,
|
||||||
@@ -44,7 +44,10 @@ const messages = defineMessages({
|
|||||||
defaultMessage: 'Configure which files are included in this export',
|
defaultMessage: 'Configure which files are included in this export',
|
||||||
},
|
},
|
||||||
exportButton: { id: 'app.export-modal.export-button', defaultMessage: '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({
|
const props = defineProps({
|
||||||
@@ -184,7 +187,10 @@ const exportPack = async () => {
|
|||||||
:placeholder="formatMessage(messages.descriptionPlaceholder)"
|
:placeholder="formatMessage(messages.descriptionPlaceholder)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
<template #title>
|
||||||
<span class="flex items-center gap-3 text-contrast group-active:scale-[0.98]">
|
<span class="flex items-center gap-3 text-contrast group-active:scale-[0.98]">
|
||||||
<WrenchIcon aria-hidden="true" class="size-5 text-secondary" />
|
<WrenchIcon aria-hidden="true" class="size-5 text-secondary" />
|
||||||
@@ -193,11 +199,17 @@ const exportPack = async () => {
|
|||||||
</template>
|
</template>
|
||||||
<div class="flex flex-col [&>*:nth-child(even)]:bg-surface-3">
|
<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">
|
<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>
|
<template #title>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
:model-value="children.every((child) => child.selected)"
|
: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 })"
|
:description="formatMessage(messages.includeFile, { file: path.name })"
|
||||||
class="pl-4 py-2"
|
class="pl-4 py-2"
|
||||||
:disabled="children.every((x) => x.disabled)"
|
:disabled="children.every((x) => x.disabled)"
|
||||||
@@ -219,7 +231,8 @@ const exportPack = async () => {
|
|||||||
</Accordion>
|
</Accordion>
|
||||||
</div>
|
</div>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
v-for="file in files" :key="file.path"
|
v-for="file in files"
|
||||||
|
:key="file.path"
|
||||||
v-model="file.selected"
|
v-model="file.selected"
|
||||||
:label="file.name"
|
:label="file.name"
|
||||||
:disabled="file.disabled"
|
:disabled="file.disabled"
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
<template>
|
<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 justify-between">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<Avatar
|
<NuxtLink
|
||||||
:src="queueEntry.project.icon_url"
|
:to="`/project/${queueEntry.project.slug}`"
|
||||||
size="4rem"
|
target="_blank"
|
||||||
class="rounded-2xl border border-surface-5 bg-surface-4 !shadow-none"
|
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 flex-col gap-1.5">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
@@ -22,11 +29,15 @@
|
|||||||
<component
|
<component
|
||||||
:is="getProjectTypeIcon(queueEntry.project.project_types[0] as any)"
|
:is="getProjectTypeIcon(queueEntry.project.project_types[0] as any)"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
class="h-4 w-4"
|
class="size-4"
|
||||||
/>
|
/>
|
||||||
<span class="text-sm font-medium text-secondary">
|
<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>
|
</span>
|
||||||
</div>
|
</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"
|
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>
|
<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>
|
</div>
|
||||||
<div v-if="queueEntry.owner" class="flex items-center gap-1">
|
<div v-if="queueEntry.ownership?.kind === 'user'">
|
||||||
<Avatar
|
|
||||||
:src="queueEntry.owner.user.avatar_url"
|
|
||||||
size="1.5rem"
|
|
||||||
circle
|
|
||||||
class="border border-surface-5 bg-surface-4 !shadow-none"
|
|
||||||
/>
|
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
:to="`/user/${queueEntry.owner.user.username}`"
|
:to="`/user/${queueEntry.ownership.id}`"
|
||||||
target="_blank"
|
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>
|
</NuxtLink>
|
||||||
</div>
|
</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
|
<Avatar
|
||||||
:src="queueEntry.org.icon_url"
|
:src="queueEntry.ownership.icon_url"
|
||||||
size="1.5rem"
|
size="1.5rem"
|
||||||
circle
|
|
||||||
class="border border-surface-5 bg-surface-4 !shadow-none"
|
class="border border-surface-5 bg-surface-4 !shadow-none"
|
||||||
/>
|
/>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
:to="`/organization/${queueEntry.org.slug}`"
|
:to="`/organization/${queueEntry.ownership.id}`"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
class="text-sm font-medium text-secondary hover:underline"
|
class="text-sm font-medium text-secondary hover:underline"
|
||||||
>
|
>
|
||||||
{{ queueEntry.org.name }}
|
{{ queueEntry.ownership.name }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -84,25 +97,20 @@
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<ButtonStyled circular color="orange">
|
<ButtonStyled circular>
|
||||||
<button @click="openProjectForReview">
|
<button v-tooltip="'Copy ID'" @click="copyId">
|
||||||
<ScaleIcon class="size-5" />
|
<ClipboardCopyIcon />
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
<ButtonStyled circular>
|
<ButtonStyled circular>
|
||||||
<OverflowMenu :options="quickActions" :dropdown-id="`${baseId}-quick-actions`">
|
<button v-tooltip="'Copy link'" @click="copyLink">
|
||||||
<template #default>
|
<LinkIcon />
|
||||||
<EllipsisVerticalIcon class="size-4" />
|
</button>
|
||||||
</template>
|
</ButtonStyled>
|
||||||
<template #copy-id>
|
<ButtonStyled v-tooltip="'Begin review'" circular color="orange">
|
||||||
<ClipboardCopyIcon />
|
<button @click="openProjectForReview">
|
||||||
<span class="hidden sm:inline">Copy ID</span>
|
<ScaleIcon />
|
||||||
</template>
|
</button>
|
||||||
<template #copy-link>
|
|
||||||
<LinkIcon />
|
|
||||||
<span class="hidden sm:inline">Copy link</span>
|
|
||||||
</template>
|
|
||||||
</OverflowMenu>
|
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -111,15 +119,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ClipboardCopyIcon, EllipsisVerticalIcon, LinkIcon, ScaleIcon } from '@modrinth/assets'
|
import { ClipboardCopyIcon, LinkIcon, ScaleIcon } from '@modrinth/assets'
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Badge,
|
Badge,
|
||||||
ButtonStyled,
|
ButtonStyled,
|
||||||
getProjectTypeIcon,
|
getProjectTypeIcon,
|
||||||
injectNotificationManager,
|
injectNotificationManager,
|
||||||
OverflowMenu,
|
|
||||||
type OverflowMenuOption,
|
|
||||||
useFormatDateTime,
|
useFormatDateTime,
|
||||||
useRelativeTime,
|
useRelativeTime,
|
||||||
} from '@modrinth/ui'
|
} from '@modrinth/ui'
|
||||||
@@ -143,8 +149,6 @@ const formatDateTimeFull = useFormatDateTime({
|
|||||||
timeZone: 'UTC',
|
timeZone: 'UTC',
|
||||||
})
|
})
|
||||||
|
|
||||||
const baseId = useId()
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
queueEntry: ModerationProject
|
queueEntry: ModerationProject
|
||||||
}>()
|
}>()
|
||||||
@@ -185,34 +189,27 @@ const formattedDate = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const quickActions: OverflowMenuOption[] = [
|
function copyLink() {
|
||||||
{
|
const base = window.location.origin
|
||||||
id: 'copy-link',
|
const projectUrl = `${base}/project/${props.queueEntry.project.slug}`
|
||||||
action: () => {
|
navigator.clipboard.writeText(projectUrl).then(() => {
|
||||||
const base = window.location.origin
|
addNotification({
|
||||||
const projectUrl = `${base}/project/${props.queueEntry.project.slug}`
|
type: 'success',
|
||||||
navigator.clipboard.writeText(projectUrl).then(() => {
|
title: 'Project link copied',
|
||||||
addNotification({
|
text: 'The link to this project has been copied to your clipboard.',
|
||||||
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',
|
||||||
id: 'copy-id',
|
title: 'Project ID copied',
|
||||||
action: () => {
|
text: 'The ID of this project has been copied to your clipboard.',
|
||||||
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() {
|
function openProjectForReview() {
|
||||||
emit('startFromProject', props.queueEntry.project.id)
|
emit('startFromProject', props.queueEntry.project.id)
|
||||||
|
|||||||
@@ -24,8 +24,16 @@
|
|||||||
</Checkbox>
|
</Checkbox>
|
||||||
<div class="input-group push-right">
|
<div class="input-group push-right">
|
||||||
<ButtonStyled color="orange">
|
<ButtonStyled color="orange">
|
||||||
<button :disabled="!submissionConfirmation" @click="resubmit()">
|
<button
|
||||||
<ScaleIcon aria-hidden="true" />
|
: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
|
Resubmit for review
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
@@ -54,8 +62,16 @@
|
|||||||
</Checkbox>
|
</Checkbox>
|
||||||
<div class="input-group push-right">
|
<div class="input-group push-right">
|
||||||
<ButtonStyled color="brand">
|
<ButtonStyled color="brand">
|
||||||
<button :disabled="!replyConfirmation" @click="sendReplyFromModal()">
|
<button
|
||||||
<ReplyIcon aria-hidden="true" />
|
: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
|
Reply to thread
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
@@ -82,8 +98,9 @@
|
|||||||
<template v-if="report && report.closed">
|
<template v-if="report && report.closed">
|
||||||
<p>This thread is closed and new messages cannot be sent to it.</p>
|
<p>This thread is closed and new messages cannot be sent to it.</p>
|
||||||
<ButtonStyled v-if="isStaff(auth.user)">
|
<ButtonStyled v-if="isStaff(auth.user)">
|
||||||
<button @click="reopenReport()">
|
<button :disabled="isLoading" @click="runBlockingAction('reopen', () => reopenReport())">
|
||||||
<CheckCircleIcon aria-hidden="true" />
|
<SpinnerIcon v-if="loadingAction === 'reopen'" class="animate-spin" aria-hidden="true" />
|
||||||
|
<CheckCircleIcon v-else aria-hidden="true" />
|
||||||
Reopen thread
|
Reopen thread
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
@@ -100,35 +117,53 @@
|
|||||||
<ButtonStyled color="brand">
|
<ButtonStyled color="brand">
|
||||||
<button
|
<button
|
||||||
v-if="sortedMessages.length > 0"
|
v-if="sortedMessages.length > 0"
|
||||||
:disabled="!replyBody"
|
:disabled="!replyBody || isLoading"
|
||||||
@click="isApproved(project) && !isStaff(auth.user) ? openReplyModal() : sendReply()"
|
@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
|
Reply
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-else
|
v-else
|
||||||
:disabled="!replyBody"
|
:disabled="!replyBody || isLoading"
|
||||||
@click="isApproved(project) && !isStaff(auth.user) ? openReplyModal() : sendReply()"
|
@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
|
Send
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
<ButtonStyled v-if="isStaff(auth.user)">
|
<ButtonStyled v-if="isStaff(auth.user)">
|
||||||
<button :disabled="!replyBody" @click="sendReply(null, true)">
|
<button
|
||||||
<ScaleIcon aria-hidden="true" />
|
: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
|
Add private note
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
<template v-if="currentMember && !isStaff(auth.user)">
|
<template v-if="currentMember && !isStaff(auth.user)">
|
||||||
<template v-if="isRejected(project)">
|
<template v-if="isRejected(project)">
|
||||||
<ButtonStyled color="orange">
|
<ButtonStyled color="orange">
|
||||||
<button v-if="replyBody" @click="openResubmitModal(true)">
|
<button v-if="replyBody" :disabled="isLoading" @click="openResubmitModal(true)">
|
||||||
<ScaleIcon aria-hidden="true" />
|
<ScaleIcon aria-hidden="true" />
|
||||||
Resubmit for review with reply
|
Resubmit for review with reply
|
||||||
</button>
|
</button>
|
||||||
<button v-else @click="openResubmitModal(false)">
|
<button v-else :disabled="isLoading" @click="openResubmitModal(false)">
|
||||||
<ScaleIcon aria-hidden="true" />
|
<ScaleIcon aria-hidden="true" />
|
||||||
Resubmit for review
|
Resubmit for review
|
||||||
</button>
|
</button>
|
||||||
@@ -140,12 +175,30 @@
|
|||||||
<template v-if="report">
|
<template v-if="report">
|
||||||
<template v-if="isStaff(auth.user)">
|
<template v-if="isStaff(auth.user)">
|
||||||
<ButtonStyled color="red">
|
<ButtonStyled color="red">
|
||||||
<button v-if="replyBody" @click="closeReport(true)">
|
<button
|
||||||
<CheckCircleIcon aria-hidden="true" />
|
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
|
Close with reply
|
||||||
</button>
|
</button>
|
||||||
<button v-else @click="closeReport()">
|
<button
|
||||||
<CheckCircleIcon aria-hidden="true" />
|
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
|
Close thread
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
@@ -154,92 +207,122 @@
|
|||||||
<template v-if="project">
|
<template v-if="project">
|
||||||
<template v-if="isStaff(auth.user)">
|
<template v-if="isStaff(auth.user)">
|
||||||
<ButtonStyled v-if="replyBody" color="green">
|
<ButtonStyled v-if="replyBody" color="green">
|
||||||
<button :disabled="isApproved(project)" @click="sendReply(requestedStatus)">
|
<button
|
||||||
<CheckIcon aria-hidden="true" />
|
: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
|
Approve with reply
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
<ButtonStyled v-else color="green">
|
<ButtonStyled v-else color="green">
|
||||||
<button :disabled="isApproved(project)" @click="setStatus(requestedStatus)">
|
<button
|
||||||
<CheckIcon aria-hidden="true" />
|
: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
|
Approve
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
<div class="joined-buttons">
|
<div class="joined-buttons">
|
||||||
<ButtonStyled v-if="replyBody" color="red">
|
<ButtonStyled v-if="replyBody" color="red">
|
||||||
<button :disabled="project.status === 'rejected'" @click="sendReply('rejected')">
|
<button
|
||||||
<XIcon aria-hidden="true" />
|
: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
|
Reject with reply
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
<ButtonStyled v-else color="red">
|
<ButtonStyled v-else color="red">
|
||||||
<button :disabled="project.status === 'rejected'" @click="setStatus('rejected')">
|
<button
|
||||||
<XIcon aria-hidden="true" />
|
: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
|
Reject
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
<ButtonStyled color="red">
|
<ButtonStyled color="red">
|
||||||
<OverflowMenu
|
<OverflowMenu
|
||||||
class="btn-dropdown-animation"
|
class="btn-dropdown-animation"
|
||||||
|
:disabled="isLoading"
|
||||||
:options="
|
:options="
|
||||||
replyBody
|
replyBody
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
id: 'withhold-reply',
|
id: 'withhold-reply',
|
||||||
color: 'danger',
|
color: 'danger',
|
||||||
action: () => {
|
action: () =>
|
||||||
sendReply('withheld')
|
runBlockingAction('withhold-reply', () => sendReply('withheld')),
|
||||||
},
|
|
||||||
hoverFilled: true,
|
hoverFilled: true,
|
||||||
disabled: project.status === 'withheld',
|
disabled: project.status === 'withheld' || isLoading,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'set-to-draft-reply',
|
id: 'set-to-draft-reply',
|
||||||
action: () => {
|
action: () =>
|
||||||
sendReply('draft')
|
runBlockingAction('set-to-draft-reply', () => sendReply('draft')),
|
||||||
},
|
|
||||||
hoverFilled: true,
|
hoverFilled: true,
|
||||||
disabled: project.status === 'draft',
|
disabled: project.status === 'draft' || isLoading,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'send-to-review-reply',
|
id: 'send-to-review-reply',
|
||||||
action: () => {
|
action: () =>
|
||||||
sendReply('processing', true)
|
runBlockingAction('send-to-review-reply', () =>
|
||||||
},
|
sendReply('processing', true),
|
||||||
|
),
|
||||||
hoverFilled: true,
|
hoverFilled: true,
|
||||||
disabled: project.status === 'processing',
|
disabled: project.status === 'processing' || isLoading,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
{
|
{
|
||||||
id: 'withhold',
|
id: 'withhold',
|
||||||
color: 'danger',
|
color: 'danger',
|
||||||
action: () => {
|
action: () =>
|
||||||
setStatus('withheld')
|
runBlockingAction('withhold', () => setStatus('withheld')),
|
||||||
},
|
|
||||||
hoverFilled: true,
|
hoverFilled: true,
|
||||||
disabled: project.status === 'withheld',
|
disabled: project.status === 'withheld' || isLoading,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'set-to-draft',
|
id: 'set-to-draft',
|
||||||
action: () => {
|
action: () =>
|
||||||
setStatus('draft')
|
runBlockingAction('set-to-draft', () => setStatus('draft')),
|
||||||
},
|
|
||||||
hoverFilled: true,
|
hoverFilled: true,
|
||||||
disabled: project.status === 'draft',
|
disabled: project.status === 'draft' || isLoading,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'send-to-review',
|
id: 'send-to-review',
|
||||||
action: () => {
|
action: () =>
|
||||||
setStatus('processing')
|
runBlockingAction('send-to-review', () => setStatus('processing')),
|
||||||
},
|
|
||||||
hoverFilled: true,
|
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>
|
<template #withhold-reply>
|
||||||
<EyeOffIcon aria-hidden="true" />
|
<EyeOffIcon aria-hidden="true" />
|
||||||
Withhold with reply
|
Withhold with reply
|
||||||
@@ -285,6 +368,7 @@ import {
|
|||||||
ReplyIcon,
|
ReplyIcon,
|
||||||
ScaleIcon,
|
ScaleIcon,
|
||||||
SendIcon,
|
SendIcon,
|
||||||
|
SpinnerIcon,
|
||||||
XIcon,
|
XIcon,
|
||||||
} from '@modrinth/assets'
|
} from '@modrinth/assets'
|
||||||
import {
|
import {
|
||||||
@@ -363,6 +447,30 @@ const sortedMessages = computed(() => {
|
|||||||
const modalSubmit = ref(null)
|
const modalSubmit = ref(null)
|
||||||
const modalReply = 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() {
|
async function updateThreadLocal() {
|
||||||
let threadId = null
|
let threadId = null
|
||||||
if (props.project) {
|
if (props.project) {
|
||||||
@@ -390,8 +498,8 @@ async function onUploadImage(file) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function sendReplyFromModal(status = null, privateMessage = false) {
|
async function sendReplyFromModal(status = null, privateMessage = false) {
|
||||||
modalReply.value.hide()
|
|
||||||
await sendReply(status, privateMessage)
|
await sendReply(status, privateMessage)
|
||||||
|
modalReply.value.hide()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendReply(status = null, privateMessage = false) {
|
async function sendReply(status = null, privateMessage = false) {
|
||||||
|
|||||||
@@ -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.
|
// 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 {
|
export interface ModerationProject {
|
||||||
project: any
|
project: any
|
||||||
owner: TeamMember | null
|
ownership: ModerationOwnership | null
|
||||||
org: Organization | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function enrichProjectBatch(projects: any[]): Promise<ModerationProject[]> {
|
export function toModerationProjects(projects: ProjectWithOwnership[]): ModerationProject[] {
|
||||||
const teamIds = [...new Set(projects.map((p) => p.team_id).filter(Boolean))]
|
return projects.map(({ ownership, ...project }) => ({
|
||||||
const orgIds = [...new Set(projects.map((p) => p.organization).filter(Boolean))]
|
project,
|
||||||
|
ownership: ownership ?? null,
|
||||||
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
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,10 +17,10 @@ import {
|
|||||||
BrowsePageLayout,
|
BrowsePageLayout,
|
||||||
BrowseSidebar,
|
BrowseSidebar,
|
||||||
CreationFlowModal,
|
CreationFlowModal,
|
||||||
PROJECT_DEP_MARKER_QUERY,
|
|
||||||
defineMessages,
|
defineMessages,
|
||||||
injectModrinthClient,
|
injectModrinthClient,
|
||||||
injectNotificationManager,
|
injectNotificationManager,
|
||||||
|
PROJECT_DEP_MARKER_QUERY,
|
||||||
provideBrowseManager,
|
provideBrowseManager,
|
||||||
useBrowseSearch,
|
useBrowseSearch,
|
||||||
useDebugLogger,
|
useDebugLogger,
|
||||||
|
|||||||
@@ -8,17 +8,12 @@
|
|||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
:placeholder="formatMessage(commonMessages.searchPlaceholder)"
|
:placeholder="formatMessage(commonMessages.searchPlaceholder)"
|
||||||
clearable
|
clearable
|
||||||
wrapper-class="flex-1 lg:max-w-52"
|
wrapper-class="flex-1"
|
||||||
input-class="h-[40px]"
|
input-class="h-[40px] w-full"
|
||||||
@input="goToPage(1)"
|
@input="goToPage(1)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div v-if="totalPages > 1" class="hidden flex-1 justify-center lg:flex">
|
<div class="flex flex-col flex-wrap justify-end gap-2 sm:flex-row lg:flex-shrink-0">
|
||||||
<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 gap-2 sm:flex-row">
|
<div class="flex flex-col gap-2 sm:flex-row">
|
||||||
<Combobox
|
<Combobox
|
||||||
v-model="currentFilterType"
|
v-model="currentFilterType"
|
||||||
@@ -55,6 +50,20 @@
|
|||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</Combobox>
|
</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>
|
</div>
|
||||||
|
|
||||||
<ButtonStyled color="orange">
|
<ButtonStyled color="orange">
|
||||||
@@ -71,25 +80,44 @@
|
|||||||
</div>
|
</div>
|
||||||
</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" />
|
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
|
||||||
<ConfettiExplosion v-if="visible" />
|
<ConfettiExplosion v-if="visible" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-3">
|
||||||
<div v-if="paginatedProjects.length === 0" class="universal-card h-24 animate-pulse"></div>
|
<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
|
<ModerationQueueCard
|
||||||
v-for="item in paginatedProjects"
|
v-for="item in paginatedProjects"
|
||||||
v-else
|
v-else
|
||||||
:key="item.project.id"
|
:key="item.project.id"
|
||||||
:queue-entry="item"
|
:queue-entry="item"
|
||||||
:owner="item.owner"
|
|
||||||
:org="item.org"
|
|
||||||
@start-from-project="startFromProject"
|
@start-from-project="startFromProject"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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" />
|
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -102,6 +130,7 @@ import {
|
|||||||
type ComboboxOption,
|
type ComboboxOption,
|
||||||
commonMessages,
|
commonMessages,
|
||||||
defineMessages,
|
defineMessages,
|
||||||
|
EmptyState,
|
||||||
injectNotificationManager,
|
injectNotificationManager,
|
||||||
Pagination,
|
Pagination,
|
||||||
StyledInput,
|
StyledInput,
|
||||||
@@ -111,7 +140,11 @@ import Fuse from 'fuse.js'
|
|||||||
import ConfettiExplosion from 'vue-confetti-explosion'
|
import ConfettiExplosion from 'vue-confetti-explosion'
|
||||||
|
|
||||||
import ModerationQueueCard from '~/components/ui/moderation/ModerationQueueCard.vue'
|
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'
|
import { useModerationQueue } from '~/services/moderation-queue.ts'
|
||||||
|
|
||||||
useHead({ title: 'Projects queue - Modrinth' })
|
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()
|
const startTime = performance.now()
|
||||||
let currentOffset = 0
|
let currentOffset = 0
|
||||||
const PROJECT_ENDPOINT_COUNT = 350
|
const PROJECT_ENDPOINT_COUNT = 350
|
||||||
const allProjects: ModerationProject[] = []
|
const allProjects: ModerationProject[] = []
|
||||||
|
|
||||||
const enrichmentPromises: Promise<ModerationProject[]>[] = []
|
let projects: ProjectWithOwnership[] = []
|
||||||
|
|
||||||
let projects: any[] = []
|
|
||||||
do {
|
do {
|
||||||
projects = (await useBaseFetch(
|
projects = (await useBaseFetch(
|
||||||
`moderation/projects?count=${PROJECT_ENDPOINT_COUNT}&offset=${currentOffset}`,
|
`moderation/projects?count=${PROJECT_ENDPOINT_COUNT}&offset=${currentOffset}`,
|
||||||
{ internal: true },
|
{ internal: true },
|
||||||
)) as any[]
|
)) as ProjectWithOwnership[]
|
||||||
|
|
||||||
if (projects.length === 0) break
|
if (projects.length === 0) break
|
||||||
|
|
||||||
const enrichmentPromise = enrichProjectBatch(projects)
|
allProjects.push(...toModerationProjects(projects))
|
||||||
enrichmentPromises.push(enrichmentPromise)
|
|
||||||
|
|
||||||
currentOffset += projects.length
|
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)
|
} while (projects.length === PROJECT_ENDPOINT_COUNT)
|
||||||
|
|
||||||
const remainingBatches = await Promise.all(enrichmentPromises)
|
const duration = performance.now() - startTime
|
||||||
allProjects.push(...remainingBatches.flat())
|
|
||||||
|
|
||||||
const endTime = performance.now()
|
|
||||||
const duration = endTime - startTime
|
|
||||||
|
|
||||||
console.debug(
|
console.debug(
|
||||||
`Projects fetched and processed in ${duration.toFixed(2)}ms (${(duration / 1000).toFixed(2)}s)`,
|
`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>[] = [
|
const filterTypes: ComboboxOption<string>[] = [
|
||||||
{ value: 'All projects', label: 'All projects' },
|
{ value: 'All projects', label: 'All projects' },
|
||||||
{ value: 'Modpacks', label: 'Modpacks' },
|
{ value: 'Modpacks', label: 'Modpacks' },
|
||||||
@@ -222,17 +241,119 @@ const filterTypes: ComboboxOption<string>[] = [
|
|||||||
{ value: 'Plugins', label: 'Plugins' },
|
{ value: 'Plugins', label: 'Plugins' },
|
||||||
{ value: 'Shaders', label: 'Shaders' },
|
{ value: 'Shaders', label: 'Shaders' },
|
||||||
{ value: 'Servers', label: 'Servers' },
|
{ 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>[] = [
|
const sortTypes: ComboboxOption<string>[] = [
|
||||||
{ value: 'Oldest', label: 'Oldest' },
|
{ value: 'Oldest', label: 'Oldest' },
|
||||||
{ value: 'Newest', label: 'Newest' },
|
{ 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 currentPage = ref(1)
|
||||||
const itemsPerPage = 15
|
const totalPages = computed(() =>
|
||||||
const totalPages = computed(() => Math.ceil((filteredProjects.value?.length || 0) / itemsPerPage))
|
Math.ceil((filteredProjects.value?.length || 0) / itemsPerPage.value),
|
||||||
|
)
|
||||||
|
|
||||||
const fuse = computed(() => {
|
const fuse = computed(() => {
|
||||||
if (!allProjects.value || allProjects.value.length === 0) return null
|
if (!allProjects.value || allProjects.value.length === 0) return null
|
||||||
@@ -254,9 +375,7 @@ const fuse = computed(() => {
|
|||||||
name: 'project.project_type',
|
name: 'project.project_type',
|
||||||
weight: 1,
|
weight: 1,
|
||||||
},
|
},
|
||||||
'owner.user.username',
|
'ownership.name',
|
||||||
'org.name',
|
|
||||||
'org.slug',
|
|
||||||
],
|
],
|
||||||
includeScore: true,
|
includeScore: true,
|
||||||
threshold: 0.4,
|
threshold: 0.4,
|
||||||
@@ -274,7 +393,11 @@ const baseFiltered = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const typeFiltered = 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> = {
|
const filterMap: Record<string, string> = {
|
||||||
Modpacks: 'modpack',
|
Modpacks: 'modpack',
|
||||||
@@ -319,11 +442,31 @@ const filteredProjects = computed(() => {
|
|||||||
|
|
||||||
const paginatedProjects = computed(() => {
|
const paginatedProjects = computed(() => {
|
||||||
if (!filteredProjects.value) return []
|
if (!filteredProjects.value) return []
|
||||||
const start = (currentPage.value - 1) * itemsPerPage
|
const start = (currentPage.value - 1) * itemsPerPage.value
|
||||||
const end = start + itemsPerPage
|
const end = start + itemsPerPage.value
|
||||||
return filteredProjects.value.slice(start, end)
|
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) {
|
function goToPage(page: number) {
|
||||||
currentPage.value = page
|
currentPage.value = page
|
||||||
}
|
}
|
||||||
@@ -368,7 +511,7 @@ async function findFirstUnlockedProject(): Promise<ModerationProject | null> {
|
|||||||
|
|
||||||
async function moderateAllInFilter() {
|
async function moderateAllInFilter() {
|
||||||
// Start from the current page - get projects from current page onwards
|
// 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 projectsFromCurrentPage = filteredProjects.value.slice(startIndex)
|
||||||
const projectIds = projectsFromCurrentPage.map((queueItem) => queueItem.project.id)
|
const projectIds = projectsFromCurrentPage.map((queueItem) => queueItem.project.id)
|
||||||
await moderationQueue.setQueue(projectIds)
|
await moderationQueue.setQueue(projectIds)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
<!-- Project statuses -->
|
<!-- Project statuses -->
|
||||||
<template v-else-if="type === 'approved'">
|
<template v-else-if="type === 'approved'">
|
||||||
<ListIcon aria-hidden="true" /> {{ formatMessage(messages.listedLabel) }}
|
<GlobeIcon aria-hidden="true" /> {{ formatMessage(messages.listedLabel) }}
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="type === 'approved-general'">
|
<template v-else-if="type === 'approved-general'">
|
||||||
<CheckIcon aria-hidden="true" /> {{ formatMessage(messages.approvedLabel) }}
|
<CheckIcon aria-hidden="true" /> {{ formatMessage(messages.approvedLabel) }}
|
||||||
@@ -91,7 +91,7 @@ import {
|
|||||||
CheckIcon,
|
CheckIcon,
|
||||||
EyeOffIcon,
|
EyeOffIcon,
|
||||||
FileTextIcon,
|
FileTextIcon,
|
||||||
ListIcon,
|
GlobeIcon,
|
||||||
LockIcon,
|
LockIcon,
|
||||||
ModrinthIcon,
|
ModrinthIcon,
|
||||||
ScaleIcon,
|
ScaleIcon,
|
||||||
@@ -134,7 +134,7 @@ const messages = defineMessages({
|
|||||||
},
|
},
|
||||||
listedLabel: {
|
listedLabel: {
|
||||||
id: 'omorphia.component.badge.label.listed',
|
id: 'omorphia.component.badge.label.listed',
|
||||||
defaultMessage: 'Listed',
|
defaultMessage: 'Public',
|
||||||
},
|
},
|
||||||
moderatorLabel: {
|
moderatorLabel: {
|
||||||
id: 'omorphia.component.badge.label.moderator',
|
id: 'omorphia.component.badge.label.moderator',
|
||||||
@@ -186,7 +186,7 @@ const messages = defineMessages({
|
|||||||
},
|
},
|
||||||
withheldLabel: {
|
withheldLabel: {
|
||||||
id: 'omorphia.component.badge.label.withheld',
|
id: 'omorphia.component.badge.label.withheld',
|
||||||
defaultMessage: 'Withheld',
|
defaultMessage: 'Unlisted by staff',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
|
|||||||
@@ -1998,7 +1998,7 @@
|
|||||||
"defaultMessage": "Failed"
|
"defaultMessage": "Failed"
|
||||||
},
|
},
|
||||||
"omorphia.component.badge.label.listed": {
|
"omorphia.component.badge.label.listed": {
|
||||||
"defaultMessage": "Listed"
|
"defaultMessage": "Public"
|
||||||
},
|
},
|
||||||
"omorphia.component.badge.label.moderator": {
|
"omorphia.component.badge.label.moderator": {
|
||||||
"defaultMessage": "Moderator"
|
"defaultMessage": "Moderator"
|
||||||
@@ -2037,7 +2037,7 @@
|
|||||||
"defaultMessage": "Fail"
|
"defaultMessage": "Fail"
|
||||||
},
|
},
|
||||||
"omorphia.component.badge.label.withheld": {
|
"omorphia.component.badge.label.withheld": {
|
||||||
"defaultMessage": "Withheld"
|
"defaultMessage": "Unlisted by staff"
|
||||||
},
|
},
|
||||||
"omorphia.component.copy.action.copy": {
|
"omorphia.component.copy.action.copy": {
|
||||||
"defaultMessage": "Copy code to clipboard"
|
"defaultMessage": "Copy code to clipboard"
|
||||||
|
|||||||
@@ -131,6 +131,8 @@ export const formatProjectType = (name, short = false) => {
|
|||||||
return 'PLG'
|
return 'PLG'
|
||||||
} else if (name === 'datapack') {
|
} else if (name === 'datapack') {
|
||||||
return 'DPK'
|
return 'DPK'
|
||||||
|
} else if (name === 'server') {
|
||||||
|
return 'SRV'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user