@@ -160,8 +166,11 @@ 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 { useModerationQueue } from '~/services/moderation-queue.ts'
+import { getProjectTypeForUrlShorthand } from '~/helpers/projects.js'
+import { useModerationQueue } from '~/services/moderation/queue.ts'
+import { findNextEligibleQueueProject } from '~/services/moderation/queue-eligibility.ts'
useHead({ title: 'Projects queue - Modrinth' })
@@ -172,6 +181,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 () => {
@@ -184,6 +195,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',
@@ -498,60 +517,36 @@ 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
{
- let skippedCount = 0
-
- while (moderationQueue.hasItems) {
- const currentId = moderationQueue.getCurrentProjectId()
- if (!currentId) return null
-
- const project = projectsById.value.get(currentId)
-
- if (project && project.project.status !== 'processing') {
- await moderationQueue.completeCurrentProject(currentId, 'skipped')
- skippedCount++
- continue
- }
+ const candidateIds = [...moderationQueue.currentQueue.items]
+ if (candidateIds.length === 0) return null
- try {
- const lockStatus = await moderationQueue.checkLock(currentId)
+ const next = await findNextEligibleQueueProject(client, moderationQueue, candidateIds)
- if (!lockStatus.locked || lockStatus.expired || lockStatus.is_own_lock) {
- notifySkippedProjects(skippedCount)
- return currentId
- }
-
- await moderationQueue.completeCurrentProject(currentId, 'skipped')
- skippedCount++
- } catch {
- return currentId
- }
+ if (!next) {
+ await Promise.all(candidateIds.map((id) => moderationQueue.excludeProject(id)))
+ return null
}
- notifySkippedProjects(skippedCount)
-
- return null
+ await Promise.all(next.excluded.map((id) => moderationQueue.excludeProject(id)))
+ return next.project
}
function getProjectRouteParam(projectId: string): string {
return projectsById.value.get(projectId)?.project.slug || projectId
}
+function getProjectRouteType(projectId: string): string {
+ const projectType = projectsById.value.get(projectId)?.project.project_types[0]
+ if (!projectType) return 'project'
+ return getProjectTypeForUrlShorthand(projectType, [])
+}
+
async function navigateToModerationProject(projectId: string) {
await navigateTo({
name: 'type-project',
params: {
- type: 'project',
+ type: getProjectRouteType(projectId),
project: getProjectRouteParam(projectId),
},
state: {
@@ -593,12 +588,8 @@ 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()
@@ -613,4 +604,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/checklist-session-storage.ts b/apps/frontend/src/services/moderation/checklist-session-storage.ts
new file mode 100644
index 0000000000..5b1c5238be
--- /dev/null
+++ b/apps/frontend/src/services/moderation/checklist-session-storage.ts
@@ -0,0 +1,38 @@
+export interface SessionChecklistState {
+ visitedStages?: string[]
+}
+
+function sessionStorageKey(projectId: string): string {
+ return `moderation-checklist-session:${projectId}`
+}
+
+export function getSessionChecklistState(projectId: string): SessionChecklistState {
+ try {
+ const raw = sessionStorage.getItem(sessionStorageKey(projectId))
+ return raw ? JSON.parse(raw) : {}
+ } catch {
+ return {}
+ }
+}
+
+export function patchSessionChecklistState(
+ projectId: string,
+ patch: Partial,
+): void {
+ try {
+ sessionStorage.setItem(
+ sessionStorageKey(projectId),
+ JSON.stringify({ ...getSessionChecklistState(projectId), ...patch }),
+ )
+ } catch {
+ // Shush
+ }
+}
+
+export function clearSessionChecklistState(projectId: string): void {
+ try {
+ sessionStorage.removeItem(sessionStorageKey(projectId))
+ } catch {
+ // Shush
+ }
+}
diff --git a/apps/frontend/src/services/moderation-checklist-storage.ts b/apps/frontend/src/services/moderation/checklist-storage.ts
similarity index 95%
rename from apps/frontend/src/services/moderation-checklist-storage.ts
rename to apps/frontend/src/services/moderation/checklist-storage.ts
index 3b4c1b356c..fa5c13d215 100644
--- a/apps/frontend/src/services/moderation-checklist-storage.ts
+++ b/apps/frontend/src/services/moderation/checklist-storage.ts
@@ -1,6 +1,6 @@
-import type { NodeState } from '@modrinth/moderation'
+import type { NodeState } from '@modrinth/moderation/src/types/node'
-import { dbDelete, dbGet, dbPut, dbScan } from './moderation-db.ts'
+import { dbDelete, dbGet, dbPut, dbScan } from './db.ts'
export interface PersistedChecklistState {
savedAt: string
@@ -9,6 +9,7 @@ export interface PersistedChecklistState {
stage?: string
message?: string
state?: Record>
+ activatedStages?: string[]
}
const STORE = 'checklist'
diff --git a/apps/frontend/src/services/moderation-db.ts b/apps/frontend/src/services/moderation/db.ts
similarity index 100%
rename from apps/frontend/src/services/moderation-db.ts
rename to apps/frontend/src/services/moderation/db.ts
diff --git a/apps/frontend/src/services/moderation/queue-eligibility.ts b/apps/frontend/src/services/moderation/queue-eligibility.ts
new file mode 100644
index 0000000000..25da387057
--- /dev/null
+++ b/apps/frontend/src/services/moderation/queue-eligibility.ts
@@ -0,0 +1,91 @@
+import type { AbstractModrinthClient } from '@modrinth/api-client'
+
+import type { ModerationQueueService } from './queue.ts'
+
+export interface QueueCandidateCheck {
+ locked: boolean
+ expired?: boolean
+ isOwnLock?: boolean
+ slug?: string
+ projectType?: string
+ status?: string
+ isProcessing: boolean
+}
+
+export interface EligibleQueueProject {
+ project: string
+ result: QueueCandidateCheck
+ excluded: string[]
+}
+
+const BATCH_SIZE = 5
+
+export function isEligibleQueueCandidate(result: QueueCandidateCheck | undefined): boolean {
+ if (!result?.isProcessing) return false
+ return !result.locked || !!result.expired || !!result.isOwnLock
+}
+
+export async function batchCheckQueueCandidates(
+ client: AbstractModrinthClient,
+ moderationQueue: ModerationQueueService,
+ projectIds: string[],
+): Promise