more queue upgrades

This commit is contained in:
chyzman
2026-07-30 16:44:04 -04:00
parent d1a49dc415
commit 7119594262
6 changed files with 263 additions and 137 deletions
@@ -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
interface PrefetchedProject {
projectId: string
project: string
slug: string // For canonical URL navigation
projectType: string // For canonical URL navigation
validatedAt: number
skippedIds: string[] // IDs that were locked when this was prefetched
}
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)
if (now - next.validatedAt > PREFETCH_STALE_MS - 5000) {
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)) {
prefetchQueue.value.shift()
return navigateToNextUnlockedProject()
@@ -657,17 +656,11 @@ async function navigateToNextUnlockedProject(): Promise<boolean> {
prefetchQueue.value.shift()
await Promise.all(
next.skippedIds.map((id) => moderationQueue.completeCurrentProject(id, 'skipped')),
)
notifySkippedQueueProjects(next.skippedIds.length)
maintainPrefetchQueue()
navigateToQueueProject(
{ slug: next.slug, projectType: next.projectType, locked: false, isProcessing: true },
next.projectId,
next.project,
)
return true
}
@@ -702,6 +695,7 @@ function markStageVisited(stageId: string | undefined) {
visitedStages: [...visitedStages.value],
})
}
const reviewedAnyway = ref(persistedState?.reviewAnyway ?? false)
const message = ref<string | null>(persistedState?.message ?? null)
const generatedActiveActions = ref<ActiveAction2[] | null>(null)
@@ -792,16 +786,6 @@ function reviewAnyway() {
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) {
if (result.slug && result.projectType) {
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)
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) {
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 currentIndex = currentProjectId ? queueItems.indexOf(currentProjectId) : -1
const remainingItems =
@@ -848,7 +832,6 @@ async function maintainPrefetchQueue() {
if (candidateIds.length === 0) return
const skippedIds: string[] = []
let checkedCount = 0
while (
@@ -864,16 +847,15 @@ async function maintainPrefetchQueue() {
const result = results.get(id)
if (isEligibleQueueCandidate(result)) {
prefetchQueue.value.push({
projectId: id,
project: id,
slug: result?.slug ?? '',
projectType: result?.projectType ?? '',
validatedAt: Date.now(),
skippedIds: [...skippedIds],
})
if (prefetchQueue.value.length >= PREFETCH_TARGET_COUNT) break
} else {
skippedIds.push(id)
void moderationQueue.excludeProject(id)
}
}
}
@@ -884,6 +866,21 @@ async function maintainPrefetchQueue() {
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() {
const currentProjectId = projectV2.value?.id
if (!currentProjectId) {
@@ -893,7 +890,7 @@ async function skipToNextProject() {
debug('[skipToNextProject] Starting. Current project:', currentProjectId)
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] hasItems:', moderationQueue.hasItems)
@@ -908,20 +905,7 @@ async function skipToNextProject() {
const remainingIds = moderationQueue.currentQueue.items.filter((id) => id !== currentProjectId)
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)
return
}
await Promise.all(
remainingIds.map((id) => moderationQueue.completeCurrentProject(id, 'skipped')),
)
if (await goToNextEligibleProject(remainingIds)) return
debug('[skipToNextProject] No eligible projects in queue')
addNotification({
@@ -1531,6 +1515,7 @@ async function generateMessage() {
if (loadingMessage.value) return
loadingMessage.value = true
markStageVisited(currentStageObj.value.id)
router.push(`/${projectUrlType.value}/${projectV2.value.slug}/moderation`)
@@ -1665,7 +1650,7 @@ async function sendMessage(status: ProjectStatus) {
await refreshModerationCaches(threadId)
const willHaveNext = await moderationQueue.completeCurrentProject(projectId, 'completed')
const willHaveNext = await moderationQueue.completeProject(projectId)
await Promise.race([
moderationQueue.releaseLock(projectId),
@@ -1701,22 +1686,23 @@ async function endChecklist(status?: string) {
await clearProjectLocalStorage()
if (!hasNextProject.value) {
const currentProjectId = projectV2.value?.id
const isRealQueue =
!!currentProjectId &&
(moderationQueue.currentQueue.completed.includes(currentProjectId) ||
moderationQueue.currentQueue.skipped.includes(currentProjectId))
await navigateTo({
name: 'moderation',
state: {
confetti: true,
queueSummary: isRealQueue,
},
})
await nextTick()
if (moderationQueue.currentQueue.total > 1) {
addNotification({
title: 'Moderation completed',
text: `You have completed the moderation queue.`,
type: 'success',
})
} else {
if (!isRealQueue) {
addNotification({
title: 'Moderation submitted',
text: `Project ${status ?? 'completed successfully'}.`,
@@ -1730,30 +1716,15 @@ async function endChecklist(status?: string) {
(id) => id !== currentProjectId,
)
let foundEligible = false
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')),
)
if (!(await goToNextEligibleProject(remainingIds))) {
if (remainingIds.length > 0) {
addNotification({
title: 'No projects available',
text: 'All remaining projects are already moderated or locked by others.',
type: 'warning',
})
}
}
if (!foundEligible) {
await navigateTo({
name: 'moderation',
})
@@ -1778,7 +1749,7 @@ async function skipCurrentProject() {
new Promise((r) => setTimeout(r, 2000)),
])
hasNextProject.value = await moderationQueue.completeCurrentProject(projectId, 'skipped')
hasNextProject.value = await moderationQueue.deferProject(projectId)
await endChecklist('skipped')
}
+40 -25
View File
@@ -105,6 +105,12 @@
@switch-page="goToPage"
/>
<ConfettiExplosion v-if="visible" />
<QueueSummaryModal
ref="queueSummaryModal"
:completed-ids="moderationQueue.currentQueue.completed"
:skipped-ids="moderationQueue.currentQueue.skipped"
@review-skipped="reviewSkippedQueue"
/>
</div>
<div class="flex flex-col gap-3">
@@ -157,6 +163,7 @@ import { useQuery } from '@tanstack/vue-query'
import ConfettiExplosion from 'vue-confetti-explosion'
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 { findNextEligibleQueueProject } from '~/services/moderation/queue-eligibility.ts'
import { useModerationQueue } from '~/services/moderation/queue.ts'
@@ -170,6 +177,8 @@ const route = useRoute()
const router = useRouter()
const client = injectModrinthClient()
const queueSummaryModal = ref()
const visible = ref(false)
if (import.meta.client && history && history.state && history.state.confetti) {
setTimeout(async () => {
@@ -182,6 +191,14 @@ if (import.meta.client && history && history.state && history.state.confetti) {
}, 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({
moderate: {
id: 'moderation.moderate',
@@ -496,16 +513,6 @@ function goToPage(page: number) {
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> {
const candidateIds = [...moderationQueue.currentQueue.items]
if (candidateIds.length === 0) return null
@@ -513,18 +520,12 @@ async function findFirstEligibleProject(): Promise<string | null> {
const next = await findNextEligibleQueueProject(client, moderationQueue, candidateIds)
if (!next) {
await Promise.all(
candidateIds.map((id) => moderationQueue.completeCurrentProject(id, 'skipped')),
)
notifySkippedProjects(candidateIds.length)
await Promise.all(candidateIds.map((id) => moderationQueue.excludeProject(id)))
return null
}
await Promise.all(
next.skippedIds.map((id) => moderationQueue.completeCurrentProject(id, 'skipped')),
)
notifySkippedProjects(next.skippedIds.length)
return next.projectId
await Promise.all(next.excluded.map((id) => moderationQueue.excludeProject(id)))
return next.project
}
function getProjectRouteParam(projectId: string): string {
@@ -577,12 +578,9 @@ async function moderateAllInFilter() {
async function startFromProject(projectId: string) {
const allFilteredProjectIds = await getFilteredProjectIds()
const projectIndex = allFilteredProjectIds.indexOf(projectId)
if (projectIndex === -1) {
await moderationQueue.setSingleProject(projectId)
} else {
const projectIds = allFilteredProjectIds.slice(projectIndex)
await moderationQueue.setQueue(projectIds)
}
const projectIds =
projectIndex === -1 ? [projectId] : allFilteredProjectIds.slice(projectIndex)
await moderationQueue.setQueue(projectIds)
const targetProjectId = await findFirstEligibleProject()
@@ -597,4 +595,21 @@ async function startFromProject(projectId: string) {
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>
@@ -13,9 +13,9 @@ export interface QueueCandidateCheck {
}
export interface EligibleQueueProject {
projectId: string
project: string
result: QueueCandidateCheck
skippedIds: string[]
excluded: string[]
}
const BATCH_SIZE = 5
@@ -69,7 +69,7 @@ export async function findNextEligibleQueueProject(
moderationQueue: ModerationQueueService,
candidateIds: string[],
): Promise<EligibleQueueProject | null> {
const skippedIds: string[] = []
const excluded: string[] = []
let checkedCount = 0
while (checkedCount < candidateIds.length) {
@@ -81,9 +81,9 @@ export async function findNextEligibleQueueProject(
for (const id of batch) {
const result = results.get(id)
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
currentQueue: {
items: string[]
skipped?: string[]
total: number
completed: number
skipped: number
completed: string[]
lastUpdated: string
}
isQueueMode: boolean
@@ -31,9 +31,9 @@ function isPersistedStateCandidate(value: unknown): value is PersistedModeration
const queue = candidate.currentQueue
if (!queue || typeof queue !== 'object') 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.completed !== 'number' || Number.isNaN(queue.completed)) return false
if (typeof queue.skipped !== 'number' || Number.isNaN(queue.skipped)) return false
if (!isStringArray(queue.completed)) return false
if (typeof queue.lastUpdated !== 'string') return false
return true
+61 -35
View File
@@ -10,9 +10,9 @@ import {
export interface ModerationQueue {
items: string[]
skipped: string[]
total: number
completed: number
skipped: number
completed: string[]
lastUpdated: Date
}
@@ -29,11 +29,14 @@ export interface ModerationQueueService {
queueLength: number
hasItems: boolean
hasSkipped: boolean
progress: number
setQueue(projectIds: string[]): Promise<void>
setSingleProject(projectId: string): Promise<void>
completeCurrentProject(projectId: string, status?: 'completed' | 'skipped'): Promise<boolean>
completeProject(projectId: string): Promise<boolean>
deferProject(projectId: string): Promise<boolean>
excludeProject(projectId: string): Promise<boolean>
startSkippedReview(): Promise<void>
getCurrentProjectId(): string | null
resetQueue(): Promise<void>
@@ -46,31 +49,31 @@ export interface ModerationQueueService {
const EMPTY_QUEUE: ModerationQueue = {
items: [],
skipped: [],
total: 0,
completed: 0,
skipped: 0,
completed: [],
lastUpdated: new Date(),
}
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 {
const lastUpdated = new Date(raw.lastUpdated)
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 = Number.isFinite(raw.skipped) ? Math.max(Math.trunc(raw.skipped), 0) : 0
const minimumTotal = items.length + completed + skipped
const skipped = (raw.skipped ?? []).filter((id): id is string => typeof id === 'string')
const completed = (raw.completed ?? []).filter((id): id is string => typeof id === 'string')
const minimumTotal = items.length + completed.length
const total = Number.isFinite(raw.total)
? Math.max(Math.trunc(raw.total), minimumTotal)
: minimumTotal
return {
items,
skipped,
total,
completed,
skipped,
lastUpdated: Number.isNaN(lastUpdated.getTime()) ? new Date() : lastUpdated,
}
}
@@ -84,9 +87,9 @@ function persistedPayload(
savedAt: new Date().toISOString(),
currentQueue: {
items: [...queue.items],
skipped: [...queue.skipped],
total: queue.total,
completed: queue.completed,
skipped: queue.skipped,
completed: [...queue.completed],
lastUpdated: queue.lastUpdated.toISOString(),
},
isQueueMode,
@@ -101,9 +104,10 @@ function createModerationQueueState(client: AbstractModrinthClient = injectModri
const queueLength = computed(() => currentQueue.value.items.length)
const hasItems = computed(() => currentQueue.value.items.length > 0)
const hasSkipped = computed(() => currentQueue.value.skipped.length > 0)
const progress = computed(() => {
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()
@@ -148,42 +152,41 @@ function createModerationQueueState(client: AbstractModrinthClient = injectModri
return result
}
function setQueueState(items: string[], mode: boolean) {
isQueueMode.value = mode
function setQueueState(items: string[]) {
isQueueMode.value = true
currentQueue.value = {
items: [...items],
skipped: [],
total: items.length,
completed: 0,
skipped: 0,
completed: [],
lastUpdated: new Date(),
}
}
async function setQueue(projectIds: string[]): Promise<void> {
await withMutation(() => {
setQueueState(projectIds, true)
setQueueState(projectIds)
})
}
async function setSingleProject(projectId: string): Promise<void> {
await withMutation(() => {
setQueueState([projectId], false)
})
}
async function completeCurrentProject(
projectId: string,
status: 'completed' | 'skipped' = 'completed',
): Promise<boolean> {
async function completeProject(projectId: string): Promise<boolean> {
return withMutation(() => {
if (!currentQueue.value.items.includes(projectId)) {
return currentQueue.value.items.length > 0
}
if (status === 'completed') {
currentQueue.value.completed++
} else {
currentQueue.value.skipped++
currentQueue.value.completed = [...currentQueue.value.completed, projectId]
currentQueue.value.items = currentQueue.value.items.filter((id) => id !== projectId)
currentQueue.value.lastUpdated = new Date()
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)
@@ -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 {
return currentQueue.value.items[0] || null
}
@@ -294,11 +317,14 @@ function createModerationQueueState(client: AbstractModrinthClient = injectModri
queueLength,
hasItems,
hasSkipped,
progress,
setQueue,
setSingleProject,
completeCurrentProject,
completeProject,
deferProject,
excludeProject,
startSkippedReview,
getCurrentProjectId,
resetQueue,