{
+ 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')
}
diff --git a/apps/frontend/src/pages/moderation/index.vue b/apps/frontend/src/pages/moderation/index.vue
index 535374a0d..a4a2dbfad 100644
--- a/apps/frontend/src/pages/moderation/index.vue
+++ b/apps/frontend/src/pages/moderation/index.vue
@@ -105,6 +105,12 @@
@switch-page="goToPage"
/>
+
@@ -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 {
const candidateIds = [...moderationQueue.currentQueue.items]
if (candidateIds.length === 0) return null
@@ -513,18 +520,12 @@ async function findFirstEligibleProject(): Promise {
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)
+}
diff --git a/apps/frontend/src/services/moderation/queue-eligibility.ts b/apps/frontend/src/services/moderation/queue-eligibility.ts
index f222f49c9..25da38705 100644
--- a/apps/frontend/src/services/moderation/queue-eligibility.ts
+++ b/apps/frontend/src/services/moderation/queue-eligibility.ts
@@ -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 {
- 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)
}
}
diff --git a/apps/frontend/src/services/moderation/queue-storage.ts b/apps/frontend/src/services/moderation/queue-storage.ts
index d16492bbe..22a450a33 100644
--- a/apps/frontend/src/services/moderation/queue-storage.ts
+++ b/apps/frontend/src/services/moderation/queue-storage.ts
@@ -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
diff --git a/apps/frontend/src/services/moderation/queue.ts b/apps/frontend/src/services/moderation/queue.ts
index ae6c2834a..9c0a45a88 100644
--- a/apps/frontend/src/services/moderation/queue.ts
+++ b/apps/frontend/src/services/moderation/queue.ts
@@ -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
- setSingleProject(projectId: string): Promise
- completeCurrentProject(projectId: string, status?: 'completed' | 'skipped'): Promise
+ completeProject(projectId: string): Promise
+ deferProject(projectId: string): Promise
+ excludeProject(projectId: string): Promise
+ startSkippedReview(): Promise
getCurrentProjectId(): string | null
resetQueue(): Promise
@@ -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 {
await withMutation(() => {
- setQueueState(projectIds, true)
+ setQueueState(projectIds)
})
}
- async function setSingleProject(projectId: string): Promise {
- await withMutation(() => {
- setQueueState([projectId], false)
- })
- }
-
- async function completeCurrentProject(
- projectId: string,
- status: 'completed' | 'skipped' = 'completed',
- ): Promise {
+ async function completeProject(projectId: string): Promise {
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 {
+ 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 {
+ 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 {
+ 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,