diff --git a/apps/frontend/src/components/ui/moderation/QueueSummaryModal.vue b/apps/frontend/src/components/ui/moderation/QueueSummaryModal.vue new file mode 100644 index 000000000..79cf6cc9e --- /dev/null +++ b/apps/frontend/src/components/ui/moderation/QueueSummaryModal.vue @@ -0,0 +1,114 @@ + + + diff --git a/apps/frontend/src/components/ui/moderation/checklist/ModerationChecklist.vue b/apps/frontend/src/components/ui/moderation/checklist/ModerationChecklist.vue index a2c77c011..c4fa33033 100644 --- a/apps/frontend/src/components/ui/moderation/checklist/ModerationChecklist.vue +++ b/apps/frontend/src/components/ui/moderation/checklist/ModerationChecklist.vue @@ -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([]) @@ -646,9 +645,9 @@ async function navigateToNextUnlockedProject(): Promise { // 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 { 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(persistedState?.message ?? null) const generatedActiveActions = ref(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 { + 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,