feat: use multi select for moderation reports (#6312)

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
ThatGravyBoat
2026-06-05 12:25:05 -02:30
committed by GitHub
parent dfe12d4ecb
commit 707e219ff8
@@ -8,15 +8,11 @@
autocomplete="off"
:placeholder="formatMessage(commonMessages.searchPlaceholder)"
clearable
wrapper-class="flex-1 lg:max-w-52"
input-class="h-[40px]"
wrapper-class="flex-1"
input-class="h-[40px] w-full"
@input="goToPage(1)"
/>
<div v-if="totalPages > 1" class="hidden flex-1 justify-center lg:flex">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
</div>
<div
class="flex flex-col items-stretch justify-end gap-2 sm:flex-row sm:items-center lg:flex-shrink-0"
>
@@ -56,6 +52,72 @@
</template>
</Combobox>
<MultiSelect
v-model="currentReporterOrProject"
:options="reporterOrProjectOptions"
:max-height="500"
dropdown-min-width="360px"
no-options-message="no options found"
:searchable="reporterOrProjectOptions.length > 6"
:max-tag-rows="1"
fit-content
checkbox-position="right"
show-selection-actions
should-show-select-all
@update:model-value="goToPage(1)"
>
<template #input-content="{ isOpen, openDirection }">
<div class="flex min-h-7 min-w-0 max-w-full flex-1 items-center gap-1.5 pr-1">
<LayersIcon class="size-5 shrink-0 text-primary" />
<span class="min-w-0 flex-1 truncate px-0.5 font-semibold text-primary">
{{
currentReporterOrProject.length === 0
? 'All Reports'
: `${currentReporterOrProject.length} selected`
}}
</span>
<ChevronLeftIcon
class="size-5 shrink-0 text-primary transition-transform duration-150"
:class="
isOpen ? (openDirection === 'down' ? 'rotate-90' : '-rotate-90') : '-rotate-90'
"
/>
</div>
</template>
<template #top>
<div>
<button
type="button"
class="flex w-full cursor-pointer items-center gap-1.5 border-0 bg-surface-4 px-4 py-3 text-left shadow-none transition-all duration-150 hover:brightness-[115%] focus:brightness-[115%]"
:aria-selected="currentReporterOrProject.length === 0"
:class="currentReporterOrProject.length === 0 ? 'text-contrast' : 'text-primary'"
role="option"
@click="
() => {
currentReporterOrProject = []
goToPage(1)
}
"
@keydown.enter.stop
@keydown.space.stop
>
<LayersIcon
class="h-5 w-5 shrink-0 text-primary"
:class="currentReporterOrProject.length === 0 ? 'text-contrast' : 'text-primary'"
/>
<span class="min-w-0 flex-1 font-semibold leading-tight">All Reports</span>
<span class="flex shrink-0 items-center justify-center text-brand">
<CheckIcon
v-if="currentReporterOrProject.length === 0"
aria-hidden="true"
class="size-5"
/>
</span>
</button>
</div>
</template>
</MultiSelect>
<FloatingPanel button-class="!h-10 !shadow-none !text-contrast" :auto-focus="false">
<BlendIcon class="size-5" /> Advanced filters
<template #panel>
@@ -67,6 +129,7 @@
class="!w-full"
:options="reportTargetFilterTypes"
:placeholder="formatMessage(commonMessages.filterByLabel)"
@select="goToPage(1)"
/>
</div>
<div class="flex min-w-64 flex-col gap-3">
@@ -77,6 +140,7 @@
class="!w-full"
:options="reportIssueFilterTypes"
:placeholder="formatMessage(commonMessages.filterByLabel)"
@select="goToPage(1)"
/>
</div>
</div>
@@ -87,6 +151,7 @@
class="!w-full"
:options="projectTypeFilterTypes"
:placeholder="formatMessage(commonMessages.filterByLabel)"
@select="goToPage(1)"
/>
</div>
</div>
@@ -95,6 +160,17 @@
</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, paginatedReports.length) }}
of {{ sortedReports.length }} reports
</div>
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
</div>
<div v-if="totalPages > 1" class="flex justify-center lg:hidden">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
</div>
@@ -111,18 +187,30 @@
</template>
<script setup lang="ts">
import { BlendIcon, ListFilterIcon, SearchIcon, SortAscIcon, SortDescIcon } from '@modrinth/assets'
import type { Labrinth } from '@modrinth/api-client'
import {
BlendIcon,
CheckIcon,
ChevronLeftIcon,
LayersIcon,
ListFilterIcon,
SearchIcon,
SortAscIcon,
SortDescIcon,
} from '@modrinth/assets'
import type { ExtendedReport } from '@modrinth/moderation'
import {
Combobox,
type ComboboxOption,
commonMessages,
FloatingPanel,
MultiSelect,
type MultiSelectItem,
Pagination,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import type { Report } from '@modrinth/utils'
import type { Report, User } from '@modrinth/utils'
import Fuse from 'fuse.js'
import ReportCard from '~/components/ui/moderation/ModerationReportCard.vue'
@@ -254,6 +342,64 @@ const reportIssueFilterTypes = computed<ComboboxOption<string>[]>(() => {
return [...base, ...sortedTypes.map((type) => ({ value: type, label: type }))]
})
type ReportedType<T> = T & { report_item_count: number }
const currentReporterOrProject = ref<string[]>([])
const reporterOrProjectOptions = computed<MultiSelectItem<string>[]>(() => {
if (!allReports.value) return []
const options: MultiSelectItem<string>[] = []
const uniqueProjectIds: { [id: string]: ReportedType<Labrinth.Projects.v2.Project> } = {}
const uniqueReporterIds: { [id: string]: ReportedType<User> } = {}
for (const report of filteredReports.value) {
if (report.project)
uniqueProjectIds[report.project.id] = {
...report.project,
report_item_count: (uniqueProjectIds[report.project.id]?.report_item_count || 0) + 1,
}
if (report.reporter_user)
uniqueReporterIds[report.reporter_user.id] = {
...report.reporter_user,
report_item_count: (uniqueReporterIds[report.reporter_user.id]?.report_item_count || 0) + 1,
}
}
if (Object.keys(uniqueProjectIds).length !== 0) {
options.push({ type: 'section-header', label: 'Projects' })
Object.values(uniqueProjectIds)
.sort((a, b) =>
a.report_item_count === b.report_item_count
? a.title.localeCompare(b.title)
: b.report_item_count - a.report_item_count,
)
.forEach((project) => {
options.push({
value: `project/${project.id}`,
label: `${project.title} (${project.report_item_count})`,
icon: project.icon_url ? h('img', { src: project.icon_url }) : undefined,
})
})
}
options.push({ type: 'section-header', label: 'Reporters' })
Object.values(uniqueReporterIds)
.sort((a, b) =>
a.report_item_count === b.report_item_count
? a.username.localeCompare(b.username)
: b.report_item_count - a.report_item_count,
)
.forEach((reporter) => {
options.push({
value: `reporter/${reporter.id}`,
label: `${reporter.username} (${reporter.report_item_count})`,
icon: reporter.avatar_url ? h('img', { src: reporter.avatar_url }) : undefined,
})
})
return options
})
const currentPage = ref(1)
const itemsPerPage = 15
const totalPages = computed(() => Math.ceil((sortedReports.value?.length || 0) / itemsPerPage))
@@ -379,7 +525,19 @@ const filteredReports = computed(() => {
})
const sortedReports = computed(() => {
const filtered = [...filteredReports.value]
const reporterOrProjectFilter = currentReporterOrProject.value
const filtered =
reporterOrProjectFilter.length === 0
? [...filteredReports.value]
: filteredReports.value.filter((report) => {
const reporterOrProjectFilterLookup = new Set(reporterOrProjectFilter)
const reporterValue = report.reporter_user ? `reporter/${report.reporter_user.id}` : null
const projectValue = report.project ? `project/${report.project.id}` : null
return (
(reporterValue && reporterOrProjectFilterLookup.has(reporterValue)) ||
(projectValue && reporterOrProjectFilterLookup.has(projectValue))
)
})
if (currentSortTypeSorting.value === 'oldest') {
filtered.sort((a, b) => new Date(a.created).getTime() - new Date(b.created).getTime())