mirror of
https://github.com/modrinth/code.git
synced 2026-07-31 21:26:40 +00:00
more queue upgrades
This commit is contained in:
@@ -0,0 +1,114 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ScaleIcon, XIcon } from '@modrinth/assets'
|
||||||
|
import { AutoLink, ButtonStyled, injectModrinthClient, NewModal } from '@modrinth/ui'
|
||||||
|
import { ref, useTemplateRef } from 'vue'
|
||||||
|
|
||||||
|
import { useGeneratedState } from '~/composables/generated'
|
||||||
|
import { getProjectTypeForUrlShorthand } from '~/helpers/projects.js'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
completedIds: string[]
|
||||||
|
skippedIds: string[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'review-skipped'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const client = injectModrinthClient()
|
||||||
|
const tags = useGeneratedState()
|
||||||
|
const modalRef = useTemplateRef<InstanceType<typeof NewModal>>('modalRef')
|
||||||
|
|
||||||
|
interface QueueSummaryEntry {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
link: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const completedEntries = ref<QueueSummaryEntry[]>([])
|
||||||
|
const skippedEntries = ref<QueueSummaryEntry[]>([])
|
||||||
|
|
||||||
|
function toEntries(
|
||||||
|
ids: string[],
|
||||||
|
projectsById: Map<string, { id: string; slug: string; title: string; project_types: string[] }>,
|
||||||
|
): QueueSummaryEntry[] {
|
||||||
|
return ids
|
||||||
|
.map((id) => projectsById.get(id))
|
||||||
|
.filter((project): project is NonNullable<typeof project> => !!project)
|
||||||
|
.map((project) => ({
|
||||||
|
id: project.id,
|
||||||
|
title: project.title,
|
||||||
|
link: `/${getProjectTypeForUrlShorthand(project.project_types[0], [], tags.value)}/${project.slug}`,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function show() {
|
||||||
|
const ids = [...new Set([...props.completedIds, ...props.skippedIds])]
|
||||||
|
const projects =
|
||||||
|
ids.length > 0 ? await client.labrinth.projects_v3.getMultiple(ids).catch(() => []) : []
|
||||||
|
const projectsById = new Map(projects.map((project) => [project.id, project]))
|
||||||
|
|
||||||
|
completedEntries.value = toEntries(props.completedIds, projectsById)
|
||||||
|
skippedEntries.value = toEntries(props.skippedIds, projectsById)
|
||||||
|
|
||||||
|
modalRef.value?.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
function hide() {
|
||||||
|
modalRef.value?.hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
function reviewSkipped() {
|
||||||
|
emit('review-skipped')
|
||||||
|
hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ show, hide })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NewModal ref="modalRef" header="Queue completed">
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div v-if="completedEntries.length > 0" class="flex flex-col gap-2">
|
||||||
|
<span class="font-bold text-contrast">Completed ({{ completedEntries.length }})</span>
|
||||||
|
<ul class="m-0 flex list-none flex-col gap-1 p-0">
|
||||||
|
<li v-for="entry in completedEntries" :key="entry.id">
|
||||||
|
<AutoLink :to="entry.link" class="text-primary hover:underline">{{
|
||||||
|
entry.title
|
||||||
|
}}</AutoLink>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="skippedEntries.length > 0" class="flex flex-col gap-2">
|
||||||
|
<span class="font-bold text-contrast">Skipped ({{ skippedEntries.length }})</span>
|
||||||
|
<ul class="m-0 flex list-none flex-col gap-1 p-0">
|
||||||
|
<li v-for="entry in skippedEntries" :key="entry.id">
|
||||||
|
<AutoLink :to="entry.link" class="text-primary hover:underline">{{
|
||||||
|
entry.title
|
||||||
|
}}</AutoLink>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="completedEntries.length === 0 && skippedEntries.length === 0" class="text-secondary">
|
||||||
|
No projects were reviewed during this queue.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<ButtonStyled v-if="skippedEntries.length > 0" color="orange">
|
||||||
|
<button @click="reviewSkipped">
|
||||||
|
<ScaleIcon />
|
||||||
|
Review skipped ({{ skippedEntries.length }})
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled>
|
||||||
|
<button @click="hide">
|
||||||
|
<XIcon />
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NewModal>
|
||||||
|
</template>
|
||||||
@@ -511,11 +511,10 @@ const alreadyReviewed = ref(false)
|
|||||||
|
|
||||||
// Prefetch queue for parallel lock checking and instant navigation
|
// Prefetch queue for parallel lock checking and instant navigation
|
||||||
interface PrefetchedProject {
|
interface PrefetchedProject {
|
||||||
projectId: string
|
project: string
|
||||||
slug: string // For canonical URL navigation
|
slug: string // For canonical URL navigation
|
||||||
projectType: string // For canonical URL navigation
|
projectType: string // For canonical URL navigation
|
||||||
validatedAt: number
|
validatedAt: number
|
||||||
skippedIds: string[] // IDs that were locked when this was prefetched
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const prefetchQueue = ref<PrefetchedProject[]>([])
|
const prefetchQueue = ref<PrefetchedProject[]>([])
|
||||||
@@ -646,9 +645,9 @@ async function navigateToNextUnlockedProject(): Promise<boolean> {
|
|||||||
// Quick re-check if close to expiry (last 5 seconds of TTL)
|
// Quick re-check if close to expiry (last 5 seconds of TTL)
|
||||||
if (now - next.validatedAt > PREFETCH_STALE_MS - 5000) {
|
if (now - next.validatedAt > PREFETCH_STALE_MS - 5000) {
|
||||||
const recheckResults = await batchCheckQueueCandidates(client, moderationQueue, [
|
const recheckResults = await batchCheckQueueCandidates(client, moderationQueue, [
|
||||||
next.projectId,
|
next.project,
|
||||||
])
|
])
|
||||||
const recheck = recheckResults.get(next.projectId)
|
const recheck = recheckResults.get(next.project)
|
||||||
if (!isEligibleQueueCandidate(recheck)) {
|
if (!isEligibleQueueCandidate(recheck)) {
|
||||||
prefetchQueue.value.shift()
|
prefetchQueue.value.shift()
|
||||||
return navigateToNextUnlockedProject()
|
return navigateToNextUnlockedProject()
|
||||||
@@ -657,17 +656,11 @@ async function navigateToNextUnlockedProject(): Promise<boolean> {
|
|||||||
|
|
||||||
prefetchQueue.value.shift()
|
prefetchQueue.value.shift()
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
next.skippedIds.map((id) => moderationQueue.completeCurrentProject(id, 'skipped')),
|
|
||||||
)
|
|
||||||
|
|
||||||
notifySkippedQueueProjects(next.skippedIds.length)
|
|
||||||
|
|
||||||
maintainPrefetchQueue()
|
maintainPrefetchQueue()
|
||||||
|
|
||||||
navigateToQueueProject(
|
navigateToQueueProject(
|
||||||
{ slug: next.slug, projectType: next.projectType, locked: false, isProcessing: true },
|
{ slug: next.slug, projectType: next.projectType, locked: false, isProcessing: true },
|
||||||
next.projectId,
|
next.project,
|
||||||
)
|
)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -702,6 +695,7 @@ function markStageVisited(stageId: string | undefined) {
|
|||||||
visitedStages: [...visitedStages.value],
|
visitedStages: [...visitedStages.value],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const reviewedAnyway = ref(persistedState?.reviewAnyway ?? false)
|
const reviewedAnyway = ref(persistedState?.reviewAnyway ?? false)
|
||||||
const message = ref<string | null>(persistedState?.message ?? null)
|
const message = ref<string | null>(persistedState?.message ?? null)
|
||||||
const generatedActiveActions = ref<ActiveAction2[] | null>(null)
|
const generatedActiveActions = ref<ActiveAction2[] | null>(null)
|
||||||
@@ -792,16 +786,6 @@ function reviewAnyway() {
|
|||||||
maintainPrefetchQueue()
|
maintainPrefetchQueue()
|
||||||
}
|
}
|
||||||
|
|
||||||
function notifySkippedQueueProjects(count: number) {
|
|
||||||
if (count <= 0) return
|
|
||||||
addNotification({
|
|
||||||
title: 'Skipped projects',
|
|
||||||
text: `Skipped ${count} project(s) already moderated or locked by others.`,
|
|
||||||
type: 'info',
|
|
||||||
autoCloseMs: 2000,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function navigateToQueueProject(result: QueueCandidateCheck, projectId: string) {
|
function navigateToQueueProject(result: QueueCandidateCheck, projectId: string) {
|
||||||
if (result.slug && result.projectType) {
|
if (result.slug && result.projectType) {
|
||||||
const urlType = getProjectTypeForUrlShorthand(result.projectType, [], tags.value)
|
const urlType = getProjectTypeForUrlShorthand(result.projectType, [], tags.value)
|
||||||
@@ -831,14 +815,14 @@ async function maintainPrefetchQueue() {
|
|||||||
prefetchQueue.value = prefetchQueue.value.filter((p) => now - p.validatedAt < PREFETCH_STALE_MS)
|
prefetchQueue.value = prefetchQueue.value.filter((p) => now - p.validatedAt < PREFETCH_STALE_MS)
|
||||||
|
|
||||||
if (currentProjectId) {
|
if (currentProjectId) {
|
||||||
prefetchQueue.value = prefetchQueue.value.filter((p) => p.projectId !== currentProjectId)
|
prefetchQueue.value = prefetchQueue.value.filter((p) => p.project !== currentProjectId)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (prefetchQueue.value.length >= PREFETCH_TARGET_COUNT) {
|
if (prefetchQueue.value.length >= PREFETCH_TARGET_COUNT) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const prefetchedIds = new Set(prefetchQueue.value.map((p) => p.projectId))
|
const prefetchedIds = new Set(prefetchQueue.value.map((p) => p.project))
|
||||||
const queueItems = [...moderationQueue.currentQueue.items]
|
const queueItems = [...moderationQueue.currentQueue.items]
|
||||||
const currentIndex = currentProjectId ? queueItems.indexOf(currentProjectId) : -1
|
const currentIndex = currentProjectId ? queueItems.indexOf(currentProjectId) : -1
|
||||||
const remainingItems =
|
const remainingItems =
|
||||||
@@ -848,7 +832,6 @@ async function maintainPrefetchQueue() {
|
|||||||
|
|
||||||
if (candidateIds.length === 0) return
|
if (candidateIds.length === 0) return
|
||||||
|
|
||||||
const skippedIds: string[] = []
|
|
||||||
let checkedCount = 0
|
let checkedCount = 0
|
||||||
|
|
||||||
while (
|
while (
|
||||||
@@ -864,16 +847,15 @@ async function maintainPrefetchQueue() {
|
|||||||
const result = results.get(id)
|
const result = results.get(id)
|
||||||
if (isEligibleQueueCandidate(result)) {
|
if (isEligibleQueueCandidate(result)) {
|
||||||
prefetchQueue.value.push({
|
prefetchQueue.value.push({
|
||||||
projectId: id,
|
project: id,
|
||||||
slug: result?.slug ?? '',
|
slug: result?.slug ?? '',
|
||||||
projectType: result?.projectType ?? '',
|
projectType: result?.projectType ?? '',
|
||||||
validatedAt: Date.now(),
|
validatedAt: Date.now(),
|
||||||
skippedIds: [...skippedIds],
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (prefetchQueue.value.length >= PREFETCH_TARGET_COUNT) break
|
if (prefetchQueue.value.length >= PREFETCH_TARGET_COUNT) break
|
||||||
} else {
|
} else {
|
||||||
skippedIds.push(id)
|
void moderationQueue.excludeProject(id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -884,6 +866,21 @@ async function maintainPrefetchQueue() {
|
|||||||
|
|
||||||
const debouncedPrefetch = useDebounceFn(maintainPrefetchQueue, 300)
|
const debouncedPrefetch = useDebounceFn(maintainPrefetchQueue, 300)
|
||||||
|
|
||||||
|
async function goToNextEligibleProject(candidateIds: string[]): Promise<boolean> {
|
||||||
|
if (candidateIds.length === 0) return false
|
||||||
|
|
||||||
|
const next = await findNextEligibleQueueProject(client, moderationQueue, candidateIds)
|
||||||
|
|
||||||
|
if (!next) {
|
||||||
|
await Promise.all(candidateIds.map((id) => moderationQueue.excludeProject(id)))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(next.excluded.map((id) => moderationQueue.excludeProject(id)))
|
||||||
|
navigateToQueueProject(next.result, next.project)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
async function skipToNextProject() {
|
async function skipToNextProject() {
|
||||||
const currentProjectId = projectV2.value?.id
|
const currentProjectId = projectV2.value?.id
|
||||||
if (!currentProjectId) {
|
if (!currentProjectId) {
|
||||||
@@ -893,7 +890,7 @@ async function skipToNextProject() {
|
|||||||
debug('[skipToNextProject] Starting. Current project:', currentProjectId)
|
debug('[skipToNextProject] Starting. Current project:', currentProjectId)
|
||||||
debug('[skipToNextProject] Queue before complete:', [...moderationQueue.currentQueue.items])
|
debug('[skipToNextProject] Queue before complete:', [...moderationQueue.currentQueue.items])
|
||||||
|
|
||||||
await moderationQueue.completeCurrentProject(currentProjectId, 'skipped')
|
await moderationQueue.deferProject(currentProjectId)
|
||||||
|
|
||||||
debug('[skipToNextProject] Queue after complete:', [...moderationQueue.currentQueue.items])
|
debug('[skipToNextProject] Queue after complete:', [...moderationQueue.currentQueue.items])
|
||||||
debug('[skipToNextProject] hasItems:', moderationQueue.hasItems)
|
debug('[skipToNextProject] hasItems:', moderationQueue.hasItems)
|
||||||
@@ -908,20 +905,7 @@ async function skipToNextProject() {
|
|||||||
const remainingIds = moderationQueue.currentQueue.items.filter((id) => id !== currentProjectId)
|
const remainingIds = moderationQueue.currentQueue.items.filter((id) => id !== currentProjectId)
|
||||||
|
|
||||||
if (remainingIds.length > 0) {
|
if (remainingIds.length > 0) {
|
||||||
const next = await findNextEligibleQueueProject(client, moderationQueue, remainingIds)
|
if (await goToNextEligibleProject(remainingIds)) return
|
||||||
|
|
||||||
if (next) {
|
|
||||||
await Promise.all(
|
|
||||||
next.skippedIds.map((id) => moderationQueue.completeCurrentProject(id, 'skipped')),
|
|
||||||
)
|
|
||||||
notifySkippedQueueProjects(next.skippedIds.length)
|
|
||||||
navigateToQueueProject(next.result, next.projectId)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
remainingIds.map((id) => moderationQueue.completeCurrentProject(id, 'skipped')),
|
|
||||||
)
|
|
||||||
|
|
||||||
debug('[skipToNextProject] No eligible projects in queue')
|
debug('[skipToNextProject] No eligible projects in queue')
|
||||||
addNotification({
|
addNotification({
|
||||||
@@ -1531,6 +1515,7 @@ async function generateMessage() {
|
|||||||
if (loadingMessage.value) return
|
if (loadingMessage.value) return
|
||||||
|
|
||||||
loadingMessage.value = true
|
loadingMessage.value = true
|
||||||
|
markStageVisited(currentStageObj.value.id)
|
||||||
|
|
||||||
router.push(`/${projectUrlType.value}/${projectV2.value.slug}/moderation`)
|
router.push(`/${projectUrlType.value}/${projectV2.value.slug}/moderation`)
|
||||||
|
|
||||||
@@ -1665,7 +1650,7 @@ async function sendMessage(status: ProjectStatus) {
|
|||||||
|
|
||||||
await refreshModerationCaches(threadId)
|
await refreshModerationCaches(threadId)
|
||||||
|
|
||||||
const willHaveNext = await moderationQueue.completeCurrentProject(projectId, 'completed')
|
const willHaveNext = await moderationQueue.completeProject(projectId)
|
||||||
|
|
||||||
await Promise.race([
|
await Promise.race([
|
||||||
moderationQueue.releaseLock(projectId),
|
moderationQueue.releaseLock(projectId),
|
||||||
@@ -1701,22 +1686,23 @@ async function endChecklist(status?: string) {
|
|||||||
await clearProjectLocalStorage()
|
await clearProjectLocalStorage()
|
||||||
|
|
||||||
if (!hasNextProject.value) {
|
if (!hasNextProject.value) {
|
||||||
|
const currentProjectId = projectV2.value?.id
|
||||||
|
const isRealQueue =
|
||||||
|
!!currentProjectId &&
|
||||||
|
(moderationQueue.currentQueue.completed.includes(currentProjectId) ||
|
||||||
|
moderationQueue.currentQueue.skipped.includes(currentProjectId))
|
||||||
|
|
||||||
await navigateTo({
|
await navigateTo({
|
||||||
name: 'moderation',
|
name: 'moderation',
|
||||||
state: {
|
state: {
|
||||||
confetti: true,
|
confetti: true,
|
||||||
|
queueSummary: isRealQueue,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
if (moderationQueue.currentQueue.total > 1) {
|
if (!isRealQueue) {
|
||||||
addNotification({
|
|
||||||
title: 'Moderation completed',
|
|
||||||
text: `You have completed the moderation queue.`,
|
|
||||||
type: 'success',
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
addNotification({
|
addNotification({
|
||||||
title: 'Moderation submitted',
|
title: 'Moderation submitted',
|
||||||
text: `Project ${status ?? 'completed successfully'}.`,
|
text: `Project ${status ?? 'completed successfully'}.`,
|
||||||
@@ -1730,30 +1716,15 @@ async function endChecklist(status?: string) {
|
|||||||
(id) => id !== currentProjectId,
|
(id) => id !== currentProjectId,
|
||||||
)
|
)
|
||||||
|
|
||||||
let foundEligible = false
|
if (!(await goToNextEligibleProject(remainingIds))) {
|
||||||
if (remainingIds.length > 0) {
|
if (remainingIds.length > 0) {
|
||||||
const next = await findNextEligibleQueueProject(client, moderationQueue, remainingIds)
|
|
||||||
|
|
||||||
if (next) {
|
|
||||||
await Promise.all(
|
|
||||||
next.skippedIds.map((id) => moderationQueue.completeCurrentProject(id, 'skipped')),
|
|
||||||
)
|
|
||||||
notifySkippedQueueProjects(next.skippedIds.length)
|
|
||||||
navigateToQueueProject(next.result, next.projectId)
|
|
||||||
foundEligible = true
|
|
||||||
} else {
|
|
||||||
await Promise.all(
|
|
||||||
remainingIds.map((id) => moderationQueue.completeCurrentProject(id, 'skipped')),
|
|
||||||
)
|
|
||||||
addNotification({
|
addNotification({
|
||||||
title: 'No projects available',
|
title: 'No projects available',
|
||||||
text: 'All remaining projects are already moderated or locked by others.',
|
text: 'All remaining projects are already moderated or locked by others.',
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (!foundEligible) {
|
|
||||||
await navigateTo({
|
await navigateTo({
|
||||||
name: 'moderation',
|
name: 'moderation',
|
||||||
})
|
})
|
||||||
@@ -1778,7 +1749,7 @@ async function skipCurrentProject() {
|
|||||||
new Promise((r) => setTimeout(r, 2000)),
|
new Promise((r) => setTimeout(r, 2000)),
|
||||||
])
|
])
|
||||||
|
|
||||||
hasNextProject.value = await moderationQueue.completeCurrentProject(projectId, 'skipped')
|
hasNextProject.value = await moderationQueue.deferProject(projectId)
|
||||||
|
|
||||||
await endChecklist('skipped')
|
await endChecklist('skipped')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,12 @@
|
|||||||
@switch-page="goToPage"
|
@switch-page="goToPage"
|
||||||
/>
|
/>
|
||||||
<ConfettiExplosion v-if="visible" />
|
<ConfettiExplosion v-if="visible" />
|
||||||
|
<QueueSummaryModal
|
||||||
|
ref="queueSummaryModal"
|
||||||
|
:completed-ids="moderationQueue.currentQueue.completed"
|
||||||
|
:skipped-ids="moderationQueue.currentQueue.skipped"
|
||||||
|
@review-skipped="reviewSkippedQueue"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
@@ -157,6 +163,7 @@ import { useQuery } from '@tanstack/vue-query'
|
|||||||
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 QueueSummaryModal from '~/components/ui/moderation/QueueSummaryModal.vue'
|
||||||
import { type ModerationProject, toModerationProjects } from '~/helpers/moderation.ts'
|
import { type ModerationProject, toModerationProjects } from '~/helpers/moderation.ts'
|
||||||
import { findNextEligibleQueueProject } from '~/services/moderation/queue-eligibility.ts'
|
import { findNextEligibleQueueProject } from '~/services/moderation/queue-eligibility.ts'
|
||||||
import { useModerationQueue } from '~/services/moderation/queue.ts'
|
import { useModerationQueue } from '~/services/moderation/queue.ts'
|
||||||
@@ -170,6 +177,8 @@ const route = useRoute()
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const client = injectModrinthClient()
|
const client = injectModrinthClient()
|
||||||
|
|
||||||
|
const queueSummaryModal = ref()
|
||||||
|
|
||||||
const visible = ref(false)
|
const visible = ref(false)
|
||||||
if (import.meta.client && history && history.state && history.state.confetti) {
|
if (import.meta.client && history && history.state && history.state.confetti) {
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
@@ -182,6 +191,14 @@ if (import.meta.client && history && history.state && history.state.confetti) {
|
|||||||
}, 1000)
|
}, 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (import.meta.client && history && history.state && history.state.queueSummary) {
|
||||||
|
setTimeout(async () => {
|
||||||
|
history.state.queueSummary = false
|
||||||
|
await nextTick()
|
||||||
|
queueSummaryModal.value?.show()
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
moderate: {
|
moderate: {
|
||||||
id: 'moderation.moderate',
|
id: 'moderation.moderate',
|
||||||
@@ -496,16 +513,6 @@ function goToPage(page: number) {
|
|||||||
currentPage.value = page
|
currentPage.value = page
|
||||||
}
|
}
|
||||||
|
|
||||||
function notifySkippedProjects(skippedCount: number) {
|
|
||||||
if (skippedCount <= 0) return
|
|
||||||
addNotification({
|
|
||||||
title: 'Skipped projects',
|
|
||||||
text: `Skipped ${skippedCount} project(s) already moderated or locked by others.`,
|
|
||||||
type: 'info',
|
|
||||||
autoCloseMs: 2000,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function findFirstEligibleProject(): Promise<string | null> {
|
async function findFirstEligibleProject(): Promise<string | null> {
|
||||||
const candidateIds = [...moderationQueue.currentQueue.items]
|
const candidateIds = [...moderationQueue.currentQueue.items]
|
||||||
if (candidateIds.length === 0) return null
|
if (candidateIds.length === 0) return null
|
||||||
@@ -513,18 +520,12 @@ async function findFirstEligibleProject(): Promise<string | null> {
|
|||||||
const next = await findNextEligibleQueueProject(client, moderationQueue, candidateIds)
|
const next = await findNextEligibleQueueProject(client, moderationQueue, candidateIds)
|
||||||
|
|
||||||
if (!next) {
|
if (!next) {
|
||||||
await Promise.all(
|
await Promise.all(candidateIds.map((id) => moderationQueue.excludeProject(id)))
|
||||||
candidateIds.map((id) => moderationQueue.completeCurrentProject(id, 'skipped')),
|
|
||||||
)
|
|
||||||
notifySkippedProjects(candidateIds.length)
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(next.excluded.map((id) => moderationQueue.excludeProject(id)))
|
||||||
next.skippedIds.map((id) => moderationQueue.completeCurrentProject(id, 'skipped')),
|
return next.project
|
||||||
)
|
|
||||||
notifySkippedProjects(next.skippedIds.length)
|
|
||||||
return next.projectId
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getProjectRouteParam(projectId: string): string {
|
function getProjectRouteParam(projectId: string): string {
|
||||||
@@ -577,12 +578,9 @@ async function moderateAllInFilter() {
|
|||||||
async function startFromProject(projectId: string) {
|
async function startFromProject(projectId: string) {
|
||||||
const allFilteredProjectIds = await getFilteredProjectIds()
|
const allFilteredProjectIds = await getFilteredProjectIds()
|
||||||
const projectIndex = allFilteredProjectIds.indexOf(projectId)
|
const projectIndex = allFilteredProjectIds.indexOf(projectId)
|
||||||
if (projectIndex === -1) {
|
const projectIds =
|
||||||
await moderationQueue.setSingleProject(projectId)
|
projectIndex === -1 ? [projectId] : allFilteredProjectIds.slice(projectIndex)
|
||||||
} else {
|
await moderationQueue.setQueue(projectIds)
|
||||||
const projectIds = allFilteredProjectIds.slice(projectIndex)
|
|
||||||
await moderationQueue.setQueue(projectIds)
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetProjectId = await findFirstEligibleProject()
|
const targetProjectId = await findFirstEligibleProject()
|
||||||
|
|
||||||
@@ -597,4 +595,21 @@ async function startFromProject(projectId: string) {
|
|||||||
|
|
||||||
await navigateToModerationProject(targetProjectId)
|
await navigateToModerationProject(targetProjectId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function reviewSkippedQueue() {
|
||||||
|
await moderationQueue.startSkippedReview()
|
||||||
|
|
||||||
|
const targetProjectId = await findFirstEligibleProject()
|
||||||
|
|
||||||
|
if (!targetProjectId) {
|
||||||
|
addNotification({
|
||||||
|
title: 'No projects available',
|
||||||
|
text: 'All previously skipped projects are already moderated or locked by others.',
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await navigateToModerationProject(targetProjectId)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ export interface QueueCandidateCheck {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface EligibleQueueProject {
|
export interface EligibleQueueProject {
|
||||||
projectId: string
|
project: string
|
||||||
result: QueueCandidateCheck
|
result: QueueCandidateCheck
|
||||||
skippedIds: string[]
|
excluded: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const BATCH_SIZE = 5
|
const BATCH_SIZE = 5
|
||||||
@@ -69,7 +69,7 @@ export async function findNextEligibleQueueProject(
|
|||||||
moderationQueue: ModerationQueueService,
|
moderationQueue: ModerationQueueService,
|
||||||
candidateIds: string[],
|
candidateIds: string[],
|
||||||
): Promise<EligibleQueueProject | null> {
|
): Promise<EligibleQueueProject | null> {
|
||||||
const skippedIds: string[] = []
|
const excluded: string[] = []
|
||||||
let checkedCount = 0
|
let checkedCount = 0
|
||||||
|
|
||||||
while (checkedCount < candidateIds.length) {
|
while (checkedCount < candidateIds.length) {
|
||||||
@@ -81,9 +81,9 @@ export async function findNextEligibleQueueProject(
|
|||||||
for (const id of batch) {
|
for (const id of batch) {
|
||||||
const result = results.get(id)
|
const result = results.get(id)
|
||||||
if (isEligibleQueueCandidate(result)) {
|
if (isEligibleQueueCandidate(result)) {
|
||||||
return { projectId: id, result: result!, skippedIds: [...skippedIds] }
|
return { project: id, result: result!, excluded: [...excluded] }
|
||||||
}
|
}
|
||||||
skippedIds.push(id)
|
excluded.push(id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ export interface PersistedModerationQueueState {
|
|||||||
savedAt: string
|
savedAt: string
|
||||||
currentQueue: {
|
currentQueue: {
|
||||||
items: string[]
|
items: string[]
|
||||||
|
skipped?: string[]
|
||||||
total: number
|
total: number
|
||||||
completed: number
|
completed: string[]
|
||||||
skipped: number
|
|
||||||
lastUpdated: string
|
lastUpdated: string
|
||||||
}
|
}
|
||||||
isQueueMode: boolean
|
isQueueMode: boolean
|
||||||
@@ -31,9 +31,9 @@ function isPersistedStateCandidate(value: unknown): value is PersistedModeration
|
|||||||
const queue = candidate.currentQueue
|
const queue = candidate.currentQueue
|
||||||
if (!queue || typeof queue !== 'object') return false
|
if (!queue || typeof queue !== 'object') return false
|
||||||
if (!isStringArray(queue.items)) return false
|
if (!isStringArray(queue.items)) return false
|
||||||
|
if (queue.skipped !== undefined && !isStringArray(queue.skipped)) return false
|
||||||
if (typeof queue.total !== 'number' || Number.isNaN(queue.total)) return false
|
if (typeof queue.total !== 'number' || Number.isNaN(queue.total)) return false
|
||||||
if (typeof queue.completed !== 'number' || Number.isNaN(queue.completed)) return false
|
if (!isStringArray(queue.completed)) return false
|
||||||
if (typeof queue.skipped !== 'number' || Number.isNaN(queue.skipped)) return false
|
|
||||||
if (typeof queue.lastUpdated !== 'string') return false
|
if (typeof queue.lastUpdated !== 'string') return false
|
||||||
|
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import {
|
|||||||
|
|
||||||
export interface ModerationQueue {
|
export interface ModerationQueue {
|
||||||
items: string[]
|
items: string[]
|
||||||
|
skipped: string[]
|
||||||
total: number
|
total: number
|
||||||
completed: number
|
completed: string[]
|
||||||
skipped: number
|
|
||||||
lastUpdated: Date
|
lastUpdated: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,11 +29,14 @@ export interface ModerationQueueService {
|
|||||||
|
|
||||||
queueLength: number
|
queueLength: number
|
||||||
hasItems: boolean
|
hasItems: boolean
|
||||||
|
hasSkipped: boolean
|
||||||
progress: number
|
progress: number
|
||||||
|
|
||||||
setQueue(projectIds: string[]): Promise<void>
|
setQueue(projectIds: string[]): Promise<void>
|
||||||
setSingleProject(projectId: string): Promise<void>
|
completeProject(projectId: string): Promise<boolean>
|
||||||
completeCurrentProject(projectId: string, status?: 'completed' | 'skipped'): Promise<boolean>
|
deferProject(projectId: string): Promise<boolean>
|
||||||
|
excludeProject(projectId: string): Promise<boolean>
|
||||||
|
startSkippedReview(): Promise<void>
|
||||||
getCurrentProjectId(): string | null
|
getCurrentProjectId(): string | null
|
||||||
resetQueue(): Promise<void>
|
resetQueue(): Promise<void>
|
||||||
|
|
||||||
@@ -46,31 +49,31 @@ export interface ModerationQueueService {
|
|||||||
|
|
||||||
const EMPTY_QUEUE: ModerationQueue = {
|
const EMPTY_QUEUE: ModerationQueue = {
|
||||||
items: [],
|
items: [],
|
||||||
|
skipped: [],
|
||||||
total: 0,
|
total: 0,
|
||||||
completed: 0,
|
completed: [],
|
||||||
skipped: 0,
|
|
||||||
lastUpdated: new Date(),
|
lastUpdated: new Date(),
|
||||||
}
|
}
|
||||||
|
|
||||||
function createEmptyQueue(): ModerationQueue {
|
function createEmptyQueue(): ModerationQueue {
|
||||||
return { ...EMPTY_QUEUE, lastUpdated: new Date(), items: [] }
|
return { ...EMPTY_QUEUE, lastUpdated: new Date(), items: [], skipped: [], completed: [] }
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeQueue(raw: PersistedModerationQueueState['currentQueue']): ModerationQueue {
|
function sanitizeQueue(raw: PersistedModerationQueueState['currentQueue']): ModerationQueue {
|
||||||
const lastUpdated = new Date(raw.lastUpdated)
|
const lastUpdated = new Date(raw.lastUpdated)
|
||||||
const items = raw.items.filter((id): id is string => typeof id === 'string')
|
const items = raw.items.filter((id): id is string => typeof id === 'string')
|
||||||
const completed = Number.isFinite(raw.completed) ? Math.max(Math.trunc(raw.completed), 0) : 0
|
const skipped = (raw.skipped ?? []).filter((id): id is string => typeof id === 'string')
|
||||||
const skipped = Number.isFinite(raw.skipped) ? Math.max(Math.trunc(raw.skipped), 0) : 0
|
const completed = (raw.completed ?? []).filter((id): id is string => typeof id === 'string')
|
||||||
const minimumTotal = items.length + completed + skipped
|
const minimumTotal = items.length + completed.length
|
||||||
const total = Number.isFinite(raw.total)
|
const total = Number.isFinite(raw.total)
|
||||||
? Math.max(Math.trunc(raw.total), minimumTotal)
|
? Math.max(Math.trunc(raw.total), minimumTotal)
|
||||||
: minimumTotal
|
: minimumTotal
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items,
|
items,
|
||||||
|
skipped,
|
||||||
total,
|
total,
|
||||||
completed,
|
completed,
|
||||||
skipped,
|
|
||||||
lastUpdated: Number.isNaN(lastUpdated.getTime()) ? new Date() : lastUpdated,
|
lastUpdated: Number.isNaN(lastUpdated.getTime()) ? new Date() : lastUpdated,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,9 +87,9 @@ function persistedPayload(
|
|||||||
savedAt: new Date().toISOString(),
|
savedAt: new Date().toISOString(),
|
||||||
currentQueue: {
|
currentQueue: {
|
||||||
items: [...queue.items],
|
items: [...queue.items],
|
||||||
|
skipped: [...queue.skipped],
|
||||||
total: queue.total,
|
total: queue.total,
|
||||||
completed: queue.completed,
|
completed: [...queue.completed],
|
||||||
skipped: queue.skipped,
|
|
||||||
lastUpdated: queue.lastUpdated.toISOString(),
|
lastUpdated: queue.lastUpdated.toISOString(),
|
||||||
},
|
},
|
||||||
isQueueMode,
|
isQueueMode,
|
||||||
@@ -101,9 +104,10 @@ function createModerationQueueState(client: AbstractModrinthClient = injectModri
|
|||||||
|
|
||||||
const queueLength = computed(() => currentQueue.value.items.length)
|
const queueLength = computed(() => currentQueue.value.items.length)
|
||||||
const hasItems = computed(() => currentQueue.value.items.length > 0)
|
const hasItems = computed(() => currentQueue.value.items.length > 0)
|
||||||
|
const hasSkipped = computed(() => currentQueue.value.skipped.length > 0)
|
||||||
const progress = computed(() => {
|
const progress = computed(() => {
|
||||||
if (currentQueue.value.total === 0) return 0
|
if (currentQueue.value.total === 0) return 0
|
||||||
return (currentQueue.value.completed + currentQueue.value.skipped) / currentQueue.value.total
|
return (currentQueue.value.total - currentQueue.value.items.length) / currentQueue.value.total
|
||||||
})
|
})
|
||||||
let mutationChain = Promise.resolve()
|
let mutationChain = Promise.resolve()
|
||||||
|
|
||||||
@@ -148,42 +152,41 @@ function createModerationQueueState(client: AbstractModrinthClient = injectModri
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
function setQueueState(items: string[], mode: boolean) {
|
function setQueueState(items: string[]) {
|
||||||
isQueueMode.value = mode
|
isQueueMode.value = true
|
||||||
currentQueue.value = {
|
currentQueue.value = {
|
||||||
items: [...items],
|
items: [...items],
|
||||||
|
skipped: [],
|
||||||
total: items.length,
|
total: items.length,
|
||||||
completed: 0,
|
completed: [],
|
||||||
skipped: 0,
|
|
||||||
lastUpdated: new Date(),
|
lastUpdated: new Date(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setQueue(projectIds: string[]): Promise<void> {
|
async function setQueue(projectIds: string[]): Promise<void> {
|
||||||
await withMutation(() => {
|
await withMutation(() => {
|
||||||
setQueueState(projectIds, true)
|
setQueueState(projectIds)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setSingleProject(projectId: string): Promise<void> {
|
async function completeProject(projectId: string): Promise<boolean> {
|
||||||
await withMutation(() => {
|
|
||||||
setQueueState([projectId], false)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function completeCurrentProject(
|
|
||||||
projectId: string,
|
|
||||||
status: 'completed' | 'skipped' = 'completed',
|
|
||||||
): Promise<boolean> {
|
|
||||||
return withMutation(() => {
|
return withMutation(() => {
|
||||||
if (!currentQueue.value.items.includes(projectId)) {
|
if (!currentQueue.value.items.includes(projectId)) {
|
||||||
return currentQueue.value.items.length > 0
|
return currentQueue.value.items.length > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'completed') {
|
currentQueue.value.completed = [...currentQueue.value.completed, projectId]
|
||||||
currentQueue.value.completed++
|
currentQueue.value.items = currentQueue.value.items.filter((id) => id !== projectId)
|
||||||
} else {
|
currentQueue.value.lastUpdated = new Date()
|
||||||
currentQueue.value.skipped++
|
|
||||||
|
return currentQueue.value.items.length > 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function excludeProject(projectId: string): Promise<boolean> {
|
||||||
|
return withMutation(() => {
|
||||||
|
if (!currentQueue.value.items.includes(projectId)) {
|
||||||
|
return currentQueue.value.items.length > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
currentQueue.value.items = currentQueue.value.items.filter((id) => id !== projectId)
|
currentQueue.value.items = currentQueue.value.items.filter((id) => id !== projectId)
|
||||||
@@ -193,6 +196,26 @@ function createModerationQueueState(client: AbstractModrinthClient = injectModri
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deferProject(projectId: string): Promise<boolean> {
|
||||||
|
return withMutation(() => {
|
||||||
|
if (!currentQueue.value.items.includes(projectId)) {
|
||||||
|
return currentQueue.value.items.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
currentQueue.value.items = currentQueue.value.items.filter((id) => id !== projectId)
|
||||||
|
currentQueue.value.skipped = [...currentQueue.value.skipped, projectId]
|
||||||
|
currentQueue.value.lastUpdated = new Date()
|
||||||
|
|
||||||
|
return currentQueue.value.items.length > 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startSkippedReview(): Promise<void> {
|
||||||
|
await withMutation(() => {
|
||||||
|
setQueueState(currentQueue.value.skipped)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function getCurrentProjectId(): string | null {
|
function getCurrentProjectId(): string | null {
|
||||||
return currentQueue.value.items[0] || null
|
return currentQueue.value.items[0] || null
|
||||||
}
|
}
|
||||||
@@ -294,11 +317,14 @@ function createModerationQueueState(client: AbstractModrinthClient = injectModri
|
|||||||
|
|
||||||
queueLength,
|
queueLength,
|
||||||
hasItems,
|
hasItems,
|
||||||
|
hasSkipped,
|
||||||
progress,
|
progress,
|
||||||
|
|
||||||
setQueue,
|
setQueue,
|
||||||
setSingleProject,
|
completeProject,
|
||||||
completeCurrentProject,
|
deferProject,
|
||||||
|
excludeProject,
|
||||||
|
startSkippedReview,
|
||||||
getCurrentProjectId,
|
getCurrentProjectId,
|
||||||
resetQueue,
|
resetQueue,
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user