You've already forked AstralRinth
* feat: filtering + sorting alignment * polish: malicious summary modal changes * feat: better filter row using floating panel * fix: re-enable request * fix: lint * polish: jump back to files tab qol * feat: scroll to top of next card when done * fix: show lock icon on preview msg * feat: download no _blank * feat: show also marked in notif * feat: auto expand if only one class in the file * feat: proper page titles * fix: text-contrast typo * fix: lint * feat: QA changes * feat: individual report page + more qa * fix: back btn * fix: broken import * feat: quick reply msgs * fix: in other queue filter * fix: caching threads wrongly * fix: flag filter * feat: toggle enabled by default * fix: dont make btns opacity 50 --------- Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
456 lines
12 KiB
Vue
456 lines
12 KiB
Vue
<template>
|
|
<div class="flex flex-col gap-4">
|
|
<div class="flex flex-col justify-between gap-3 lg:flex-row">
|
|
<div class="iconified-input flex-1 lg:max-w-md">
|
|
<SearchIcon aria-hidden="true" class="text-lg" />
|
|
<input
|
|
v-model="query"
|
|
class="h-[40px]"
|
|
autocomplete="off"
|
|
spellcheck="false"
|
|
type="text"
|
|
:placeholder="formatMessage(messages.searchPlaceholder)"
|
|
@input="goToPage(1)"
|
|
/>
|
|
<Button v-if="query" class="r-btn" @click="() => (query = '')">
|
|
<XIcon />
|
|
</Button>
|
|
</div>
|
|
|
|
<div v-if="totalPages > 1" class="hidden flex-1 justify-center lg:flex">
|
|
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
|
|
<ConfettiExplosion v-if="visible" />
|
|
</div>
|
|
|
|
<div class="flex flex-col justify-end gap-2 sm:flex-row lg:flex-shrink-0">
|
|
<div class="flex flex-col gap-2 sm:flex-row">
|
|
<Combobox
|
|
v-model="currentFilterType"
|
|
class="!w-full flex-grow sm:!w-[280px] sm:flex-grow-0 lg:!w-[280px]"
|
|
:options="filterTypes"
|
|
:placeholder="formatMessage(messages.filterBy)"
|
|
@select="goToPage(1)"
|
|
>
|
|
<template #selected>
|
|
<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 }})</span
|
|
>
|
|
</span>
|
|
</template>
|
|
</Combobox>
|
|
|
|
<Combobox
|
|
v-model="currentSortType"
|
|
class="!w-full flex-grow sm:!w-[150px] sm:flex-grow-0 lg:!w-[150px]"
|
|
:options="sortTypes"
|
|
:placeholder="formatMessage(messages.sortBy)"
|
|
@select="goToPage(1)"
|
|
>
|
|
<template #selected>
|
|
<span class="flex flex-row gap-2 align-middle font-semibold">
|
|
<SortAscIcon
|
|
v-if="currentSortType === 'Oldest'"
|
|
class="size-5 flex-shrink-0 text-secondary"
|
|
/>
|
|
<SortDescIcon v-else class="size-5 flex-shrink-0 text-secondary" />
|
|
<span class="truncate text-contrast">{{ currentSortType }}</span>
|
|
</span>
|
|
</template>
|
|
</Combobox>
|
|
</div>
|
|
|
|
<ButtonStyled color="orange" class="w-full sm:w-auto">
|
|
<button
|
|
class="flex !h-[40px] w-full items-center justify-center gap-2 sm:w-auto"
|
|
:disabled="paginatedProjects?.length === 0"
|
|
@click="moderateAllInFilter()"
|
|
>
|
|
<ScaleIcon class="flex-shrink-0" />
|
|
<span class="hidden sm:inline">{{ formatMessage(messages.moderate) }}</span>
|
|
<span class="sm:hidden">Moderate</span>
|
|
</button>
|
|
</ButtonStyled>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="totalPages > 1" class="flex justify-center lg:hidden">
|
|
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
|
|
<ConfettiExplosion v-if="visible" />
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-4">
|
|
<div v-if="paginatedProjects.length === 0" class="universal-card h-24 animate-pulse"></div>
|
|
<ModerationQueueCard
|
|
v-for="item in paginatedProjects"
|
|
v-else
|
|
:key="item.project.id"
|
|
:queue-entry="item"
|
|
:owner="item.owner"
|
|
:org="item.org"
|
|
@start-from-project="startFromProject"
|
|
/>
|
|
</div>
|
|
|
|
<div v-if="totalPages > 1" class="mt-4 flex justify-center">
|
|
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<script setup lang="ts">
|
|
import {
|
|
ListFilterIcon,
|
|
ScaleIcon,
|
|
SearchIcon,
|
|
SortAscIcon,
|
|
SortDescIcon,
|
|
XIcon,
|
|
} from '@modrinth/assets'
|
|
import {
|
|
Button,
|
|
ButtonStyled,
|
|
Combobox,
|
|
type ComboboxOption,
|
|
defineMessages,
|
|
injectNotificationManager,
|
|
Pagination,
|
|
useVIntl,
|
|
} from '@modrinth/ui'
|
|
import Fuse from 'fuse.js'
|
|
import ConfettiExplosion from 'vue-confetti-explosion'
|
|
|
|
import ModerationQueueCard from '~/components/ui/moderation/ModerationQueueCard.vue'
|
|
import { enrichProjectBatch, type ModerationProject } from '~/helpers/moderation.ts'
|
|
import { useModerationStore } from '~/store/moderation.ts'
|
|
|
|
useHead({ title: 'Projects queue - Modrinth' })
|
|
|
|
const { formatMessage } = useVIntl()
|
|
const { addNotification } = injectNotificationManager()
|
|
const moderationStore = useModerationStore()
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
|
|
const visible = ref(false)
|
|
if (import.meta.client && history && history.state && history.state.confetti) {
|
|
setTimeout(async () => {
|
|
history.state.confetti = false
|
|
visible.value = true
|
|
await nextTick()
|
|
setTimeout(() => {
|
|
visible.value = false
|
|
}, 5000)
|
|
}, 1000)
|
|
}
|
|
|
|
const messages = defineMessages({
|
|
searchPlaceholder: {
|
|
id: 'moderation.search.placeholder',
|
|
defaultMessage: 'Search...',
|
|
},
|
|
filterBy: {
|
|
id: 'moderation.filter.by',
|
|
defaultMessage: 'Filter by',
|
|
},
|
|
sortBy: {
|
|
id: 'moderation.sort.by',
|
|
defaultMessage: 'Sort by',
|
|
},
|
|
moderate: {
|
|
id: 'moderation.moderate',
|
|
defaultMessage: 'Moderate',
|
|
},
|
|
})
|
|
|
|
const { data: allProjects } = await useLazyAsyncData('moderation-projects', async () => {
|
|
const startTime = performance.now()
|
|
let currentOffset = 0
|
|
const PROJECT_ENDPOINT_COUNT = 350
|
|
const allProjects: ModerationProject[] = []
|
|
|
|
const enrichmentPromises: Promise<ModerationProject[]>[] = []
|
|
|
|
let projects: any[] = []
|
|
do {
|
|
projects = (await useBaseFetch(
|
|
`moderation/projects?count=${PROJECT_ENDPOINT_COUNT}&offset=${currentOffset}`,
|
|
{ internal: true },
|
|
)) as any[]
|
|
|
|
if (projects.length === 0) break
|
|
|
|
const enrichmentPromise = enrichProjectBatch(projects)
|
|
enrichmentPromises.push(enrichmentPromise)
|
|
|
|
currentOffset += projects.length
|
|
|
|
if (enrichmentPromises.length >= 3) {
|
|
const completed = await Promise.all(enrichmentPromises.splice(0, 2))
|
|
allProjects.push(...completed.flat())
|
|
}
|
|
} while (projects.length === PROJECT_ENDPOINT_COUNT)
|
|
|
|
const remainingBatches = await Promise.all(enrichmentPromises)
|
|
allProjects.push(...remainingBatches.flat())
|
|
|
|
const endTime = performance.now()
|
|
const duration = endTime - startTime
|
|
|
|
console.debug(
|
|
`Projects fetched and processed in ${duration.toFixed(2)}ms (${(duration / 1000).toFixed(2)}s)`,
|
|
)
|
|
|
|
return allProjects
|
|
})
|
|
|
|
const query = ref(route.query.q?.toString() || '')
|
|
|
|
watch(
|
|
query,
|
|
(newQuery) => {
|
|
const currentQuery = { ...route.query }
|
|
if (newQuery) {
|
|
currentQuery.q = newQuery
|
|
} else {
|
|
delete currentQuery.q
|
|
}
|
|
|
|
router.replace({
|
|
path: route.path,
|
|
query: currentQuery,
|
|
})
|
|
},
|
|
{ immediate: false },
|
|
)
|
|
|
|
watch(
|
|
() => route.query.q,
|
|
(newQueryParam) => {
|
|
const newValue = newQueryParam?.toString() || ''
|
|
if (query.value !== newValue) {
|
|
query.value = newValue
|
|
}
|
|
},
|
|
)
|
|
|
|
const currentFilterType = ref('All projects')
|
|
const filterTypes: ComboboxOption<string>[] = [
|
|
{ value: 'All projects', label: 'All projects' },
|
|
{ value: 'Modpacks', label: 'Modpacks' },
|
|
{ value: 'Mods', label: 'Mods' },
|
|
{ value: 'Resource Packs', label: 'Resource Packs' },
|
|
{ value: 'Data Packs', label: 'Data Packs' },
|
|
{ value: 'Plugins', label: 'Plugins' },
|
|
{ value: 'Shaders', label: 'Shaders' },
|
|
]
|
|
|
|
const currentSortType = ref('Oldest')
|
|
const sortTypes: ComboboxOption<string>[] = [
|
|
{ value: 'Oldest', label: 'Oldest' },
|
|
{ value: 'Newest', label: 'Newest' },
|
|
]
|
|
|
|
const currentPage = ref(1)
|
|
const itemsPerPage = 15
|
|
const totalPages = computed(() => Math.ceil((filteredProjects.value?.length || 0) / itemsPerPage))
|
|
|
|
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,
|
|
},
|
|
'owner.user.username',
|
|
'org.name',
|
|
'org.slug',
|
|
],
|
|
includeScore: true,
|
|
threshold: 0.4,
|
|
})
|
|
})
|
|
|
|
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
|
|
|
|
const filterMap: Record<string, string> = {
|
|
Modpacks: 'modpack',
|
|
Mods: 'mod',
|
|
'Resource Packs': 'resourcepack',
|
|
'Data Packs': 'datapack',
|
|
Plugins: 'plugin',
|
|
Shaders: 'shader',
|
|
}
|
|
|
|
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,
|
|
)
|
|
})
|
|
|
|
const filteredProjects = computed(() => {
|
|
const filtered = [...typeFiltered.value]
|
|
|
|
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
|
|
})
|
|
}
|
|
|
|
return filtered
|
|
})
|
|
|
|
const paginatedProjects = computed(() => {
|
|
if (!filteredProjects.value) return []
|
|
const start = (currentPage.value - 1) * itemsPerPage
|
|
const end = start + itemsPerPage
|
|
return filteredProjects.value.slice(start, end)
|
|
})
|
|
|
|
function goToPage(page: number) {
|
|
currentPage.value = page
|
|
}
|
|
|
|
async function findFirstUnlockedProject(): Promise<ModerationProject | null> {
|
|
let skippedCount = 0
|
|
|
|
while (moderationStore.hasItems) {
|
|
const currentId = moderationStore.getCurrentProjectId()
|
|
if (!currentId) return null
|
|
|
|
const project = filteredProjects.value.find((p) => p.project.id === currentId)
|
|
if (!project) {
|
|
moderationStore.completeCurrentProject(currentId, 'skipped')
|
|
continue
|
|
}
|
|
|
|
try {
|
|
const lockStatus = await moderationStore.checkLock(currentId)
|
|
|
|
if (!lockStatus.locked || lockStatus.expired) {
|
|
if (skippedCount > 0) {
|
|
addNotification({
|
|
title: 'Skipped locked projects',
|
|
text: `Skipped ${skippedCount} project(s) being moderated by others.`,
|
|
type: 'info',
|
|
})
|
|
}
|
|
return project
|
|
}
|
|
|
|
// Project is locked, skip it
|
|
moderationStore.completeCurrentProject(currentId, 'skipped')
|
|
skippedCount++
|
|
} catch {
|
|
return project
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
async function moderateAllInFilter() {
|
|
// Start from the current page - get projects from current page onwards
|
|
const startIndex = (currentPage.value - 1) * itemsPerPage
|
|
const projectsFromCurrentPage = filteredProjects.value.slice(startIndex)
|
|
const projectIds = projectsFromCurrentPage.map((queueItem) => queueItem.project.id)
|
|
moderationStore.setQueue(projectIds)
|
|
|
|
// Find first unlocked project
|
|
const targetProject = await findFirstUnlockedProject()
|
|
|
|
if (!targetProject) {
|
|
addNotification({
|
|
title: 'All projects locked',
|
|
text: 'All projects in queue are currently being moderated by others.',
|
|
type: 'warning',
|
|
})
|
|
return
|
|
}
|
|
|
|
navigateTo({
|
|
name: 'type-id',
|
|
params: {
|
|
type: 'project',
|
|
id: targetProject.project.slug,
|
|
},
|
|
state: {
|
|
showChecklist: true,
|
|
},
|
|
})
|
|
}
|
|
|
|
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)
|
|
if (projectIndex === -1) {
|
|
// Project not found in filtered list, just moderate it alone
|
|
moderationStore.setSingleProject(projectId)
|
|
} else {
|
|
// Start queue from this project onwards
|
|
const projectsFromHere = filteredProjects.value.slice(projectIndex)
|
|
const projectIds = projectsFromHere.map((queueItem) => queueItem.project.id)
|
|
moderationStore.setQueue(projectIds)
|
|
}
|
|
|
|
// Find first unlocked project
|
|
const targetProject = await findFirstUnlockedProject()
|
|
|
|
if (!targetProject) {
|
|
addNotification({
|
|
title: 'All projects locked',
|
|
text: 'All projects in queue are currently being moderated by others.',
|
|
type: 'warning',
|
|
})
|
|
return
|
|
}
|
|
|
|
navigateTo({
|
|
name: 'type-id',
|
|
params: {
|
|
type: 'project',
|
|
id: targetProject.project.slug,
|
|
},
|
|
state: {
|
|
showChecklist: true,
|
|
},
|
|
})
|
|
}
|
|
</script>
|