@@ -18,6 +18,7 @@
< Combobox
v-model = "currentFilterType"
class = "!w-full flex-grow sm:!w-[280px] sm:flex-grow-0 lg:!w-[280px]"
trigger -class = " ! h -10 "
:options = "filterTypes"
:placeholder = "formatMessage(commonMessages.filterByLabel)"
@select ="goToPage(1)"
@@ -26,7 +27,7 @@
< span class = "flex flex-row gap-2 align-middle font-semibold" >
< ListFilterIcon class = "size-5 flex-shrink-0 text-secondary" / >
< span class = "truncate text-contrast"
> { { currentFilterType } } ( { { filteredProjects . length } } ) < / s p a n
> { { currentFilterType } } ( { { totalProjects } } ) < / s p a n
>
< / span >
< / template >
@@ -35,6 +36,7 @@
< Combobox
v-model = "currentSortType"
class = "!w-full flex-grow sm:!w-[240px] sm:flex-grow-0"
trigger -class = " ! h -10 "
:options = "sortTypes"
:placeholder = "formatMessage(commonMessages.sortByLabel)"
@select ="goToPage(1)"
@@ -54,6 +56,7 @@
< Combobox
v-model = "itemsPerPage"
class = "!w-full flex-grow sm:!w-[160px] sm:flex-grow-0 lg:!w-[140px]"
trigger -class = " ! h -10 "
:options = "itemsPerPageOptions"
placeholder = "Items per page"
@select ="goToPage(1)"
@@ -69,7 +72,7 @@
< ButtonStyled color = "orange" >
< button
class = "flex !h-[40px] w-full items-center justify-center gap-2 sm:w-auto"
: disabled = "paginatedProjects?.length === 0"
: disabled = "pending || paginatedProjects?.length === 0"
@click ="moderateAllInFilter()"
>
< ScaleIcon class = "flex-shrink-0" / >
@@ -80,17 +83,27 @@
< / div >
< / div >
< div v-if = "totalPages > 1" class="flex items-center justify-between" >
< div >
Showing { { itemsPerPage * ( currentPage - 1 ) + 1 } } – { {
itemsPerPage * ( currentPage - 1 ) + Math . min ( itemsPerPage , paginatedProjects . length )
} }
of { { filteredProjects . length } }
{ {
currentFilterType === DEFAULT _FILTER _TYPE ? 'projects' : currentFilterType . toLowerCase ( )
} }
< div class = "flex flex-col gap-2 sm:flex-row sm: items-center sm: justify-between" >
< div class = "flex flex-wrap items-center gap-3" >
< div v-if = "totalProjects > 0" >
Showing {{ pageStart }} – {{ pageEnd }} of {{ totalProjects }}
{{
currentFilterType = = = DEFAULT_FILTER_TYPE ? ' projects ' : currentFilterType.toLowerCase ( )
}}
< / div >
< div class = "flex items-center gap-2 text-sm font-semibold text-secondary" >
< Toggle id = "moderation-exclude-technical-review" v-model = "excludeTechnicalReview" small / >
< label class = "cursor-pointer" for = "moderation-exclude-technical-review" >
{ { formatMessage ( messages . excludeTechnicalReview ) } }
< / label >
< / div >
< / div >
< Pagination :page = "currentPage" :count = "totalPages" @ switch -page = " goToPage " / >
< Pagination
v-if = "totalPages > 1"
:page = "currentPage"
:count = "totalPages"
@ switch -page = " goToPage "
/ >
< ConfettiExplosion v-if = "visible" / >
< / div >
@@ -124,6 +137,7 @@
< / div >
< / template >
< script setup lang = "ts" >
import type { Labrinth } from '@modrinth/api-client'
import { ListFilterIcon , ScaleIcon , SearchIcon , SortAscIcon , SortDescIcon } from '@modrinth/assets'
import {
ButtonStyled ,
@@ -132,20 +146,18 @@ import {
commonMessages ,
defineMessages ,
EmptyState ,
injectModrinthClient ,
injectNotificationManager ,
Pagination ,
StyledInput ,
Toggle ,
useVIntl ,
} from '@modrinth/ui'
import Fuse from 'fuse.js '
import { useQuery } from '@tanstack/vue-query '
import ConfettiExplosion from 'vue-confetti-explosion'
import ModerationQueueCard from '~/components/ui/moderation/ModerationQueueCard.vue'
import {
type ModerationProject ,
type ProjectWithOwnership ,
toModerationProjects ,
} from '~/helpers/moderation.ts'
import { type ModerationProject , toModerationProjects } from '~/helpers/moderation.ts'
import { useModerationQueue } from '~/services/moderation-queue.ts'
useHead ( { title : 'Projects queue - Modrinth' } )
@@ -155,6 +167,7 @@ const { addNotification } = injectNotificationManager()
const moderationQueue = useModerationQueue ( )
const route = useRoute ( )
const router = useRouter ( )
const client = injectModrinthClient ( )
const visible = ref ( false )
if ( import . meta . client && history && history . state && history . state . confetti ) {
@@ -173,37 +186,14 @@ const messages = defineMessages({
id : 'moderation.moderate' ,
defaultMessage : 'Moderate' ,
} ,
} )
const { data : allProjects , pending } = await useLazyAsyncData ( 'moderation-projects' , async ( ) => {
const startTime = performance . now ( )
let currentOffset = 0
const PROJECT _ENDPOINT _COUNT = 350
const allProjects : ModerationProject [ ] = [ ]
let projects : ProjectWithOwnership [ ] = [ ]
do {
projects = ( await useBaseFetch (
` moderation/projects?count= ${ PROJECT _ENDPOINT _COUNT } &offset= ${ currentOffset } ` ,
{ internal : true } ,
) ) as ProjectWithOwnership [ ]
if ( projects . length === 0 ) break
allProjects . push ( ... toModerationProjects ( projects ) )
currentOffset += projects . length
} while ( projects . length === PROJECT _ENDPOINT _COUNT )
const duration = performance . now ( ) - startTime
console . debug (
` Projects fetched and processed in ${ duration . toFixed ( 2 ) } ms ( ${ ( duration / 1000 ) . toFixed ( 2 ) } s) ` ,
)
return allProjects
excludeTechnicalReview : {
id : 'moderation.exclude-technical-review' ,
defaultMessage : 'Exclude TR' ,
} ,
} )
const query = ref ( route . query . q ? . toString ( ) || '' )
const excludeTechnicalReview = ref ( false )
watch (
query ,
@@ -379,116 +369,106 @@ const itemsPerPage = computed({
} )
const currentPage = ref ( 1 )
const totalPages = computed ( ( ) =>
Math . ceil ( ( filteredProjects . value ? . length || 0 ) / itemsPerPage . value ) ,
function toApiProjectType ( label : string ) : string | undefined {
switch ( label ) {
case 'Modpacks' :
return 'modpack'
case 'Mods' :
return 'mod'
case 'Resource Packs' :
return 'resourcepack'
case 'Data Packs' :
return 'datapack'
case 'Plugins' :
return 'plugin'
case 'Shaders' :
return 'shader'
case 'Servers' :
return 'minecraft_java_server'
case 'Fucked up' :
return 'none'
default :
return undefined
}
}
function toApiSort ( label : string ) : Labrinth . Moderation . Internal . ProjectsSort {
switch ( label ) {
case 'Newest' :
return 'newest'
case 'Most external deps' :
return 'most_external_deps'
case 'Least external deps' :
return 'least_external_deps'
default :
return 'oldest'
}
}
const moderationProjectsRequest = computed < Labrinth .Moderation.Internal.ProjectsRequest > ( ( ) => ( {
count : itemsPerPage . value ,
offset : ( currentPage . value - 1 ) * itemsPerPage . value ,
exclude _technical _review : excludeTechnicalReview . value ,
query : query . value || undefined ,
project _type : toApiProjectType ( currentFilterType . value ) ,
sort : toApiSort ( currentSortType . value ) ,
} ) )
const moderationProjectsQueryKey = computed (
( ) => [ 'moderation-projects' , moderationProjectsRequest . value ] as const ,
)
const fuse = computed ( ( ) => {
if ( ! allProjects . value || allProjects . value . length === 0 ) return null
return new Fuse ( allProjects . value , {
keys : [
{
name : 'project.title' ,
weight : 3 ,
} ,
{
name : 'project.slug' ,
weight : 2 ,
} ,
{
name : 'project.description' ,
weight : 2 ,
} ,
{
name : 'project.project_type' ,
weight : 1 ,
} ,
'ownership.name' ,
] ,
includeScore : true ,
threshold : 0.4 ,
} )
const {
data : moderationProjectsResponse ,
isPending : moderationProjectsPending ,
isPlaceholderData : moderationProjectsPlaceholder ,
} = useQuery ( {
queryKey : moderationProjectsQueryKey ,
queryFn : ( { queryKey } ) => client . labrinth . moderation _internal . getProjects ( queryKey [ 1 ] ) ,
placeholderData : ( previousData ) => previousData ,
} )
const searchResults = computed ( ( ) => {
if ( ! query . value || ! fuse . value ) return null
return fuse . value . search ( query . value ) . map ( ( result ) => result . item )
} )
const baseFiltered = computed ( ( ) => {
if ( ! allProjects . value ) return [ ]
return query . value && searchResults . value ? searchResults . value : [ ... allProjects . value ]
} )
const typeFiltered = computed ( ( ) => {
if ( currentFilterType . value === 'All projects' ) {
return baseFiltered . value
} else if ( currentFilterType . value === 'Fucked up' ) {
return baseFiltered . value . filter ( ( queueItem ) => queueItem . project . project _types . length === 0 )
const pending = computed (
( ) => moderationProjectsPending . value || moderationProjectsPlaceholder . value ,
)
const totalProjects = computed ( ( ) => moderationProjectsResponse . value ? . total ? ? 0 )
const totalPages = computed ( ( ) => Math . ceil ( totalProjects . value / itemsPerPage . value ) )
const filteredProjects = computed ( ( ) =>
toModerationProjects ( moderationProjectsResponse . value ? . projects ? ? [ ] ) ,
)
const paginatedProjects = computed ( ( ) => filteredProjects . value )
const pageStart = computed ( ( ) =>
totalProjects . value === 0 ? 0 : ( currentPage . value - 1 ) * itemsPerPage . value + 1 ,
)
const pageEnd = computed ( ( ) =>
Math . min (
( currentPage . value - 1 ) * itemsPerPage . value + paginatedProjects . value . length ,
totalProjects . value ,
) ,
)
const projectsById = computed ( ( ) => {
const projects = new Map < string , ModerationProject > ( )
for ( const project of filteredProjects . value ) {
projects . set ( project . project . id , project )
}
const filterMap : Record < string , string > = {
Modpacks : 'modpack' ,
Mods : 'mod' ,
'Resource Packs' : 'resourcepack' ,
'Data Packs' : 'datapack' ,
Plugins : 'plugin' ,
Shaders : 'shader' ,
Servers : 'minecraft_java_server' ,
}
const projectType = filterMap [ currentFilterType . value ]
if ( ! projectType ) return baseFiltered . value
return baseFiltered . value . filter (
( queueItem ) =>
( queueItem . project . project _types . length > 0 &&
queueItem . project . project _types [ 0 ] === projectType ) ||
( projectType === 'minecraft_java_server' &&
queueItem . project . project _types . includes ( 'minecraft_java_server' ) ) ,
)
return projects
} )
const filteredProjects = computed ( ( ) => {
const filtered = [ ... typeFiltered . value ]
if ( currentSortType . value === 'Most external deps' ) {
filtered . sort ( ( a , b ) => {
const depsDiff = b . external _dependencies _count - a . external _dependencies _count
if ( depsDiff !== 0 ) return depsDiff
const dateA = new Date ( a . project . queued || a . project . published || 0 ) . getTime ( )
const dateB = new Date ( b . project . queued || b . project . published || 0 ) . getTime ( )
return dateA - dateB
} )
} else if ( currentSortType . value === 'Least external deps' ) {
filtered . sort ( ( a , b ) => {
const depsDiff = a . external _dependencies _count - b . external _dependencies _count
if ( depsDiff !== 0 ) return depsDiff
const dateA = new Date ( a . project . queued || a . project . published || 0 ) . getTime ( )
const dateB = new Date ( b . project . queued || b . project . published || 0 ) . getTime ( )
return dateA - dateB
} )
} else if ( currentSortType . value === 'Oldest' ) {
filtered . sort ( ( a , b ) => {
const dateA = new Date ( a . project . queued || a . project . published || 0 ) . getTime ( )
const dateB = new Date ( b . project . queued || b . project . published || 0 ) . getTime ( )
return dateA - dateB
} )
} else {
filtered . sort ( ( a , b ) => {
const dateA = new Date ( a . project . queued || a . project . published || 0 ) . getTime ( )
const dateB = new Date ( b . project . queued || b . project . published || 0 ) . getTime ( )
return dateB - dateA
} )
watch ( totalPages , ( pages ) => {
if ( pages === 0 && currentPage . value !== 1 ) {
currentPage . value = 1
return
}
return filtered
if ( pages > 0 && currentPage . value > pages ) {
currentPage . value = pages
}
} )
const paginatedProjects = computed ( ( ) => {
if ( ! filteredProjects . value ) return [ ]
const start = ( currentPage . value - 1 ) * itemsPerPage . value
const end = start + itemsPerPage . value
return filteredProjects . value . slice ( start , end )
watch ( excludeTechnicalReview , ( ) => {
goToPage ( 1 )
} )
const emptyStateHeading = computed ( ( ) => {
@@ -525,21 +505,16 @@ function notifySkippedProjects(skippedCount: number) {
} )
}
async function findFirstEligibleProject ( ) : Promise < ModerationProject | null > {
async function findFirstEligibleProject ( ) : Promise < string | null > {
let skippedCount = 0
while ( moderationQueue . hasItems ) {
const currentId = moderationQueue . getCurrentProjectId ( )
if ( ! currentId ) return null
const project = filteredProjects . value . find ( ( p ) => p . project . id === currentId )
if ( ! project ) {
await moderationQueue . completeCurrentProject ( currentId , 'skipped' )
skippedCount ++
continue
}
const project = projectsById . value . get ( currentId )
if ( project . project . status !== 'processing' ) {
if ( project && project . project . status !== 'processing' ) {
await moderationQueue . completeCurrentProject ( currentId , 'skipped' )
skippedCount ++
continue
@@ -550,13 +525,13 @@ async function findFirstEligibleProject(): Promise<ModerationProject | null> {
if ( ! lockStatus . locked || lockStatus . expired || lockStatus . is _own _lock ) {
notifySkippedProjects ( skippedCount )
return project
return currentId
}
await moderationQueue . completeCurrentProject ( currentId , 'skipped' )
skippedCount ++
} catch {
return project
return currentId
}
}
@@ -565,17 +540,42 @@ async function findFirstEligibleProject(): Promise<ModerationProject | null> {
return null
}
function getProjectRouteParam ( projectId : string ) : string {
return projectsById . value . get ( projectId ) ? . project . slug || projectId
}
async function navigateToModerationProject ( projectId : string ) {
await navigateTo ( {
name : 'type-project' ,
params : {
type : 'project' ,
project : getProjectRouteParam ( projectId ) ,
} ,
state : {
showChecklist : true ,
} ,
} )
}
async function getFilteredProjectIds ( ) : Promise < string [ ] > {
const response = await client . labrinth . moderation _internal . getProjectIds ( {
exclude _technical _review : excludeTechnicalReview . value ,
query : query . value || undefined ,
project _type : toApiProjectType ( currentFilterType . value ) ,
sort : toApiSort ( currentSortType . value ) ,
} )
return response . ids
}
async function moderateAllInFilter ( ) {
// Start from the current page - get projects from current page onwards
const startIndex = ( currentPage . value - 1 ) * itemsPerPage . value
const projectsFromCurrentPage = filteredProjects . value . slice ( startIndex )
const projectIds = projectsFromCurrentPage . map ( ( queueItem ) => queueItem . project . id )
const projectIds = ( await getFilteredProjectIds ( ) ) . slice ( startIndex )
await moderationQueue . setQueue ( projectIds )
// Find first unlocked project
const targetProject = await findFirstEligibleProject ( )
const targetProjectId = await findFirstEligibleProject ( )
if ( ! targetProject ) {
if ( ! targetProjectId ) {
addNotification ( {
title : 'No projects available' ,
text : 'All projects in queue are already moderated or locked by others.' ,
@@ -584,34 +584,22 @@ async function moderateAllInFilter() {
return
}
navigateTo ( {
name : 'type-project' ,
params : {
type : 'project' ,
project : targetProject . project . slug ,
} ,
state : {
showChecklist : true ,
} ,
} )
await navigateToModerationProject ( targetProjectId )
}
async function startFromProject ( projectId : string ) {
// Find the index of the clicked project in the filtered list
const projectIndex = filteredProjects . value . findIndex ( ( p ) => p . project . id === projectId )
const allFilteredProjectIds = await getFilteredProjectIds ( )
const projectIndex = allFilteredProjectIds . indexOf ( projectId )
if ( projectIndex === - 1 ) {
// Project not found in filtered list, just moderate it alone
await moderationQueue . setSingleProject ( projectId )
} else {
// Start queue from this project onwards
const projectsFromHere = filteredProjects . value . slice ( projectIndex )
const projectIds = projectsFromHere . map ( ( queueItem ) => queueItem . project . id )
const projectIds = allFilteredProjectIds . slice ( projectIndex )
await moderationQueue . setQueue ( projectIds )
}
const targetProject = await findFirstEligibleProject ( )
const targetProjectId = await findFirstEligibleProject ( )
if ( ! targetProject ) {
if ( ! targetProjectId ) {
addNotification ( {
title : 'No projects available' ,
text : 'All projects in queue are already moderated or locked by others.' ,
@@ -620,15 +608,6 @@ async function startFromProject(projectId: string) {
return
}
navigateTo ( {
name : 'type-project' ,
params : {
type : 'project' ,
project : targetProject . project . slug ,
} ,
state : {
showChecklist : true ,
} ,
} )
await navigateToModerationProject ( targetProjectId )
}
< / script >