Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 167 additions & 9 deletions apps/frontend/src/pages/moderation/reports/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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"
>
Expand Down Expand Up @@ -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>
Expand All @@ -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">
Expand All @@ -77,6 +140,7 @@
class="!w-full"
:options="reportIssueFilterTypes"
:placeholder="formatMessage(commonMessages.filterByLabel)"
@select="goToPage(1)"
/>
</div>
</div>
Expand All @@ -87,6 +151,7 @@
class="!w-full"
:options="projectTypeFilterTypes"
:placeholder="formatMessage(commonMessages.filterByLabel)"
@select="goToPage(1)"
/>
</div>
</div>
Expand All @@ -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>
Expand All @@ -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'
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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())
Expand Down
Loading