mirror of
https://github.com/modrinth/code.git
synced 2026-08-30 19:46:33 +00:00
fix moderation collapsibles & do a consistency pass (#7330)
* fix moderation collapsibles & do a consistency pass * extract tech review sources * improve buttons * buttn * improve markdown styling * fix nested details * save filters + fix reports reloading * prepr
This commit is contained in:
@@ -1,11 +1,8 @@
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<router-link
|
||||
class="mb-4 flex w-fit items-center gap-2 rounded-lg px-2 py-0.5 pl-0 text-link"
|
||||
:to="buildProjectHref(`/project/${route.params.id}/versions`)"
|
||||
>
|
||||
<ChevronLeftIcon class="shrink-0" /> {{ formatMessage(messages.allVersions) }}
|
||||
</router-link>
|
||||
<BackToParentLink :to="buildProjectHref(`/project/${route.params.id}/versions`)">
|
||||
{{ formatMessage(messages.allVersions) }}
|
||||
</BackToParentLink>
|
||||
<VersionPage
|
||||
v-if="version"
|
||||
:version="version"
|
||||
@@ -85,14 +82,13 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronLeftIcon,
|
||||
DownloadIcon,
|
||||
ExternalIcon,
|
||||
MoreVerticalIcon,
|
||||
ReportIcon,
|
||||
VersionIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { Button, ButtonLink, TeleportOverflowMenu } from '@modrinth/ui'
|
||||
import { BackToParentLink, Button, ButtonLink, TeleportOverflowMenu } from '@modrinth/ui'
|
||||
import {
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
|
||||
@@ -32,50 +32,19 @@
|
||||
>
|
||||
<MailIcon />
|
||||
</ButtonLink>
|
||||
<IconButton
|
||||
v-tooltip="copied ? `Copied to clipboard` : `Copy link`"
|
||||
:label="copied ? `Copied to clipboard` : `Copy link`"
|
||||
:disabled="copied"
|
||||
class="relative grid place-items-center overflow-hidden"
|
||||
@click="copyToClipboard(url)"
|
||||
>
|
||||
<CheckIcon
|
||||
class="absolute transition-all ease-in-out"
|
||||
:class="copied ? 'translate-y-0' : 'translate-y-7'"
|
||||
/>
|
||||
<LinkIcon
|
||||
class="absolute transition-all ease-in-out"
|
||||
:class="copied ? '-translate-y-7' : 'translate-y-0'"
|
||||
/>
|
||||
</IconButton>
|
||||
<CopyLinkButton :url="url" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
BlueskyIcon,
|
||||
CheckIcon,
|
||||
LinkIcon,
|
||||
MailIcon,
|
||||
MastodonIcon,
|
||||
TwitterIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { ButtonLink, IconButton } from '@modrinth/ui'
|
||||
import { BlueskyIcon, MailIcon, MastodonIcon, TwitterIcon } from '@modrinth/assets'
|
||||
import { ButtonLink, CopyLinkButton } from '@modrinth/ui'
|
||||
|
||||
const props = defineProps<{
|
||||
title?: string
|
||||
url: string
|
||||
}>()
|
||||
|
||||
const copied = ref(false)
|
||||
const encodedUrl = computed(() => encodeURIComponent(props.url))
|
||||
const encodedTitle = computed(() => (props.title ? encodeURIComponent(props.title) : undefined))
|
||||
|
||||
async function copyToClipboard(text: string) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
copied.value = true
|
||||
setTimeout(() => {
|
||||
copied.value = false
|
||||
}, 3000)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<form class="flex flex-col gap-2 sm:flex-row" @submit.prevent="executeSearch">
|
||||
<form class="flex flex-col gap-2 sm:flex-row sm:items-center" @submit.prevent="executeSearch">
|
||||
<Input
|
||||
v-model="query"
|
||||
:icon="SearchIcon"
|
||||
@@ -8,9 +8,10 @@
|
||||
autocomplete="off"
|
||||
placeholder="Search global trace keys..."
|
||||
clearable
|
||||
wrapper-class="flex-1 w-full"
|
||||
size="medium"
|
||||
wrapper-class="min-w-0 flex-1"
|
||||
/>
|
||||
<Button type="colored" color="brand" native-type="submit" :disabled="isLoading">
|
||||
<Button type="colored" color="brand" size="lg" native-type="submit" :disabled="isLoading">
|
||||
<SearchIcon aria-hidden="true" />
|
||||
Search
|
||||
</Button>
|
||||
@@ -20,7 +21,7 @@
|
||||
v-if="!isLoading && !loadError && total > 0"
|
||||
class="mt-4 flex flex-wrap items-center justify-between gap-3"
|
||||
>
|
||||
<p class="m-0 text-sm text-secondary">Showing {{ pageStart }}-{{ pageEnd }} of {{ total }}</p>
|
||||
<p class="m-0">Showing {{ pageStart }}-{{ pageEnd }} of {{ total }}</p>
|
||||
<Pagination :page="currentPage" :count="pageCount" @switch-page="switchPage" />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<span class="flex min-w-0 items-center gap-1.5 text-contrast">
|
||||
<span class="truncate">{{ label }}</span>
|
||||
<SpinnerIcon v-if="loading" class="size-4 shrink-0 animate-spin" aria-hidden="true" />
|
||||
<span v-else class="shrink-0">({{ formatNumber(count) }})</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SpinnerIcon } from '@modrinth/assets'
|
||||
import { useFormatNumber } from '@modrinth/ui'
|
||||
|
||||
defineProps<{
|
||||
label: string
|
||||
count: number
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
const formatNumber = useFormatNumber()
|
||||
</script>
|
||||
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div
|
||||
v-for="i in 3"
|
||||
:key="`loading-skeleton-${i}`"
|
||||
class="flex h-[98px] w-full animate-pulse rounded-2xl bg-surface-3"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col justify-between gap-2 lg:flex-row">
|
||||
<Input
|
||||
v-model="query"
|
||||
:icon="SearchIcon"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
:placeholder="formatMessage(commonMessages.searchPlaceholder)"
|
||||
clearable
|
||||
size="medium"
|
||||
wrapper-class="min-w-0 flex-1"
|
||||
@input="$emit('search')"
|
||||
/>
|
||||
<div
|
||||
class="flex flex-col items-stretch justify-end gap-2 sm:flex-row sm:items-center lg:flex-shrink-0"
|
||||
>
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-3">
|
||||
<slot name="meta" />
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center justify-end gap-2 sm:ml-auto">
|
||||
<slot name="pagination-extra" />
|
||||
<Pagination
|
||||
v-if="totalPages > 1"
|
||||
:page="page"
|
||||
:count="totalPages"
|
||||
@switch-page="$emit('switch-page', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SearchIcon } from '@modrinth/assets'
|
||||
import { commonMessages, Input, Pagination, useVIntl } from '@modrinth/ui'
|
||||
|
||||
const query = defineModel<string>({ required: true })
|
||||
|
||||
defineProps<{
|
||||
page: number
|
||||
totalPages: number
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
search: []
|
||||
'switch-page': [page: number]
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
</script>
|
||||
@@ -1,6 +1,9 @@
|
||||
<template>
|
||||
<div class="overflow-hidden rounded-2xl">
|
||||
<div class="bg-bg-raised p-4">
|
||||
<div
|
||||
class="relative overflow-hidden rounded-2xl border border-solid border-surface-4 transition-[transform,opacity] duration-[400ms] ease-in"
|
||||
:class="{ 'pointer-events-none translate-x-[120%] opacity-0': isSwipingAway }"
|
||||
>
|
||||
<div class="border-0 border-b border-solid border-surface-4 bg-bg-raised p-4">
|
||||
<div
|
||||
class="flex w-full flex-col items-start justify-between gap-3 sm:flex-row sm:items-center sm:gap-0"
|
||||
>
|
||||
@@ -180,6 +183,7 @@
|
||||
v-model:collapsed="isThreadCollapsed"
|
||||
:expand-text="expandText"
|
||||
collapse-text="Collapse thread"
|
||||
:disabled="disableCollapsing"
|
||||
>
|
||||
<div class="bg-surface-2 pt-2">
|
||||
<ThreadView
|
||||
@@ -231,7 +235,7 @@
|
||||
@click="reopenReport()"
|
||||
>
|
||||
<CheckCircleIcon class="size-4" />
|
||||
Reopen Thread
|
||||
Reopen report
|
||||
</Button>
|
||||
</template>
|
||||
<template #additionalActions="{ hasReply }">
|
||||
@@ -261,6 +265,17 @@
|
||||
</ThreadView>
|
||||
</div>
|
||||
</CollapsibleRegion>
|
||||
<div
|
||||
v-if="pendingDismiss"
|
||||
:key="dismissAnimationId"
|
||||
class="pointer-events-none absolute inset-x-0 bottom-0 h-1.5 overflow-hidden bg-highlight-red"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
class="report-dismiss-progress h-full w-full bg-red"
|
||||
:style="{ animationDuration: `${DISMISS_DELAY_MS}ms` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
@@ -288,7 +303,8 @@ import {
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { formatProjectType } from '@modrinth/utils'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import { isStaff } from '~/helpers/users.js'
|
||||
|
||||
@@ -302,16 +318,22 @@ import SharedInstanceReportContext, {
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
const client = injectModrinthClient()
|
||||
const auth = await useAuth()
|
||||
const queryClient = useQueryClient()
|
||||
const auth = useAuthState()
|
||||
|
||||
type SharedInstanceVersionDependency = Labrinth.Versions.v2.Dependency & {
|
||||
project_id?: string
|
||||
version_id?: string
|
||||
}
|
||||
|
||||
const DISMISS_DELAY_MS = 3000
|
||||
const SWIPE_DURATION_MS = 400
|
||||
|
||||
const props = defineProps<{
|
||||
report: ExtendedReport
|
||||
collapsed: boolean
|
||||
disableCollapsing?: boolean
|
||||
dismissAfterClose?: boolean
|
||||
sharedInstanceDetailsLoader?: () => Promise<SharedInstanceReportDetails>
|
||||
sharedInstanceVersionContentLoader?: (
|
||||
instanceId: string,
|
||||
@@ -319,6 +341,10 @@ const props = defineProps<{
|
||||
) => Promise<ContentItem[]>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
dismiss: []
|
||||
}>()
|
||||
|
||||
const reportThread = ref<{
|
||||
setReplyContent: (content: string) => void
|
||||
sendReply: (privateMessage?: boolean) => Promise<void>
|
||||
@@ -339,17 +365,38 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const didCloseReport = ref(false)
|
||||
const reportClosed = computed(() => {
|
||||
return didCloseReport.value || props.report.closed
|
||||
})
|
||||
const closedOverride = ref<boolean | null>(null)
|
||||
const pendingDismiss = ref(false)
|
||||
const isSwipingAway = ref(false)
|
||||
const dismissAnimationId = ref(0)
|
||||
const thread = ref(props.report.thread)
|
||||
let dismissTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
let swipeTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const reportClosed = computed(() => closedOverride.value ?? props.report.closed)
|
||||
|
||||
watch(
|
||||
() => props.report.thread,
|
||||
(value) => {
|
||||
thread.value = value
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.report.closed,
|
||||
(closed) => {
|
||||
if (closedOverride.value === closed) {
|
||||
closedOverride.value = null
|
||||
}
|
||||
},
|
||||
)
|
||||
const sharedInstanceQuarantined = computed(
|
||||
() =>
|
||||
sharedInstanceDetails.value?.quarantine ?? props.report.shared_instance?.quarantine ?? false,
|
||||
)
|
||||
|
||||
const threadWithReportBody = computed(() => {
|
||||
if (!props.report.thread) return null
|
||||
if (!thread.value) return null
|
||||
|
||||
const reportBodyMessage = {
|
||||
id: `report-body-${props.report.id}`,
|
||||
@@ -366,16 +413,15 @@ const threadWithReportBody = computed(() => {
|
||||
}
|
||||
|
||||
return {
|
||||
...props.report.thread,
|
||||
messages: [reportBodyMessage, ...props.report.thread.messages],
|
||||
members: [props.report.reporter_user, ...props.report.thread.members],
|
||||
...thread.value,
|
||||
messages: [reportBodyMessage, ...thread.value.messages],
|
||||
members: [props.report.reporter_user, ...thread.value.members],
|
||||
}
|
||||
})
|
||||
|
||||
const remainingMessageCount = computed(() => {
|
||||
if (!props.report.thread?.messages) return 0
|
||||
// Thread messages count (report body is injected separately)
|
||||
return props.report.thread.messages.length
|
||||
if (!thread.value?.messages) return 0
|
||||
return thread.value.messages.length
|
||||
})
|
||||
|
||||
const expandText = computed(() => {
|
||||
@@ -396,8 +442,10 @@ async function closeReport(reply = false) {
|
||||
closed: true,
|
||||
},
|
||||
})
|
||||
await refreshReportCaches()
|
||||
didCloseReport.value = true
|
||||
await refreshThread()
|
||||
closedOverride.value = true
|
||||
startDismissCountdown()
|
||||
void refreshReportQuery()
|
||||
} catch (err: any) {
|
||||
addNotification({
|
||||
title: 'Error closing report',
|
||||
@@ -408,6 +456,8 @@ async function closeReport(reply = false) {
|
||||
}
|
||||
|
||||
async function reopenReport() {
|
||||
cancelDismissCountdown()
|
||||
|
||||
try {
|
||||
await useBaseFetch(`report/${props.report.id}`, {
|
||||
method: 'PATCH',
|
||||
@@ -415,41 +465,84 @@ async function reopenReport() {
|
||||
closed: false,
|
||||
},
|
||||
})
|
||||
await refreshReportCaches()
|
||||
didCloseReport.value = false
|
||||
await refreshThread()
|
||||
closedOverride.value = false
|
||||
void refreshReportQuery()
|
||||
} catch (err: any) {
|
||||
addNotification({
|
||||
title: 'Error reopening report',
|
||||
text: err.data ? err.data.description : err,
|
||||
type: 'error',
|
||||
})
|
||||
if (reportClosed.value) {
|
||||
startDismissCountdown()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cancelDismissCountdown() {
|
||||
pendingDismiss.value = false
|
||||
if (dismissTimeout !== null) {
|
||||
clearTimeout(dismissTimeout)
|
||||
dismissTimeout = null
|
||||
}
|
||||
}
|
||||
|
||||
function startDismissCountdown() {
|
||||
if (!props.dismissAfterClose || isSwipingAway.value) return
|
||||
|
||||
cancelDismissCountdown()
|
||||
dismissAnimationId.value += 1
|
||||
pendingDismiss.value = true
|
||||
dismissTimeout = setTimeout(() => {
|
||||
dismissTimeout = null
|
||||
swipeAway()
|
||||
}, DISMISS_DELAY_MS)
|
||||
}
|
||||
|
||||
function swipeAway() {
|
||||
if (isSwipingAway.value) return
|
||||
|
||||
isSwipingAway.value = true
|
||||
swipeTimeout = setTimeout(() => {
|
||||
swipeTimeout = null
|
||||
emit('dismiss')
|
||||
}, SWIPE_DURATION_MS)
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
cancelDismissCountdown()
|
||||
if (swipeTimeout !== null) {
|
||||
clearTimeout(swipeTimeout)
|
||||
swipeTimeout = null
|
||||
}
|
||||
})
|
||||
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
const formatDateTime = useFormatDateTime({
|
||||
timeStyle: 'short',
|
||||
dateStyle: 'long',
|
||||
})
|
||||
|
||||
async function refreshReportCaches() {
|
||||
await Promise.allSettled([refreshThread(), refreshNuxtData('new-moderation-reports')])
|
||||
}
|
||||
|
||||
async function refreshThread() {
|
||||
const threadId = props.report.thread?.id ?? props.report.thread_id
|
||||
const threadId = thread.value?.id ?? props.report.thread?.id ?? props.report.thread_id
|
||||
if (!threadId) return
|
||||
|
||||
const thread = await useBaseFetch(`thread/${threadId}`)
|
||||
updateThread(thread)
|
||||
const nextThread = await useBaseFetch(`thread/${threadId}`)
|
||||
updateThread(nextThread)
|
||||
}
|
||||
|
||||
function updateThread(newThread: any) {
|
||||
thread.value = newThread
|
||||
if (props.report.thread) {
|
||||
Object.assign(props.report.thread, newThread)
|
||||
}
|
||||
}
|
||||
|
||||
function refreshReportQuery() {
|
||||
return queryClient.invalidateQueries({ queryKey: ['report', props.report.id] })
|
||||
}
|
||||
|
||||
async function getSharedInstanceVersion(
|
||||
instanceId: string,
|
||||
versionNumber: number,
|
||||
@@ -845,3 +938,21 @@ async function banSharedInstanceOwner(owner: SharedInstanceReportUser) {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.report-dismiss-progress {
|
||||
transform-origin: left center;
|
||||
animation-name: report-dismiss-fill;
|
||||
animation-timing-function: linear;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
@keyframes report-dismiss-fill {
|
||||
from {
|
||||
transform: scaleX(0);
|
||||
}
|
||||
to {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,824 @@
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
CopyIcon,
|
||||
LoaderCircleIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { Collapsible, IconButton, injectNotificationManager, Toggle } from '@modrinth/ui'
|
||||
import { capitalizeString, highlightCodeLines } from '@modrinth/utils'
|
||||
import { computed, nextTick, reactive, ref, watch } from 'vue'
|
||||
|
||||
import {
|
||||
canUpdateGlobalDetail,
|
||||
getFileDetailCount,
|
||||
getSeverityBadgeColor,
|
||||
severityOrder,
|
||||
truncateMiddle,
|
||||
verdictToDecision,
|
||||
} from './helpers'
|
||||
import TechRevVerdictButtons from './TechRevVerdictButtons.vue'
|
||||
import type { ClassGroup, FlagItem, FlattenedFileReport, JarGroup } from './types'
|
||||
import { injectTechReviewDecisions } from './use-tech-review-decisions'
|
||||
|
||||
const props = defineProps<{
|
||||
file: FlattenedFileReport
|
||||
focusedDetailId?: string | null
|
||||
loadingIssues: Set<string>
|
||||
decompiledSources: Map<string, string>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
refetch: []
|
||||
loadIssueSources: [issueIds: string[]]
|
||||
allFlagsResolved: []
|
||||
}>()
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const {
|
||||
updatingDetails,
|
||||
updatingGlobalDetailKeys,
|
||||
getDetailDecision,
|
||||
isPreReviewed,
|
||||
getFileMarkedCount,
|
||||
getMarkedFlagsCount,
|
||||
isDetailGloballyPassed,
|
||||
isDetailGloballyResolved,
|
||||
applyDecisionToRelatedDetails,
|
||||
getToggledDetailVerdict,
|
||||
updateIssueDetails,
|
||||
updateGlobalIssueDetails,
|
||||
} = injectTechReviewDecisions()
|
||||
|
||||
const hideGloballyPassed = ref(true)
|
||||
const isBatchUpdating = ref(false)
|
||||
const expandedClasses = reactive<Set<string>>(new Set())
|
||||
const autoExpandedFileIds = reactive<Set<string>>(new Set())
|
||||
const showCopyFeedback = reactive<Map<string, boolean>>(new Map())
|
||||
const highlightedSourceCache = reactive<Map<string, { source: string; lines: string[] }>>(new Map())
|
||||
const LAZY_LOAD_CLASS_SOURCE_MINIMUM = 2
|
||||
|
||||
const globallyPassedCount = computed(() => {
|
||||
return props.file.issues.reduce(
|
||||
(count, issue) => count + issue.details.filter(isDetailGloballyPassed).length,
|
||||
0,
|
||||
)
|
||||
})
|
||||
|
||||
const globallyResolvedCount = computed(() => {
|
||||
return props.file.issues.reduce(
|
||||
(count, issue) => count + issue.details.filter(isDetailGloballyResolved).length,
|
||||
0,
|
||||
)
|
||||
})
|
||||
|
||||
const remainingUnmarkedCount = computed(() => {
|
||||
return getFileDetailCount(props.file) - getFileMarkedCount(props.file)
|
||||
})
|
||||
|
||||
const selectedFileFlags = computed<FlagItem[]>(() =>
|
||||
props.file.issues.flatMap((issue) =>
|
||||
issue.details.map((detail) => ({
|
||||
issueId: issue.id,
|
||||
issueType: issue.issue_type,
|
||||
detail,
|
||||
})),
|
||||
),
|
||||
)
|
||||
|
||||
function getJarFlags(jarGroup: JarGroup): FlagItem[] {
|
||||
return jarGroup.classes.flatMap((classItem) => classItem.flags)
|
||||
}
|
||||
|
||||
function getJarRemainingUnmarkedCount(jarGroup: JarGroup): number {
|
||||
const flags = getJarFlags(jarGroup)
|
||||
return flags.length - getMarkedFlagsCount(flags)
|
||||
}
|
||||
|
||||
function getRemainingGlobalDetailCount(flags: FlagItem[]): number {
|
||||
return new Set(
|
||||
flags
|
||||
.filter(
|
||||
(flag) =>
|
||||
getDetailDecision(flag.detail.id, flag.detail.status) === 'pending' &&
|
||||
canUpdateGlobalDetail(flag.detail),
|
||||
)
|
||||
.map((flag) => flag.detail.key),
|
||||
).size
|
||||
}
|
||||
|
||||
function maybeReturnToFileList() {
|
||||
if (getFileMarkedCount(props.file) === getFileDetailCount(props.file)) {
|
||||
emit('allFlagsResolved')
|
||||
}
|
||||
}
|
||||
|
||||
async function batchMarkRemainingGlobally(flags: FlagItem[], verdict: 'safe' | 'unsafe') {
|
||||
if (isBatchUpdating.value) return
|
||||
|
||||
const detailsByKey = new Map(
|
||||
flags
|
||||
.filter(
|
||||
(flag) =>
|
||||
getDetailDecision(flag.detail.id, flag.detail.status) === 'pending' &&
|
||||
canUpdateGlobalDetail(flag.detail),
|
||||
)
|
||||
.map((flag) => [flag.detail.key, flag.detail]),
|
||||
)
|
||||
const details = [...detailsByKey.values()]
|
||||
|
||||
if (details.length === 0) return
|
||||
|
||||
isBatchUpdating.value = true
|
||||
try {
|
||||
await updateGlobalIssueDetails(details.map((detail) => ({ detail_key: detail.key, verdict })))
|
||||
|
||||
applyDecisionToRelatedDetails(
|
||||
details.map((detail) => detail.id),
|
||||
verdictToDecision(verdict),
|
||||
'global',
|
||||
)
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: `Globally marked ${details.length} trace keys as ${verdict}`,
|
||||
text: `All remaining eligible traces have been globally marked as ${
|
||||
verdict === 'safe' ? 'false positives' : 'malicious'
|
||||
}.`,
|
||||
})
|
||||
|
||||
maybeReturnToFileList()
|
||||
emit('refetch')
|
||||
} catch (error) {
|
||||
console.error('Failed to batch update global traces:', error)
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Global batch update failed',
|
||||
text: 'An error occurred while globally updating traces.',
|
||||
})
|
||||
} finally {
|
||||
isBatchUpdating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function batchMarkRemaining(flags: FlagItem[], verdict: 'safe' | 'unsafe', inJar = false) {
|
||||
if (isBatchUpdating.value) return
|
||||
|
||||
const detailIds = flags
|
||||
.filter((flag) => getDetailDecision(flag.detail.id, flag.detail.status) === 'pending')
|
||||
.map((flag) => flag.detail.id)
|
||||
|
||||
if (detailIds.length === 0) return
|
||||
|
||||
isBatchUpdating.value = true
|
||||
try {
|
||||
await updateIssueDetails(detailIds.map((detail_id) => ({ detail_id, verdict })))
|
||||
applyDecisionToRelatedDetails(detailIds, verdictToDecision(verdict), 'local')
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: `Marked ${detailIds.length} traces as ${verdict}`,
|
||||
text: `All remaining traces${inJar ? ' in this JAR' : ''} have been marked as ${
|
||||
verdict === 'safe' ? 'false positives' : 'malicious'
|
||||
}.`,
|
||||
})
|
||||
|
||||
maybeReturnToFileList()
|
||||
emit('refetch')
|
||||
} catch (error) {
|
||||
console.error('Failed to batch update:', error)
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Batch update failed',
|
||||
text: 'An error occurred while updating traces.',
|
||||
})
|
||||
} finally {
|
||||
isBatchUpdating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function updateLocalDetailAction(
|
||||
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
|
||||
decision: 'safe' | 'malware',
|
||||
) {
|
||||
return updateDetailStatus(detail.id, getToggledDetailVerdict(detail, decision, 'local'))
|
||||
}
|
||||
|
||||
function updateGlobalDetailAction(
|
||||
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
|
||||
decision: 'safe' | 'malware',
|
||||
) {
|
||||
return updateGlobalDetailStatus(detail, getToggledDetailVerdict(detail, decision, 'global'))
|
||||
}
|
||||
|
||||
async function updateDetailStatus(
|
||||
detailId: string,
|
||||
verdict: Labrinth.TechReview.Internal.DelphiReportIssueStatus,
|
||||
) {
|
||||
const detail = props.file.issues.flatMap((issue) => issue.details).find((d) => d.id === detailId)
|
||||
const priorDecision = detail ? getDetailDecision(detail.id, detail.status) : 'pending'
|
||||
|
||||
updatingDetails.add(detailId)
|
||||
|
||||
const previousMarkedCount = getFileMarkedCount(props.file)
|
||||
|
||||
try {
|
||||
await updateIssueDetails([{ detail_id: detailId, verdict }])
|
||||
|
||||
const { otherMatchedCount } = applyDecisionToRelatedDetails(
|
||||
[detailId],
|
||||
verdictToDecision(verdict),
|
||||
'local',
|
||||
)
|
||||
|
||||
if (verdict !== 'pending' && priorDecision === 'pending') {
|
||||
for (const classGroup of groupedByClass.value) {
|
||||
const hasThisDetail = classGroup.flags.some((f) => f.detail.id === detailId)
|
||||
if (hasThisDetail && getMarkedFlagsCount(classGroup.flags) === classGroup.flags.length) {
|
||||
expandedClasses.delete(classGroup.key)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (verdict !== 'pending') {
|
||||
const markedCount = getFileMarkedCount(props.file)
|
||||
const totalCount = getFileDetailCount(props.file)
|
||||
if (previousMarkedCount != markedCount && markedCount === totalCount) {
|
||||
emit('allFlagsResolved')
|
||||
}
|
||||
}
|
||||
|
||||
const otherText =
|
||||
otherMatchedCount > 0
|
||||
? ` (${otherMatchedCount} other trace${otherMatchedCount === 1 ? '' : 's'} also marked)`
|
||||
: ''
|
||||
|
||||
if (verdict === 'pending') {
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Local trace verdict unset',
|
||||
text: `The project-local verdict has been removed.${otherText}`,
|
||||
})
|
||||
} else if (verdict === 'safe') {
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Issue marked as pass',
|
||||
text: `This issue has been marked as a false positive.${otherText}`,
|
||||
})
|
||||
} else {
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Issue marked as fail',
|
||||
text: `This issue has been flagged as malicious.${otherText}`,
|
||||
})
|
||||
}
|
||||
|
||||
emit('refetch')
|
||||
} catch (error) {
|
||||
console.error('Failed to update detail status:', error)
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Failed to update issue',
|
||||
text: 'An error occurred while updating the issue status.',
|
||||
})
|
||||
} finally {
|
||||
updatingDetails.delete(detailId)
|
||||
}
|
||||
}
|
||||
|
||||
async function updateGlobalDetailStatus(
|
||||
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
|
||||
verdict: Labrinth.TechReview.Internal.DelphiReportIssueStatus,
|
||||
) {
|
||||
if (!canUpdateGlobalDetail(detail)) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Global update unavailable',
|
||||
text: 'Generated trace keys cannot be marked globally.',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
updatingGlobalDetailKeys.add(detail.key)
|
||||
|
||||
const previousMarkedCount = getFileMarkedCount(props.file)
|
||||
|
||||
try {
|
||||
await updateGlobalIssueDetails([{ detail_key: detail.key, verdict }])
|
||||
|
||||
const { otherMatchedCount } = applyDecisionToRelatedDetails(
|
||||
[detail.id],
|
||||
verdictToDecision(verdict),
|
||||
'global',
|
||||
)
|
||||
|
||||
if (verdict !== 'pending') {
|
||||
for (const classGroup of groupedByClass.value) {
|
||||
if (getMarkedFlagsCount(classGroup.flags) === classGroup.flags.length) {
|
||||
expandedClasses.delete(classGroup.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (verdict !== 'pending') {
|
||||
const markedCount = getFileMarkedCount(props.file)
|
||||
const totalCount = getFileDetailCount(props.file)
|
||||
if (previousMarkedCount != markedCount && markedCount === totalCount) {
|
||||
emit('allFlagsResolved')
|
||||
}
|
||||
}
|
||||
|
||||
const otherText =
|
||||
otherMatchedCount > 0
|
||||
? ` (${otherMatchedCount} other trace${otherMatchedCount === 1 ? '' : 's'} also marked in this project)`
|
||||
: ''
|
||||
|
||||
if (verdict === 'pending') {
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Global trace verdict unset',
|
||||
text: `The global verdict for this trace key has been removed.${otherText}`,
|
||||
})
|
||||
} else {
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title:
|
||||
verdict === 'safe' ? 'Trace globally marked as pass' : 'Trace globally marked as fail',
|
||||
text:
|
||||
verdict === 'safe'
|
||||
? `This trace key has been marked as a global false positive.${otherText}`
|
||||
: `This trace key has been globally flagged as malicious.${otherText}`,
|
||||
})
|
||||
}
|
||||
|
||||
emit('refetch')
|
||||
} catch (error) {
|
||||
console.error('Failed to update global detail status:', error)
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Failed to update global trace',
|
||||
text: 'An error occurred while updating the global trace status.',
|
||||
})
|
||||
} finally {
|
||||
updatingGlobalDetailKeys.delete(detail.key)
|
||||
}
|
||||
}
|
||||
|
||||
function splitJarSegments(jar: string | null, currentFileName: string | null): string[] {
|
||||
if (!jar) return []
|
||||
const segments = jar
|
||||
.split(/[/#]/)
|
||||
.map((s) => decodeURIComponent(s.trim()))
|
||||
.filter((s) => s.length > 0)
|
||||
if (segments.length > 0 && currentFileName && segments[0] === currentFileName) {
|
||||
return segments.slice(1)
|
||||
}
|
||||
return segments
|
||||
}
|
||||
|
||||
const groupedByClass = computed<ClassGroup[]>(() => {
|
||||
const classMap = new Map<string, ClassGroup>()
|
||||
|
||||
for (const issue of props.file.issues) {
|
||||
for (const detail of issue.details) {
|
||||
if (hideGloballyPassed.value && isDetailGloballyPassed(detail)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const classKey = `${detail.jar ?? ''}::${detail.file_path}`
|
||||
if (!classMap.has(classKey)) {
|
||||
classMap.set(classKey, {
|
||||
key: classKey,
|
||||
jar: detail.jar ?? null,
|
||||
filePath: detail.file_path,
|
||||
flags: [],
|
||||
})
|
||||
}
|
||||
classMap.get(classKey)!.flags.push({
|
||||
issueId: issue.id,
|
||||
issueType: issue.issue_type,
|
||||
detail,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const classGroup of classMap.values()) {
|
||||
classGroup.flags.sort((a, b) => {
|
||||
const aPreReviewed = isPreReviewed(a.detail.id, a.detail.status)
|
||||
const bPreReviewed = isPreReviewed(b.detail.id, b.detail.status)
|
||||
return aPreReviewed === bPreReviewed ? 0 : aPreReviewed ? 1 : -1
|
||||
})
|
||||
}
|
||||
|
||||
return Array.from(classMap.values())
|
||||
})
|
||||
|
||||
const groupedByJar = computed<JarGroup[]>(() => {
|
||||
const jarMap = new Map<string, JarGroup>()
|
||||
|
||||
for (const classItem of groupedByClass.value) {
|
||||
const jarKey = classItem.jar ?? ''
|
||||
if (!jarMap.has(jarKey)) {
|
||||
jarMap.set(jarKey, {
|
||||
key: jarKey,
|
||||
jar: classItem.jar,
|
||||
segments: splitJarSegments(classItem.jar, props.file.file_name),
|
||||
classes: [],
|
||||
})
|
||||
}
|
||||
jarMap.get(jarKey)!.classes.push(classItem)
|
||||
}
|
||||
|
||||
return Array.from(jarMap.values()).sort((a, b) => {
|
||||
const aRoot = a.segments.length === 0
|
||||
const bRoot = b.segments.length === 0
|
||||
return aRoot === bRoot ? 0 : aRoot ? -1 : 1
|
||||
})
|
||||
})
|
||||
|
||||
function getHighestSeverityInClass(flags: FlagItem[]): Labrinth.TechReview.Internal.DelphiSeverity {
|
||||
return flags.reduce(
|
||||
(highest, flag) =>
|
||||
severityOrder[flag.detail.severity] > severityOrder[highest] ? flag.detail.severity : highest,
|
||||
'low' as Labrinth.TechReview.Internal.DelphiSeverity,
|
||||
)
|
||||
}
|
||||
|
||||
function getClassDecompiledSource(classItem: ClassGroup): string | undefined {
|
||||
for (const flag of classItem.flags) {
|
||||
const source = props.decompiledSources.get(flag.detail.id)
|
||||
if (source) return source
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function getHighlightedClassSource(classItem: ClassGroup): string[] {
|
||||
const source = getClassDecompiledSource(classItem)
|
||||
if (!source) return []
|
||||
|
||||
const cached = highlightedSourceCache.get(classItem.key)
|
||||
if (cached?.source === source) return cached.lines
|
||||
|
||||
const lines = highlightCodeLines(source, 'java')
|
||||
highlightedSourceCache.set(classItem.key, { source, lines })
|
||||
return lines
|
||||
}
|
||||
|
||||
function isClassLoadingSource(classItem: ClassGroup): boolean {
|
||||
return classItem.flags.some((flag) => props.loadingIssues.has(flag.issueId))
|
||||
}
|
||||
|
||||
function loadClassSources(classItem: ClassGroup) {
|
||||
const issueIds = [...new Set(classItem.flags.map((flag) => flag.issueId))]
|
||||
if (issueIds.length > 0) {
|
||||
emit('loadIssueSources', issueIds)
|
||||
}
|
||||
}
|
||||
|
||||
function expandClass(classItem: ClassGroup) {
|
||||
if (expandedClasses.has(classItem.key)) return
|
||||
expandedClasses.add(classItem.key)
|
||||
loadClassSources(classItem)
|
||||
}
|
||||
|
||||
function toggleClass(classItem: ClassGroup) {
|
||||
if (expandedClasses.has(classItem.key)) {
|
||||
expandedClasses.delete(classItem.key)
|
||||
} else {
|
||||
expandClass(classItem)
|
||||
}
|
||||
}
|
||||
|
||||
async function copyToClipboard(code: string, detailId: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code)
|
||||
showCopyFeedback.set(detailId, true)
|
||||
setTimeout(() => {
|
||||
showCopyFeedback.delete(detailId)
|
||||
}, 2000)
|
||||
} catch (error) {
|
||||
console.error('Failed to copy code:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function focusDetail(detailId: string) {
|
||||
await nextTick()
|
||||
|
||||
const classItem = groupedByClass.value.find((group) =>
|
||||
group.flags.some((flag) => flag.detail.id === detailId),
|
||||
)
|
||||
|
||||
if (classItem) {
|
||||
expandClass(classItem)
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
|
||||
if (!import.meta.client) return
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
document.getElementById(`tech-review-detail-${detailId}`)?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
[() => props.focusedDetailId, () => props.file.id],
|
||||
([detailId]) => {
|
||||
if (detailId) {
|
||||
focusDetail(detailId)
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
[() => props.file.id, groupedByClass],
|
||||
([fileId, classes]) => {
|
||||
if (!fileId || classes.length === 0 || autoExpandedFileIds.has(fileId)) return
|
||||
|
||||
autoExpandedFileIds.add(fileId)
|
||||
|
||||
if (classes.length < LAZY_LOAD_CLASS_SOURCE_MINIMUM) {
|
||||
for (const classItem of classes) {
|
||||
expandClass(classItem)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="getFileDetailCount(file) > 0"
|
||||
class="flex flex-wrap items-center justify-between gap-3 p-4"
|
||||
>
|
||||
<TechRevVerdictButtons
|
||||
v-if="remainingUnmarkedCount > 0"
|
||||
variant="remaining"
|
||||
:remaining-count="remainingUnmarkedCount"
|
||||
:global-disabled="isBatchUpdating || getRemainingGlobalDetailCount(selectedFileFlags) === 0"
|
||||
:local-disabled="isBatchUpdating"
|
||||
@global-safe="batchMarkRemainingGlobally(selectedFileFlags, 'safe')"
|
||||
@local-safe="batchMarkRemaining(selectedFileFlags, 'safe')"
|
||||
@local-unsafe="batchMarkRemaining(selectedFileFlags, 'unsafe')"
|
||||
@global-unsafe="batchMarkRemainingGlobally(selectedFileFlags, 'unsafe')"
|
||||
/>
|
||||
<label class="ml-auto flex cursor-pointer items-center gap-3 text-sm">
|
||||
<span class="text-right text-secondary">
|
||||
Hide globally passed
|
||||
<span class="text-tertiary block text-xs">
|
||||
{{ globallyResolvedCount }}/{{ getFileDetailCount(file) }} traces globally resolved
|
||||
</span>
|
||||
</span>
|
||||
<Toggle v-model="hideGloballyPassed" :disabled="globallyPassedCount === 0" small />
|
||||
</label>
|
||||
</div>
|
||||
<div v-for="jarGroup in groupedByJar" :key="jarGroup.key" class="flex flex-col gap-1 px-4 pb-4">
|
||||
<div v-if="jarGroup.segments.length > 0" class="my-2">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
<template v-for="(segment, index) in jarGroup.segments" :key="`${jarGroup.key}-${index}`">
|
||||
<span
|
||||
class="font-mono text-sm"
|
||||
:class="
|
||||
index === jarGroup.segments.length - 1
|
||||
? 'font-semibold text-contrast'
|
||||
: 'text-secondary'
|
||||
"
|
||||
>
|
||||
{{ segment }}
|
||||
</span>
|
||||
<ChevronRightIcon
|
||||
v-if="index < jarGroup.segments.length - 1"
|
||||
class="size-4 text-secondary"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<TechRevVerdictButtons
|
||||
v-if="getJarRemainingUnmarkedCount(jarGroup) > 0"
|
||||
variant="remaining"
|
||||
jar
|
||||
:remaining-count="getJarRemainingUnmarkedCount(jarGroup)"
|
||||
:global-disabled="
|
||||
isBatchUpdating || getRemainingGlobalDetailCount(getJarFlags(jarGroup)) === 0
|
||||
"
|
||||
:local-disabled="isBatchUpdating"
|
||||
@global-safe="batchMarkRemainingGlobally(getJarFlags(jarGroup), 'safe')"
|
||||
@local-safe="batchMarkRemaining(getJarFlags(jarGroup), 'safe', true)"
|
||||
@local-unsafe="batchMarkRemaining(getJarFlags(jarGroup), 'unsafe', true)"
|
||||
@global-unsafe="batchMarkRemainingGlobally(getJarFlags(jarGroup), 'unsafe')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="classItem in jarGroup.classes"
|
||||
:key="classItem.key"
|
||||
class="overflow-clip rounded-xl border border-solid border-surface-4"
|
||||
>
|
||||
<div
|
||||
class="flex cursor-pointer items-center justify-between bg-surface-3 p-2 transition-colors duration-200 hover:bg-surface-4"
|
||||
@click="toggleClass(classItem)"
|
||||
>
|
||||
<div class="my-auto flex items-center gap-2">
|
||||
<IconButton
|
||||
type="quiet"
|
||||
label="Toggle details"
|
||||
class="transition-transform"
|
||||
:class="{ 'rotate-180': expandedClasses.has(classItem.key) }"
|
||||
>
|
||||
<ChevronDownIcon class="h-5 w-5 text-contrast" />
|
||||
</IconButton>
|
||||
|
||||
<span v-tooltip="classItem.filePath" class="font-mono text-sm font-semibold">{{
|
||||
truncateMiddle(classItem.filePath)
|
||||
}}</span>
|
||||
|
||||
<div
|
||||
class="rounded-full border-solid px-2.5 py-1"
|
||||
:class="getSeverityBadgeColor(getHighestSeverityInClass(classItem.flags))"
|
||||
>
|
||||
<span class="text-sm font-medium">{{
|
||||
capitalizeString(getHighestSeverityInClass(classItem.flags))
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-center gap-1 rounded-full border border-solid px-2.5 py-1 text-sm"
|
||||
:class="
|
||||
getMarkedFlagsCount(classItem.flags) === classItem.flags.length
|
||||
? 'border-green/60 bg-highlight-green text-green'
|
||||
: 'border-red/60 bg-highlight-red text-red'
|
||||
"
|
||||
>
|
||||
<CheckIcon
|
||||
v-if="getMarkedFlagsCount(classItem.flags) === classItem.flags.length"
|
||||
class="size-4"
|
||||
/>
|
||||
{{ getMarkedFlagsCount(classItem.flags) }}/{{ classItem.flags.length }} flags
|
||||
</div>
|
||||
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="isClassLoadingSource(classItem)"
|
||||
class="rounded-full border border-solid border-surface-5 bg-surface-3 px-2.5 py-1"
|
||||
>
|
||||
<span class="flex items-center gap-1.5 text-sm font-medium text-secondary">
|
||||
<LoaderCircleIcon class="size-4 animate-spin" />
|
||||
Loading source...
|
||||
</span>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Collapsible :collapsed="!expandedClasses.has(classItem.key)">
|
||||
<div class="flex flex-col gap-2 border-0 border-t border-solid border-surface-4 p-2">
|
||||
<div
|
||||
v-for="flag in classItem.flags"
|
||||
:id="`tech-review-detail-${flag.detail.id}`"
|
||||
:key="`${flag.issueId}-${flag.detail.id}`"
|
||||
class="flex flex-col gap-2 rounded-lg border border-solid border-surface-5 bg-surface-3 py-2 pl-4 last:border-b-0"
|
||||
:class="{
|
||||
'!border-brand bg-brand-highlight': focusedDetailId === flag.detail.id,
|
||||
}"
|
||||
>
|
||||
<div class="grid grid-cols-[1fr_auto] items-center">
|
||||
<div
|
||||
class="flex items-center gap-2"
|
||||
:class="{
|
||||
'opacity-50': isPreReviewed(flag.detail.id, flag.detail.status),
|
||||
}"
|
||||
>
|
||||
<span class="text-base font-semibold text-contrast">{{
|
||||
flag.issueType.replace(/_/g, ' ')
|
||||
}}</span>
|
||||
<div
|
||||
class="rounded-full border-solid px-2.5 py-1"
|
||||
:class="getSeverityBadgeColor(flag.detail.severity)"
|
||||
>
|
||||
<span class="text-sm font-medium">{{
|
||||
capitalizeString(flag.detail.severity)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="me-2 flex items-center justify-end gap-2">
|
||||
<TechRevVerdictButtons
|
||||
variant="trace"
|
||||
:detail="flag.detail"
|
||||
@global-safe="updateGlobalDetailAction(flag.detail, 'safe')"
|
||||
@local-safe="updateLocalDetailAction(flag.detail, 'safe')"
|
||||
@local-unsafe="updateLocalDetailAction(flag.detail, 'malware')"
|
||||
@global-unsafe="updateGlobalDetailAction(flag.detail, 'malware')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="flag.detail.data && Object.keys(flag.detail.data).length > 0"
|
||||
class="flex flex-wrap gap-x-4 gap-y-1 pr-4 text-sm"
|
||||
>
|
||||
<div
|
||||
v-for="[key, value] in Object.entries(flag.detail.data).sort(([a], [b]) =>
|
||||
a.localeCompare(b),
|
||||
)"
|
||||
:key="key"
|
||||
class="flex items-center gap-1.5"
|
||||
>
|
||||
<span class="text-secondary">{{ key }}:</span>
|
||||
<a
|
||||
v-if="typeof value === 'string' && value.startsWith('http')"
|
||||
:href="value"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-brand-blue hover:underline"
|
||||
>
|
||||
{{ value }}
|
||||
</a>
|
||||
<span v-else class="font-mono text-contrast">{{ value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="getHighlightedClassSource(classItem).length > 0"
|
||||
class="relative inset-0 overflow-hidden rounded-lg border border-solid border-surface-5 bg-surface-4"
|
||||
>
|
||||
<IconButton
|
||||
v-tooltip="`Copy code`"
|
||||
type="quiet"
|
||||
:label="`Copy code`"
|
||||
class="!absolute right-2 top-2 border-[1px]"
|
||||
@click="copyToClipboard(getClassDecompiledSource(classItem)!, classItem.key)"
|
||||
>
|
||||
<CopyIcon v-if="!showCopyFeedback.get(classItem.key)" />
|
||||
<CheckIcon v-else />
|
||||
</IconButton>
|
||||
|
||||
<div class="overflow-x-auto bg-surface-3 py-3">
|
||||
<div
|
||||
v-for="(line, n) in getHighlightedClassSource(classItem)"
|
||||
:key="n"
|
||||
class="flex font-mono text-[13px] leading-[1.6]"
|
||||
>
|
||||
<div
|
||||
class="select-none border-0 border-r border-solid border-surface-5 px-4 py-0 text-right text-primary"
|
||||
style="min-width: 3.5rem"
|
||||
>
|
||||
{{ n + 1 }}
|
||||
</div>
|
||||
<div class="flex-1 px-4 py-0 text-primary">
|
||||
<pre v-html="line || ' '"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="isClassLoadingSource(classItem)"
|
||||
class="rounded-lg border border-solid border-surface-5 bg-surface-3 p-4"
|
||||
>
|
||||
<p class="flex items-center gap-2 text-sm text-secondary">
|
||||
<LoaderCircleIcon class="size-4 animate-spin" />
|
||||
Loading source...
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="rounded-lg border border-solid border-surface-5 bg-surface-3 p-4">
|
||||
<p class="text-sm text-secondary">
|
||||
Source code not available or failed to decompile for this file.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
pre {
|
||||
all: unset;
|
||||
display: inline;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.fade-enter-active {
|
||||
transition: opacity 0.3s ease-in;
|
||||
transition-delay: 0.2s;
|
||||
}
|
||||
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.15s ease-out;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,133 @@
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, DownloadIcon, ExternalIcon, VersionIcon } from '@modrinth/assets'
|
||||
import { ButtonLink, useFormatBytes } from '@modrinth/ui'
|
||||
import { capitalizeString } from '@modrinth/utils'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import {
|
||||
getFileDetailCount,
|
||||
getFileHighestSeverity,
|
||||
getSeverityBadgeColor,
|
||||
getVersionLabel,
|
||||
getVersionPageHref,
|
||||
truncateMiddle,
|
||||
} from './helpers'
|
||||
import type { FlattenedFileReport } from './types'
|
||||
import { injectTechReviewDecisions } from './use-tech-review-decisions'
|
||||
|
||||
const props = defineProps<{
|
||||
reports: FlattenedFileReport[]
|
||||
project: {
|
||||
id: string
|
||||
slug?: string
|
||||
project_types: string[]
|
||||
}
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
viewFlags: [file: FlattenedFileReport]
|
||||
}>()
|
||||
|
||||
const formatBytes = useFormatBytes()
|
||||
const { getFileMarkedCount } = injectTechReviewDecisions()
|
||||
|
||||
const allFiles = computed(() => {
|
||||
return [...props.reports].sort((a, b) => {
|
||||
const aComplete = getFileMarkedCount(a) === getFileDetailCount(a)
|
||||
const bComplete = getFileMarkedCount(b) === getFileDetailCount(b)
|
||||
return aComplete === bComplete ? 0 : aComplete ? 1 : -1
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-for="(file, idx) in allFiles"
|
||||
:key="idx"
|
||||
class="flex items-center justify-between border-0 border-x border-b border-solid border-surface-3 bg-surface-2 px-4 py-3"
|
||||
:class="{
|
||||
'rounded-bl-2xl rounded-br-2xl': idx === allFiles.length - 1,
|
||||
'bg-[#E8E8E8] dark:bg-[#1A1C20]': idx % 2 === 1,
|
||||
}"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
v-tooltip="file.file_name"
|
||||
class="py-2 font-medium text-contrast"
|
||||
:aria-label="`View flags for ${file.file_name}`"
|
||||
tabindex="0"
|
||||
:class="{ 'cursor-pointer hover:underline': getFileDetailCount(file) > 0 }"
|
||||
@click="getFileDetailCount(file) > 0 && emit('viewFlags', file)"
|
||||
>
|
||||
{{ truncateMiddle(file.file_name, 50) }}
|
||||
</span>
|
||||
<div class="rounded-full border border-solid border-surface-5 bg-surface-3 px-2.5 py-1">
|
||||
<span class="text-sm font-medium text-secondary">{{ formatBytes(file.file_size) }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="getFileDetailCount(file) > 0"
|
||||
class="rounded-full border-solid px-2.5 py-1"
|
||||
:class="getSeverityBadgeColor(getFileHighestSeverity(file))"
|
||||
>
|
||||
<span class="text-sm font-medium">{{
|
||||
capitalizeString(getFileHighestSeverity(file))
|
||||
}}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="getFileDetailCount(file) > 0"
|
||||
class="flex items-center gap-1 rounded-full border border-solid px-2.5 py-1 text-sm"
|
||||
:class="
|
||||
getFileMarkedCount(file) === getFileDetailCount(file)
|
||||
? 'border-green/60 bg-highlight-green text-green'
|
||||
: 'border-red/60 bg-highlight-red text-red'
|
||||
"
|
||||
>
|
||||
<CheckIcon v-if="getFileMarkedCount(file) === getFileDetailCount(file)" class="size-4" />
|
||||
{{ getFileMarkedCount(file) }}/{{ getFileDetailCount(file) }} flags
|
||||
</div>
|
||||
<!-- TODO: remove toString when backend supports it properly -->
|
||||
<div
|
||||
v-else-if="file.flag_reason.toString() === 'manual'"
|
||||
class="border-blue/60 flex items-center gap-1 rounded-full border border-solid bg-highlight-blue px-2.5 py-1 text-sm text-blue"
|
||||
>
|
||||
Manual review
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="border-green/60 flex items-center gap-1 rounded-full border border-solid bg-highlight-green px-2.5 py-1 text-sm text-green"
|
||||
>
|
||||
No flags
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<ButtonLink
|
||||
type="outlined"
|
||||
target="_blank"
|
||||
:href="getVersionPageHref(project, file.version_id)"
|
||||
:aria-label="`Open version ${getVersionLabel(file)}`"
|
||||
>
|
||||
<VersionIcon aria-hidden="true" /> {{ getVersionLabel(file) }}
|
||||
</ButtonLink>
|
||||
<ButtonLink
|
||||
type="outlined"
|
||||
target="_blank"
|
||||
:href="`https://slicer.run/?url=${encodeURIComponent(file.download_url)}`"
|
||||
aria-label="Open in Slicer"
|
||||
>
|
||||
<ExternalIcon aria-hidden="true" /> Slicer
|
||||
</ButtonLink>
|
||||
<ButtonLink
|
||||
v-tooltip="`Download ${file.file_name} (${formatBytes(file.file_size)})`"
|
||||
type="outlined"
|
||||
:href="file.download_url"
|
||||
:download="file.file_name"
|
||||
tabindex="0"
|
||||
icon-only
|
||||
circular
|
||||
>
|
||||
<DownloadIcon />
|
||||
</ButtonLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,416 @@
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
BugIcon,
|
||||
CheckIcon,
|
||||
DropdownIcon,
|
||||
EyeOffIcon,
|
||||
ScaleIcon,
|
||||
ShieldCheckIcon,
|
||||
SpinnerIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { type TechReviewContext, techReviewQuickReplies } from '@modrinth/moderation'
|
||||
import {
|
||||
Button,
|
||||
type ButtonMenuOption,
|
||||
CollapsibleRegion,
|
||||
commonMessages,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
TeleportOverflowMenu,
|
||||
useFormatBytes,
|
||||
useFormatDateTime,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { capitalizeString, type ThreadMessage, type User } from '@modrinth/utils'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { UnsafeFile } from '~/components/ui/moderation/MaliciousSummaryModal.vue'
|
||||
import ThreadView from '~/components/ui/thread/ThreadView.vue'
|
||||
|
||||
import { severityOrder } from './helpers'
|
||||
import type { FlattenedFileReport } from './types'
|
||||
import { injectTechReviewDecisions } from './use-tech-review-decisions'
|
||||
|
||||
const props = defineProps<{
|
||||
project: Labrinth.Projects.v3.Project
|
||||
projectOwner: Labrinth.TechReview.Internal.Ownership
|
||||
thread: Labrinth.TechReview.Internal.Thread
|
||||
reports: FlattenedFileReport[]
|
||||
disableCollapsing?: boolean
|
||||
}>()
|
||||
|
||||
const isThreadCollapsed = defineModel<boolean>('collapsed', { required: true })
|
||||
|
||||
const emit = defineEmits<{
|
||||
refetch: []
|
||||
markComplete: [projectId: string]
|
||||
showMaliciousSummary: [unsafeFiles: UnsafeFile[]]
|
||||
statusChanged: [status: Labrinth.Projects.v2.ProjectStatus]
|
||||
}>()
|
||||
|
||||
const auth = useAuthState()
|
||||
const featureFlags = useFeatureFlags()
|
||||
const { formatMessage } = useVIntl()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const client = injectModrinthClient()
|
||||
const { getDetailDecision } = injectTechReviewDecisions()
|
||||
const formatBytes = useFormatBytes()
|
||||
|
||||
const formatDateTimeUtc = useFormatDateTime({
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
timeZoneName: 'short',
|
||||
timeZone: 'UTC',
|
||||
})
|
||||
|
||||
const remainingMessageCount = computed(() => {
|
||||
if (!props.thread?.messages) return 0
|
||||
return Math.max(0, props.thread.messages.length - 1)
|
||||
})
|
||||
|
||||
const threadExpandText = computed(() => {
|
||||
if (remainingMessageCount.value === 0) return 'Expand'
|
||||
if (remainingMessageCount.value === 1) return 'Show 1 more message'
|
||||
return `Show ${remainingMessageCount.value} more messages`
|
||||
})
|
||||
|
||||
const projectStatus = ref<Labrinth.Projects.v2.ProjectStatus>(props.project.status)
|
||||
const isLoadingStatusAction = ref(false)
|
||||
|
||||
function isStatusActionDisabled(status: Labrinth.Projects.v2.ProjectStatus): boolean {
|
||||
return projectStatus.value === status || isLoadingStatusAction.value
|
||||
}
|
||||
|
||||
async function setStatus(status: Labrinth.Projects.v2.ProjectStatus) {
|
||||
isLoadingStatusAction.value = true
|
||||
try {
|
||||
await client.labrinth.projects_v2.edit(props.project.id, { status })
|
||||
emit('refetch')
|
||||
projectStatus.value = status
|
||||
emit('statusChanged', status)
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.errorNotificationTitle),
|
||||
text: (err as any)?.data?.description ? (err as any).data.description : String(err),
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
isLoadingStatusAction.value = false
|
||||
}
|
||||
|
||||
const projectStatusActions = computed<ButtonMenuOption[]>(() => [
|
||||
{
|
||||
id: 'approve',
|
||||
label: 'Approve',
|
||||
icon: CheckIcon,
|
||||
tone: 'green',
|
||||
hoverFilled: true,
|
||||
action: () => setStatus('approved'),
|
||||
disabled: isStatusActionDisabled('approved'),
|
||||
},
|
||||
{
|
||||
id: 'withhold',
|
||||
label: 'Withhold',
|
||||
icon: EyeOffIcon,
|
||||
tone: 'orange',
|
||||
hoverFilled: true,
|
||||
action: () => setStatus('withheld'),
|
||||
disabled: isStatusActionDisabled('withheld'),
|
||||
},
|
||||
{
|
||||
id: 'send-to-review',
|
||||
label: 'Send to review',
|
||||
icon: ScaleIcon,
|
||||
action: () => setStatus('processing'),
|
||||
disabled: isStatusActionDisabled('processing'),
|
||||
},
|
||||
{
|
||||
id: 'reject',
|
||||
label: 'Reject',
|
||||
icon: XIcon,
|
||||
tone: 'red',
|
||||
hoverFilled: true,
|
||||
action: () => setStatus('rejected'),
|
||||
disabled: isStatusActionDisabled('rejected'),
|
||||
},
|
||||
])
|
||||
|
||||
const techReviewContext = computed<TechReviewContext>(() => ({
|
||||
project: props.project,
|
||||
project_owner: props.projectOwner,
|
||||
reports: props.reports,
|
||||
}))
|
||||
|
||||
const threadViewRef = ref<{
|
||||
setReplyContent: (content: string) => void
|
||||
getReplyContent: () => string
|
||||
} | null>(null)
|
||||
|
||||
const unsafeFiles = computed<UnsafeFile[]>(() => {
|
||||
return props.reports
|
||||
.filter((report) =>
|
||||
report.issues.some((issue) =>
|
||||
issue.details.some((detail) => getDetailDecision(detail.id, detail.status) === 'malware'),
|
||||
),
|
||||
)
|
||||
.map((report) => ({
|
||||
file: report,
|
||||
projectName: props.project.name,
|
||||
projectId: props.project.id,
|
||||
userId: props.projectOwner.id,
|
||||
username: props.projectOwner.name,
|
||||
}))
|
||||
})
|
||||
|
||||
const reviewSummaryPreview = computed(() => {
|
||||
const fileDecisions = new Map<
|
||||
string,
|
||||
{
|
||||
fileName: string
|
||||
fileSize: number
|
||||
decisions: {
|
||||
filePath: string
|
||||
issueType: string
|
||||
severity: string
|
||||
decision: 'safe' | 'malware'
|
||||
}[]
|
||||
maxSeverity: Labrinth.TechReview.Internal.DelphiSeverity
|
||||
}
|
||||
>()
|
||||
let totalSafe = 0
|
||||
let totalUnsafe = 0
|
||||
|
||||
for (const report of props.reports) {
|
||||
if (!fileDecisions.has(report.id)) {
|
||||
fileDecisions.set(report.id, {
|
||||
fileName: report.file_name,
|
||||
fileSize: report.file_size,
|
||||
decisions: [],
|
||||
maxSeverity: 'low',
|
||||
})
|
||||
}
|
||||
const fileData = fileDecisions.get(report.id)!
|
||||
|
||||
for (const issue of report.issues) {
|
||||
for (const detail of issue.details) {
|
||||
const decision = getDetailDecision(detail.id, detail.status)
|
||||
if (decision === 'pending') continue
|
||||
|
||||
fileData.decisions.push({
|
||||
filePath: detail.file_path,
|
||||
issueType: issue.issue_type.replace(/_/g, ' '),
|
||||
severity: detail.severity,
|
||||
decision,
|
||||
})
|
||||
|
||||
if (severityOrder[detail.severity] > severityOrder[fileData.maxSeverity]) {
|
||||
fileData.maxSeverity = detail.severity
|
||||
}
|
||||
|
||||
if (decision === 'safe') totalSafe++
|
||||
else totalUnsafe++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const totalDecisions = totalSafe + totalUnsafe
|
||||
if (totalDecisions === 0) return ''
|
||||
|
||||
const timestamp = formatDateTimeUtc(dayjs().toDate())
|
||||
let markdown = `## Tech Review Summary\n*${timestamp}*\n\n`
|
||||
markdown += `<details>\n<summary>File Details (${totalSafe} safe, ${totalUnsafe} unsafe)</summary>\n\n`
|
||||
|
||||
for (const [, fileData] of fileDecisions) {
|
||||
if (fileData.decisions.length === 0) continue
|
||||
|
||||
const fileSafe = fileData.decisions.filter((d) => d.decision === 'safe').length
|
||||
const fileUnsafe = fileData.decisions.filter((d) => d.decision === 'malware').length
|
||||
const fileVerdict = fileUnsafe > 0 ? 'Unsafe' : 'Safe'
|
||||
|
||||
markdown += `### ${fileData.fileName}\n`
|
||||
markdown += `> ${formatBytes(fileData.fileSize)} • ${fileData.decisions.length} issues • Max severity: ${fileData.maxSeverity} • **Verdict:** ${fileVerdict}\n\n`
|
||||
markdown += `<details>\n<summary>Issues (${fileSafe} safe, ${fileUnsafe} unsafe)</summary>\n\n`
|
||||
markdown += `| Class | Issue Type | Severity | Decision |\n`
|
||||
markdown += `|-------|------------|----------|----------|\n`
|
||||
|
||||
for (const d of fileData.decisions) {
|
||||
const decisionText = d.decision === 'safe' ? '✅ Safe' : '❌ Unsafe'
|
||||
markdown += `| \`${d.filePath}\` | ${d.issueType} | ${capitalizeString(d.severity)} | ${decisionText} |\n`
|
||||
}
|
||||
|
||||
markdown += `\n</details>\n\n`
|
||||
}
|
||||
|
||||
markdown += `</details>\n\n`
|
||||
markdown += `---\n\n**Total:** ${totalDecisions} issues reviewed (${totalSafe} safe, ${totalUnsafe} unsafe)\n\n`
|
||||
|
||||
return markdown
|
||||
})
|
||||
|
||||
const threadWithPreview = computed(() => {
|
||||
if (!reviewSummaryPreview.value) return props.thread
|
||||
|
||||
const user = auth.value?.user as User | null
|
||||
if (!user) return props.thread
|
||||
|
||||
const previewMessage: ThreadMessage & { preview: true } = {
|
||||
id: 'preview-message',
|
||||
author_id: user.id,
|
||||
body: {
|
||||
type: 'text',
|
||||
body: reviewSummaryPreview.value,
|
||||
private: true,
|
||||
replying_to: null,
|
||||
associated_images: [],
|
||||
},
|
||||
created: new Date().toISOString(),
|
||||
hide_identity: false,
|
||||
preview: true,
|
||||
}
|
||||
|
||||
return {
|
||||
...props.thread,
|
||||
messages: [...props.thread.messages, previewMessage],
|
||||
members: props.thread.members.some((m) => m.id === user.id)
|
||||
? props.thread.members
|
||||
: [...props.thread.members, user],
|
||||
}
|
||||
})
|
||||
|
||||
const allIssuesResolved = computed(() => {
|
||||
for (const report of props.reports) {
|
||||
for (const issue of report.issues) {
|
||||
for (const detail of issue.details) {
|
||||
if (getDetailDecision(detail.id, detail.status) === 'pending') return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const canSubmitReview = computed(() => {
|
||||
const totalIssues = props.reports.reduce((sum, r) => sum + r.issues.length, 0)
|
||||
if (totalIssues === 0) return true
|
||||
return allIssuesResolved.value
|
||||
})
|
||||
const hasSubmittedPassReview = ref(false)
|
||||
|
||||
async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
|
||||
hasSubmittedPassReview.value = verdict === 'safe'
|
||||
const editorContent = threadViewRef.value?.getReplyContent() || ''
|
||||
|
||||
let message: string | undefined
|
||||
if (reviewSummaryPreview.value && editorContent) {
|
||||
message = `${reviewSummaryPreview.value}${editorContent}`
|
||||
} else if (reviewSummaryPreview.value) {
|
||||
message = reviewSummaryPreview.value
|
||||
} else if (editorContent) {
|
||||
message = editorContent
|
||||
}
|
||||
|
||||
try {
|
||||
await client.labrinth.tech_review_internal.submitProject(props.project.id, {
|
||||
verdict,
|
||||
message,
|
||||
})
|
||||
emit('markComplete', props.project.id)
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Review submitted',
|
||||
text: 'Technical review completed successfully.',
|
||||
})
|
||||
|
||||
if (verdict === 'unsafe') {
|
||||
emit('showMaliciousSummary', unsafeFiles.value)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { data?: { issues?: string[] } } }
|
||||
if (err.response?.data?.issues) {
|
||||
const missedCount = err.response.data.issues.length
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Pending issues remain',
|
||||
text: `${missedCount} issue(s) still need a verdict before submitting.`,
|
||||
})
|
||||
} else {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Submit failed',
|
||||
text: 'Failed to submit review. Please try again.',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CollapsibleRegion
|
||||
v-model:collapsed="isThreadCollapsed"
|
||||
:expand-text="threadExpandText"
|
||||
:disabled="disableCollapsing"
|
||||
collapse-text="Collapse thread"
|
||||
>
|
||||
<div class="bg-surface-2 pt-0">
|
||||
<!-- DEV-531 -->
|
||||
<!-- @vue-expect-error TODO: will convert ThreadView to use api-client types at a later date -->
|
||||
<ThreadView
|
||||
ref="threadViewRef"
|
||||
:thread="threadWithPreview"
|
||||
:quick-replies="techReviewQuickReplies"
|
||||
:quick-reply-context="techReviewContext"
|
||||
primary-action="note"
|
||||
@update-thread="emit('refetch')"
|
||||
>
|
||||
<template #additionalActions>
|
||||
<Button
|
||||
v-tooltip="
|
||||
!canSubmitReview
|
||||
? 'There are still pending flags!'
|
||||
: hasSubmittedPassReview
|
||||
? 'Project already passed!'
|
||||
: undefined
|
||||
"
|
||||
type="colored"
|
||||
color="brand"
|
||||
:disabled="!canSubmitReview || hasSubmittedPassReview"
|
||||
@click="handleSubmitReview('safe')"
|
||||
>
|
||||
<ShieldCheckIcon /> Pass
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip="!canSubmitReview ? 'There are still pending flags!' : undefined"
|
||||
type="colored"
|
||||
color="red"
|
||||
:disabled="!canSubmitReview"
|
||||
@click="handleSubmitReview('unsafe')"
|
||||
>
|
||||
<BugIcon /> Fail
|
||||
</Button>
|
||||
<TeleportOverflowMenu
|
||||
label="More options"
|
||||
class="btn-dropdown-animation !w-auto !rounded-xl !px-2.5"
|
||||
:disabled="isLoadingStatusAction"
|
||||
:options="projectStatusActions"
|
||||
>
|
||||
<SpinnerIcon v-if="isLoadingStatusAction" class="animate-spin" aria-hidden="true" />
|
||||
<ScaleIcon v-else aria-hidden="true" />
|
||||
Set status
|
||||
<DropdownIcon aria-hidden="true" />
|
||||
</TeleportOverflowMenu>
|
||||
<Button
|
||||
v-if="featureFlags.developerMode"
|
||||
type="outlined"
|
||||
@click="emit('showMaliciousSummary', unsafeFiles)"
|
||||
>Debug</Button
|
||||
>
|
||||
</template>
|
||||
</ThreadView>
|
||||
</div>
|
||||
</CollapsibleRegion>
|
||||
</template>
|
||||
@@ -0,0 +1,155 @@
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { BanIcon, CheckCheckIcon, CheckIcon, ShieldAlertIcon } from '@modrinth/assets'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { canUpdateGlobalDetail } from './helpers'
|
||||
import { injectTechReviewDecisions } from './use-tech-review-decisions'
|
||||
|
||||
const REMAINING_LABELS = {
|
||||
globalSafe: 'All remaining globally safe',
|
||||
localSafe: 'All remaining safe',
|
||||
localUnsafe: 'All remaining malware',
|
||||
globalUnsafe: 'All remaining globally unsafe',
|
||||
} as const
|
||||
|
||||
const TRACE_ARIA_LABELS = {
|
||||
globalSafe: 'Global pass',
|
||||
localSafe: 'Local pass',
|
||||
localUnsafe: 'Local fail',
|
||||
globalUnsafe: 'Global fail',
|
||||
} as const
|
||||
|
||||
const props = defineProps<{
|
||||
variant: 'remaining' | 'trace'
|
||||
remainingCount?: number
|
||||
jar?: boolean
|
||||
detail?: Labrinth.TechReview.Internal.ReportIssueDetail
|
||||
globalDisabled?: boolean
|
||||
localDisabled?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
globalSafe: []
|
||||
localSafe: []
|
||||
localUnsafe: []
|
||||
globalUnsafe: []
|
||||
}>()
|
||||
|
||||
const {
|
||||
isDetailActionSelected,
|
||||
getDetailActionTooltip,
|
||||
updatingDetails,
|
||||
updatingGlobalDetailKeys,
|
||||
} = injectTechReviewDecisions()
|
||||
|
||||
const groupAriaLabel = computed(() => {
|
||||
if (props.variant === 'trace') return 'Trace verdict actions'
|
||||
return props.jar ? 'Remaining JAR issue actions' : 'Remaining issue actions'
|
||||
})
|
||||
|
||||
const remainingLabel = computed(() =>
|
||||
props.variant === 'remaining' && props.remainingCount != null
|
||||
? `${props.remainingCount} issue${props.remainingCount === 1 ? '' : 's'} remaining`
|
||||
: undefined,
|
||||
)
|
||||
|
||||
const ariaLabels = computed(() =>
|
||||
props.variant === 'remaining' ? REMAINING_LABELS : TRACE_ARIA_LABELS,
|
||||
)
|
||||
|
||||
function tooltip(decision: 'safe' | 'malware', scope: 'local' | 'global'): string {
|
||||
if (props.variant === 'remaining') {
|
||||
if (decision === 'safe' && scope === 'global') return REMAINING_LABELS.globalSafe
|
||||
if (decision === 'safe') return REMAINING_LABELS.localSafe
|
||||
if (scope === 'local') return REMAINING_LABELS.localUnsafe
|
||||
return REMAINING_LABELS.globalUnsafe
|
||||
}
|
||||
|
||||
return getDetailActionTooltip(props.detail!, decision, scope)
|
||||
}
|
||||
|
||||
function selected(decision: 'safe' | 'malware', scope: 'local' | 'global'): boolean {
|
||||
if (props.variant !== 'trace' || !props.detail) return false
|
||||
return isDetailActionSelected(props.detail, decision, scope)
|
||||
}
|
||||
|
||||
const isGlobalDisabled = computed(() => {
|
||||
if (props.variant === 'remaining') return props.globalDisabled
|
||||
if (!props.detail) return true
|
||||
return (
|
||||
!canUpdateGlobalDetail(props.detail) ||
|
||||
updatingGlobalDetailKeys.has(props.detail.key) ||
|
||||
updatingDetails.has(props.detail.id)
|
||||
)
|
||||
})
|
||||
|
||||
const isLocalDisabled = computed(() => {
|
||||
if (props.variant === 'remaining') return props.localDisabled
|
||||
if (!props.detail) return true
|
||||
return updatingDetails.has(props.detail.id) || updatingGlobalDetailKeys.has(props.detail.key)
|
||||
})
|
||||
|
||||
const BUTTON_BASE_CLASS =
|
||||
'custom-focus-indicator flex size-8 cursor-pointer items-center justify-center border-0 border-l border-solid border-l-surface-5 bg-transparent p-0 transition-[background-color,filter] duration-150 ease-in-out first:rounded-s-[calc(var(--radius-md)-1px)] first:border-l-0 last:rounded-e-[calc(var(--radius-md)-1px)] disabled:cursor-not-allowed disabled:opacity-50 [&>svg]:size-4'
|
||||
|
||||
function buttonClass(decision: 'safe' | 'malware', scope: 'local' | 'global') {
|
||||
return [
|
||||
BUTTON_BASE_CLASS,
|
||||
decision === 'safe' ? 'text-green' : 'text-red',
|
||||
selected(decision, scope)
|
||||
? 'bg-bg-green shadow-[inset_0_0_0_1px_var(--color-green)] hover:bg-bg-green focus-visible:bg-bg-green focus-visible:shadow-[inset_0_0_0_2px_var(--color-green)]'
|
||||
: 'hover:bg-surface-4 focus-visible:bg-surface-4 focus-visible:shadow-[inset_0_0_0_2px_var(--color-brand)]',
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center overflow-hidden rounded-xl border border-solid border-surface-5 bg-surface-3"
|
||||
role="group"
|
||||
:aria-label="groupAriaLabel"
|
||||
>
|
||||
<span
|
||||
v-if="remainingLabel"
|
||||
class="whitespace-nowrap px-3 text-sm font-semibold text-secondary"
|
||||
>{{ remainingLabel }}</span
|
||||
>
|
||||
<button
|
||||
v-tooltip="tooltip('safe', 'global')"
|
||||
:class="buttonClass('safe', 'global')"
|
||||
:aria-label="ariaLabels.globalSafe"
|
||||
:disabled="isGlobalDisabled"
|
||||
@click="emit('globalSafe')"
|
||||
>
|
||||
<CheckCheckIcon aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
v-tooltip="tooltip('safe', 'local')"
|
||||
:class="buttonClass('safe', 'local')"
|
||||
:aria-label="ariaLabels.localSafe"
|
||||
:disabled="isLocalDisabled"
|
||||
@click="emit('localSafe')"
|
||||
>
|
||||
<CheckIcon aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
v-tooltip="tooltip('malware', 'local')"
|
||||
:class="buttonClass('malware', 'local')"
|
||||
:aria-label="ariaLabels.localUnsafe"
|
||||
:disabled="isLocalDisabled"
|
||||
@click="emit('localUnsafe')"
|
||||
>
|
||||
<BanIcon aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
v-tooltip="tooltip('malware', 'global')"
|
||||
:class="buttonClass('malware', 'global')"
|
||||
:aria-label="ariaLabels.globalUnsafe"
|
||||
:disabled="isGlobalDisabled"
|
||||
@click="emit('globalUnsafe')"
|
||||
>
|
||||
<ShieldAlertIcon aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
|
||||
import type { DetailDecision, FlattenedFileReport } from './types'
|
||||
|
||||
export const severityOrder: Record<Labrinth.TechReview.Internal.DelphiSeverity, number> = {
|
||||
severe: 3,
|
||||
high: 2,
|
||||
medium: 1,
|
||||
low: 0,
|
||||
}
|
||||
|
||||
export function getSeverityBadgeColor(
|
||||
severity: Labrinth.TechReview.Internal.DelphiSeverity,
|
||||
): string {
|
||||
switch (severity) {
|
||||
case 'severe':
|
||||
return 'border-red/60 border bg-highlight-red text-red'
|
||||
case 'high':
|
||||
return 'border-orange/60 border bg-highlight-orange text-orange'
|
||||
case 'medium':
|
||||
return 'border-green/60 border bg-highlight-green text-green'
|
||||
case 'low':
|
||||
default:
|
||||
return 'border-blue/60 border bg-highlight-blue text-blue'
|
||||
}
|
||||
}
|
||||
|
||||
export function truncateMiddle(str: string, maxLength = 120): string {
|
||||
if (str.length <= maxLength) return str
|
||||
const keep = maxLength - 3
|
||||
const front = Math.ceil(keep / 3)
|
||||
return str.slice(0, front) + '...' + str.slice(front - keep)
|
||||
}
|
||||
|
||||
export function getFileHighestSeverity(
|
||||
file: FlattenedFileReport,
|
||||
): Labrinth.TechReview.Internal.DelphiSeverity {
|
||||
let highest: Labrinth.TechReview.Internal.DelphiSeverity = 'low'
|
||||
for (const issue of file.issues) {
|
||||
for (const detail of issue.details) {
|
||||
if (severityOrder[detail.severity] > severityOrder[highest]) {
|
||||
highest = detail.severity
|
||||
}
|
||||
}
|
||||
}
|
||||
return highest
|
||||
}
|
||||
|
||||
export function getFileDetailCount(file: FlattenedFileReport): number {
|
||||
return file.issues.reduce((sum, issue) => sum + issue.details.length, 0)
|
||||
}
|
||||
|
||||
export function flattenFileReports(
|
||||
versions: Labrinth.TechReview.Internal.VersionReport[],
|
||||
): FlattenedFileReport[] {
|
||||
return versions.flatMap((version) =>
|
||||
version.files.map((file) => ({
|
||||
...file,
|
||||
id: file.report_id,
|
||||
version_id: version.version_id,
|
||||
version_number: version.version_number,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
export function getVersionLabel(file: FlattenedFileReport): string {
|
||||
return file.version_number || file.version_id
|
||||
}
|
||||
|
||||
export function getVersionPageHref(
|
||||
project: { id: string; slug?: string; project_types: string[] },
|
||||
versionId: string,
|
||||
): string {
|
||||
return `/${project.project_types[0] ?? 'project'}/${project.slug ?? project.id}/version/${versionId}`
|
||||
}
|
||||
|
||||
export function verdictToDecision(
|
||||
verdict: Labrinth.TechReview.Internal.DelphiReportIssueStatus,
|
||||
): DetailDecision {
|
||||
if (verdict === 'safe') return 'safe'
|
||||
if (verdict === 'unsafe') return 'malware'
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
export function decisionToVerdict(
|
||||
decision: Exclude<DetailDecision, 'pending'>,
|
||||
): Labrinth.TechReview.Internal.DelphiReportIssueStatus {
|
||||
return decision === 'safe' ? 'safe' : 'unsafe'
|
||||
}
|
||||
|
||||
export function statusMatchesDecision(
|
||||
status: Labrinth.TechReview.Internal.DelphiReportIssueStatus | null,
|
||||
decision: DetailDecision,
|
||||
): boolean {
|
||||
if (status === 'safe') return decision === 'safe'
|
||||
if (status === 'unsafe') return decision === 'malware'
|
||||
return false
|
||||
}
|
||||
|
||||
export function canUpdateGlobalDetail(
|
||||
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
|
||||
): boolean {
|
||||
return detail.key.length > 0 && !detail.key.startsWith('<no-key-')
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
|
||||
export type FlattenedFileReport = Labrinth.TechReview.Internal.FileReport & {
|
||||
id: string
|
||||
version_id: string
|
||||
version_number?: string
|
||||
}
|
||||
|
||||
export type DetailDecision = 'safe' | 'malware' | 'pending'
|
||||
export type DetailDecisionScope = 'local' | 'global'
|
||||
|
||||
export type FlagItem = {
|
||||
issueId: string
|
||||
issueType: string
|
||||
detail: Labrinth.TechReview.Internal.ReportIssueDetail
|
||||
}
|
||||
|
||||
export type ClassGroup = {
|
||||
key: string
|
||||
jar: string | null
|
||||
filePath: string
|
||||
flags: FlagItem[]
|
||||
}
|
||||
|
||||
export type JarGroup = {
|
||||
key: string
|
||||
jar: string | null
|
||||
segments: string[]
|
||||
classes: ClassGroup[]
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { injectModrinthClient } from '@modrinth/ui'
|
||||
import { inject, type InjectionKey, type MaybeRefOrGetter, reactive, toValue } from 'vue'
|
||||
|
||||
import { canUpdateGlobalDetail, decisionToVerdict, statusMatchesDecision } from './helpers'
|
||||
import type { DetailDecision, DetailDecisionScope, FlagItem, FlattenedFileReport } from './types'
|
||||
|
||||
export function useTechReviewDecisions(reports: MaybeRefOrGetter<FlattenedFileReport[]>) {
|
||||
const client = injectModrinthClient()
|
||||
|
||||
const detailDecisions = reactive<Map<string, DetailDecision>>(new Map())
|
||||
const detailDecisionScopes = reactive<Map<string, DetailDecisionScope>>(new Map())
|
||||
const updatingDetails = reactive<Set<string>>(new Set())
|
||||
const updatingGlobalDetailKeys = reactive<Set<string>>(new Set())
|
||||
|
||||
function getDetailDecision(
|
||||
detailId: string,
|
||||
backendStatus: Labrinth.TechReview.Internal.DelphiReportIssueStatus,
|
||||
): DetailDecision {
|
||||
const localDecision = detailDecisions.get(detailId)
|
||||
if (localDecision) return localDecision
|
||||
if (backendStatus === 'safe') return 'safe'
|
||||
if (backendStatus === 'unsafe') return 'malware'
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
function isPreReviewed(
|
||||
detailId: string,
|
||||
backendStatus: Labrinth.TechReview.Internal.DelphiReportIssueStatus,
|
||||
): boolean {
|
||||
return (
|
||||
(backendStatus === 'safe' || backendStatus === 'unsafe') && !detailDecisions.has(detailId)
|
||||
)
|
||||
}
|
||||
|
||||
function getFileMarkedCount(file: FlattenedFileReport): number {
|
||||
let count = 0
|
||||
for (const issue of file.issues) {
|
||||
for (const detail of issue.details) {
|
||||
if (getDetailDecision(detail.id, detail.status) !== 'pending') count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
function getMarkedFlagsCount(flags: FlagItem[]): number {
|
||||
return flags.filter((f) => getDetailDecision(f.detail.id, f.detail.status) !== 'pending').length
|
||||
}
|
||||
|
||||
function isDetailGloballyPassed(detail: Labrinth.TechReview.Internal.ReportIssueDetail): boolean {
|
||||
if (detailDecisionScopes.get(detail.id) === 'global') {
|
||||
return detailDecisions.get(detail.id) === 'safe'
|
||||
}
|
||||
|
||||
return detail.global_status === 'safe'
|
||||
}
|
||||
|
||||
function isDetailGloballyResolved(
|
||||
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
|
||||
): boolean {
|
||||
if (detailDecisionScopes.get(detail.id) === 'global') {
|
||||
return detailDecisions.get(detail.id) !== 'pending'
|
||||
}
|
||||
|
||||
return detail.global_status === 'safe' || detail.global_status === 'unsafe'
|
||||
}
|
||||
|
||||
function applyDecisionToRelatedDetails(
|
||||
detailIds: string[],
|
||||
decision: DetailDecision,
|
||||
scope: DetailDecisionScope,
|
||||
): { otherMatchedCount: number } {
|
||||
const allDetails = toValue(reports).flatMap((report) =>
|
||||
report.issues.flatMap((issue) => issue.details),
|
||||
)
|
||||
const selectedDetailIds = new Set(detailIds)
|
||||
const updatedDetailIds = new Set<string>()
|
||||
|
||||
for (const detailId of detailIds) {
|
||||
const detail = allDetails.find((candidate) => candidate.id === detailId)
|
||||
const matchingDetails = detail?.key
|
||||
? allDetails.filter((candidate) => candidate.key === detail.key)
|
||||
: detail
|
||||
? [detail]
|
||||
: []
|
||||
|
||||
if (matchingDetails.length === 0) {
|
||||
detailDecisions.set(detailId, decision)
|
||||
detailDecisionScopes.set(detailId, scope)
|
||||
updatedDetailIds.add(detailId)
|
||||
continue
|
||||
}
|
||||
|
||||
for (const matchingDetail of matchingDetails) {
|
||||
detailDecisions.set(matchingDetail.id, decision)
|
||||
detailDecisionScopes.set(matchingDetail.id, scope)
|
||||
updatedDetailIds.add(matchingDetail.id)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
otherMatchedCount: [...updatedDetailIds].filter((id) => !selectedDetailIds.has(id)).length,
|
||||
}
|
||||
}
|
||||
|
||||
function isDetailActionSelected(
|
||||
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
|
||||
decision: DetailDecision,
|
||||
scope: DetailDecisionScope,
|
||||
): boolean {
|
||||
const localDecision = detailDecisions.get(detail.id)
|
||||
const localScope = detailDecisionScopes.get(detail.id)
|
||||
if (localDecision && localScope) {
|
||||
if (localDecision === 'pending') {
|
||||
if (localScope === 'local') {
|
||||
if (scope === 'local') return false
|
||||
return statusMatchesDecision(detail.global_status, decision)
|
||||
}
|
||||
|
||||
if (scope === 'global') return false
|
||||
return statusMatchesDecision(detail.local_status, decision)
|
||||
}
|
||||
|
||||
return localDecision === decision && localScope === scope
|
||||
}
|
||||
|
||||
if (scope === 'global') {
|
||||
return statusMatchesDecision(detail.global_status, decision)
|
||||
}
|
||||
|
||||
if (detail.global_status) return false
|
||||
|
||||
return statusMatchesDecision(detail.local_status, decision)
|
||||
}
|
||||
|
||||
function getToggledDetailVerdict(
|
||||
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
|
||||
decision: Exclude<DetailDecision, 'pending'>,
|
||||
scope: DetailDecisionScope,
|
||||
): Labrinth.TechReview.Internal.DelphiReportIssueStatus {
|
||||
return isDetailActionSelected(detail, decision, scope) ? 'pending' : decisionToVerdict(decision)
|
||||
}
|
||||
|
||||
function getDetailActionTooltip(
|
||||
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
|
||||
decision: Exclude<DetailDecision, 'pending'>,
|
||||
scope: DetailDecisionScope,
|
||||
): string {
|
||||
const action = decision === 'safe' ? 'pass' : 'fail'
|
||||
const scopeLabel = scope === 'global' ? 'Global' : 'Local'
|
||||
|
||||
if (scope === 'global' && !canUpdateGlobalDetail(detail)) {
|
||||
return 'Global verdict unavailable for generated trace keys'
|
||||
}
|
||||
|
||||
if (isDetailActionSelected(detail, decision, scope)) {
|
||||
return `Unset ${scopeLabel.toLowerCase()} ${action}`
|
||||
}
|
||||
|
||||
return `${scopeLabel} ${action}`
|
||||
}
|
||||
|
||||
async function updateIssueDetails(
|
||||
data: {
|
||||
detail_id: string
|
||||
verdict: Labrinth.TechReview.Internal.DelphiReportIssueStatus
|
||||
}[],
|
||||
) {
|
||||
await client.request('/moderation/tech-review/issue-detail', {
|
||||
api: 'labrinth',
|
||||
version: 'internal',
|
||||
method: 'PATCH',
|
||||
body: data,
|
||||
})
|
||||
}
|
||||
|
||||
async function updateGlobalIssueDetails(
|
||||
data: {
|
||||
detail_key: string
|
||||
verdict: Labrinth.TechReview.Internal.DelphiReportIssueStatus
|
||||
}[],
|
||||
) {
|
||||
await client.labrinth.tech_review_internal.updateGlobalIssueDetails(data)
|
||||
}
|
||||
|
||||
return {
|
||||
updatingDetails,
|
||||
updatingGlobalDetailKeys,
|
||||
getDetailDecision,
|
||||
isPreReviewed,
|
||||
getFileMarkedCount,
|
||||
getMarkedFlagsCount,
|
||||
isDetailGloballyPassed,
|
||||
isDetailGloballyResolved,
|
||||
applyDecisionToRelatedDetails,
|
||||
isDetailActionSelected,
|
||||
getToggledDetailVerdict,
|
||||
getDetailActionTooltip,
|
||||
updateIssueDetails,
|
||||
updateGlobalIssueDetails,
|
||||
}
|
||||
}
|
||||
|
||||
export type TechReviewDecisions = ReturnType<typeof useTechReviewDecisions>
|
||||
|
||||
export const TECH_REVIEW_DECISIONS_KEY: InjectionKey<TechReviewDecisions> =
|
||||
Symbol('techReviewDecisions')
|
||||
|
||||
export function injectTechReviewDecisions(): TechReviewDecisions {
|
||||
const decisions = inject(TECH_REVIEW_DECISIONS_KEY)
|
||||
if (!decisions) {
|
||||
throw new Error('Tech review decisions must be provided by ModerationTechRevCard')
|
||||
}
|
||||
return decisions
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { injectModrinthClient } from '@modrinth/ui'
|
||||
import { type MaybeRefOrGetter, reactive, toValue } from 'vue'
|
||||
|
||||
const CACHE_TTL = 24 * 60 * 60 * 1000
|
||||
const CACHE_KEY_PREFIX = 'tech_review_source_'
|
||||
|
||||
type CachedSource = {
|
||||
source: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
function getCachedSource(detailId: string): string | null {
|
||||
try {
|
||||
const cached = localStorage.getItem(`${CACHE_KEY_PREFIX}${detailId}`)
|
||||
if (!cached) return null
|
||||
|
||||
const data: CachedSource = JSON.parse(cached)
|
||||
const now = Date.now()
|
||||
|
||||
if (now - data.timestamp > CACHE_TTL) {
|
||||
localStorage.removeItem(`${CACHE_KEY_PREFIX}${detailId}`)
|
||||
return null
|
||||
}
|
||||
|
||||
return data.source
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function setCachedSource(detailId: string, source: string): void {
|
||||
try {
|
||||
const data: CachedSource = {
|
||||
source,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
localStorage.setItem(`${CACHE_KEY_PREFIX}${detailId}`, JSON.stringify(data))
|
||||
} catch (error) {
|
||||
console.error('Failed to cache source:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function clearExpiredCache(): void {
|
||||
try {
|
||||
const now = Date.now()
|
||||
const keys = Object.keys(localStorage)
|
||||
|
||||
for (const key of keys) {
|
||||
if (key.startsWith(CACHE_KEY_PREFIX)) {
|
||||
const cached = localStorage.getItem(key)
|
||||
if (cached) {
|
||||
const data: CachedSource = JSON.parse(cached)
|
||||
if (now - data.timestamp > CACHE_TTL) {
|
||||
localStorage.removeItem(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to clear expired cache:', error)
|
||||
}
|
||||
}
|
||||
|
||||
export function useTechReviewSources(
|
||||
issues: MaybeRefOrGetter<Labrinth.TechReview.Internal.FileIssue[]>,
|
||||
) {
|
||||
const client = injectModrinthClient()
|
||||
|
||||
if (import.meta.client) {
|
||||
clearExpiredCache()
|
||||
}
|
||||
|
||||
const loadingIssues = reactive<Set<string>>(new Set())
|
||||
const decompiledSources = reactive<Map<string, string>>(new Map())
|
||||
const loadedIssues = reactive<Set<string>>(new Set())
|
||||
|
||||
async function loadIssueSource(issueId: string): Promise<void> {
|
||||
if (loadingIssues.has(issueId) || loadedIssues.has(issueId)) return
|
||||
|
||||
loadingIssues.add(issueId)
|
||||
|
||||
try {
|
||||
const issueData = await client.labrinth.tech_review_internal.getIssue(issueId)
|
||||
|
||||
for (const detail of issueData.details) {
|
||||
if (detail.decompiled_source) {
|
||||
decompiledSources.set(detail.id, detail.decompiled_source)
|
||||
setCachedSource(detail.id, detail.decompiled_source)
|
||||
}
|
||||
}
|
||||
loadedIssues.add(issueId)
|
||||
} catch (error) {
|
||||
console.error('Failed to load issue source:', error)
|
||||
} finally {
|
||||
loadingIssues.delete(issueId)
|
||||
}
|
||||
}
|
||||
|
||||
function handleLoadIssueSources(issueIds: string[]): void {
|
||||
const uniqueIssueIds = new Set(issueIds)
|
||||
const matchedIssues = toValue(issues).filter((issue) => uniqueIssueIds.has(issue.id))
|
||||
|
||||
for (const issue of matchedIssues) {
|
||||
for (const detail of issue.details) {
|
||||
if (!decompiledSources.has(detail.id)) {
|
||||
const cached = getCachedSource(detail.id)
|
||||
if (cached) {
|
||||
decompiledSources.set(detail.id, cached)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hasUncached = issue.details.some((detail) => !decompiledSources.has(detail.id))
|
||||
if (hasUncached) {
|
||||
loadIssueSource(issue.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loadingIssues,
|
||||
decompiledSources,
|
||||
handleLoadIssueSources,
|
||||
}
|
||||
}
|
||||
@@ -95,7 +95,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
<div v-if="flags.developerMode" class="mx-4 mb-3 font-semibold">
|
||||
<div v-if="flags.showThreadIds" class="mx-4 mb-3 font-semibold">
|
||||
Thread ID:
|
||||
<CopyCode :text="thread.id" />
|
||||
</div>
|
||||
@@ -113,18 +113,19 @@
|
||||
@update-thread="() => updateThreadLocal()"
|
||||
/>
|
||||
</div>
|
||||
<template v-if="report && report.closed">
|
||||
<p>{{ formatMessage(messages.closedThreadDescription) }}</p>
|
||||
<div v-if="report && report.closed" class="m-4 mt-2 flex flex-col gap-4">
|
||||
<p class="m-0">{{ formatMessage(messages.closedThreadDescription) }}</p>
|
||||
<Button
|
||||
v-if="isStaff(auth.user)"
|
||||
:disabled="isLoading"
|
||||
class="w-fit"
|
||||
@click="runBlockingAction('reopen', () => reopenReport())"
|
||||
>
|
||||
<SpinnerIcon v-if="loadingAction === 'reopen'" class="animate-spin" aria-hidden="true" />
|
||||
<CheckCircleIcon v-else aria-hidden="true" />
|
||||
{{ formatMessage(messages.actionReopenThread) }}
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
<template v-else-if="!report || !report.closed">
|
||||
<div class="mx-4 mb-2 mt-2">
|
||||
<MarkdownEditor
|
||||
@@ -211,36 +212,34 @@
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<template v-if="report">
|
||||
<template v-if="isStaff(auth.user)">
|
||||
<Button
|
||||
v-if="replyBody"
|
||||
type="colored"
|
||||
color="red"
|
||||
:disabled="isLoading"
|
||||
@click="runBlockingAction('close-with-reply', () => closeReport(true))"
|
||||
>
|
||||
<SpinnerIcon
|
||||
v-if="loadingAction === 'close-with-reply'"
|
||||
class="animate-spin"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<CheckCircleIcon v-else aria-hidden="true" />
|
||||
{{ formatMessage(messages.actionCloseWithReply) }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
:disabled="isLoading"
|
||||
@click="runBlockingAction('close', () => closeReport())"
|
||||
>
|
||||
<SpinnerIcon
|
||||
v-if="loadingAction === 'close'"
|
||||
class="animate-spin"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<CheckCircleIcon v-else aria-hidden="true" />
|
||||
{{ formatMessage(messages.actionCloseThread) }}
|
||||
</Button>
|
||||
</template>
|
||||
<Button
|
||||
v-if="isStaff(auth.user) && replyBody"
|
||||
type="colored"
|
||||
color="red"
|
||||
:disabled="isLoading"
|
||||
@click="runBlockingAction('close-with-reply', () => closeReport(true))"
|
||||
>
|
||||
<SpinnerIcon
|
||||
v-if="loadingAction === 'close-with-reply'"
|
||||
class="animate-spin"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<CheckCircleIcon v-else aria-hidden="true" />
|
||||
{{ formatMessage(messages.actionCloseWithReply) }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
:disabled="isLoading"
|
||||
@click="runBlockingAction('close', () => closeReport())"
|
||||
>
|
||||
<SpinnerIcon
|
||||
v-if="loadingAction === 'close'"
|
||||
class="animate-spin"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<CheckCircleIcon v-else aria-hidden="true" />
|
||||
{{ formatMessage(messages.actionCloseThread) }}
|
||||
</Button>
|
||||
</template>
|
||||
<template v-if="project">
|
||||
<template v-if="isStaff(auth.user)">
|
||||
@@ -526,8 +525,8 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Close with reply',
|
||||
},
|
||||
actionCloseThread: {
|
||||
id: 'conversation-thread.action.close-thread',
|
||||
defaultMessage: 'Close thread',
|
||||
id: 'conversation-thread.action.close-report',
|
||||
defaultMessage: 'Close report',
|
||||
},
|
||||
actionApproveWithReply: {
|
||||
id: 'conversation-thread.action.approve-with-reply',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="flags.developerMode" class="m-4 font-bold text-heading">
|
||||
<div v-if="flags.showThreadIds" class="m-4 font-bold text-heading">
|
||||
Thread ID:
|
||||
<CopyCode :text="thread.id" />
|
||||
</div>
|
||||
@@ -23,10 +23,12 @@
|
||||
<p class="text-lg text-secondary">No messages yet</p>
|
||||
</div>
|
||||
|
||||
<template v-if="closed">
|
||||
<p class="text-secondary">This thread is closed and new messages cannot be sent to it.</p>
|
||||
<slot name="closedActions" />
|
||||
</template>
|
||||
<div v-if="closed" class="flex flex-col gap-4 p-4 pt-2">
|
||||
<p class="m-0 text-secondary">This thread is closed and new messages cannot be sent to it.</p>
|
||||
<div>
|
||||
<slot name="closedActions" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="px-4 py-2">
|
||||
@@ -41,43 +43,58 @@
|
||||
class="mt-4 flex flex-col items-stretch justify-between gap-3 px-4 pb-4 sm:flex-row sm:items-center sm:gap-2"
|
||||
>
|
||||
<div class="flex flex-col items-stretch gap-2 sm:flex-row sm:items-center">
|
||||
<Button
|
||||
v-if="sortedMessages.length > 0"
|
||||
<SplitButton
|
||||
v-if="primaryAction === 'note' && isStaff(auth.user)"
|
||||
type="colored"
|
||||
color="brand"
|
||||
menu-label="More send options"
|
||||
:disabled="!replyBody"
|
||||
class="w-full gap-2 sm:w-auto"
|
||||
@click="sendReply()"
|
||||
>
|
||||
<ReplyIcon class="size-4" />
|
||||
Reply
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
type="colored"
|
||||
color="brand"
|
||||
:disabled="!replyBody"
|
||||
class="w-full gap-2 sm:w-auto"
|
||||
@click="sendReply()"
|
||||
>
|
||||
<SendIcon class="size-4" />
|
||||
Send
|
||||
</Button>
|
||||
<Button
|
||||
v-if="isStaff(auth.user)"
|
||||
:disabled="!replyBody"
|
||||
:options="publicMessageOptions"
|
||||
class="w-full sm:w-auto"
|
||||
@click="sendReply(true)"
|
||||
>
|
||||
Add note
|
||||
</Button>
|
||||
</SplitButton>
|
||||
<template v-else>
|
||||
<Button
|
||||
v-if="sortedMessages.length > 0"
|
||||
type="colored"
|
||||
color="brand"
|
||||
:disabled="!replyBody"
|
||||
class="w-full gap-2 sm:w-auto"
|
||||
@click="sendReply()"
|
||||
>
|
||||
<ReplyIcon class="size-4" />
|
||||
Reply
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
type="colored"
|
||||
color="brand"
|
||||
:disabled="!replyBody"
|
||||
class="w-full gap-2 sm:w-auto"
|
||||
@click="sendReply()"
|
||||
>
|
||||
<SendIcon class="size-4" />
|
||||
Send
|
||||
</Button>
|
||||
<Button
|
||||
v-if="isStaff(auth.user)"
|
||||
:disabled="!replyBody"
|
||||
class="w-full sm:w-auto"
|
||||
@click="sendReply(true)"
|
||||
>
|
||||
Add note
|
||||
</Button>
|
||||
</template>
|
||||
<TeleportOverflowMenu
|
||||
v-if="visibleQuickReplies.length > 0"
|
||||
label="More options"
|
||||
:options="visibleQuickReplies"
|
||||
class="!w-auto !rounded-xl !px-2.5"
|
||||
>
|
||||
Quick reply
|
||||
<ArrowUpFromLineIcon />
|
||||
Load preset
|
||||
<ChevronDownIcon />
|
||||
</TeleportOverflowMenu>
|
||||
</div>
|
||||
@@ -91,9 +108,15 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="T">
|
||||
import { ChevronDownIcon, MessageIcon, ReplyIcon, SendIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ArrowUpFromLineIcon,
|
||||
ChevronDownIcon,
|
||||
MessageIcon,
|
||||
ReplyIcon,
|
||||
SendIcon,
|
||||
} from '@modrinth/assets'
|
||||
import type { QuickReply } from '@modrinth/moderation'
|
||||
import { Button, TeleportOverflowMenu } from '@modrinth/ui'
|
||||
import { Button, SplitButton, TeleportOverflowMenu } from '@modrinth/ui'
|
||||
import {
|
||||
type ButtonMenuOption,
|
||||
CopyCode,
|
||||
@@ -110,6 +133,19 @@ import ThreadMessage from './ThreadMessage.vue'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
thread: Thread
|
||||
quickReplies?: ReadonlyArray<QuickReply<T>>
|
||||
quickReplyContext?: T
|
||||
closed?: boolean
|
||||
primaryAction?: 'reply' | 'note'
|
||||
}>(),
|
||||
{
|
||||
primaryAction: 'reply',
|
||||
},
|
||||
)
|
||||
|
||||
const visibleQuickReplies = computed<ButtonMenuOption[]>(() => {
|
||||
const replies = props.quickReplies
|
||||
const context = props.quickReplyContext
|
||||
@@ -131,13 +167,6 @@ const visibleQuickReplies = computed<ButtonMenuOption[]>(() => {
|
||||
)
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
thread: Thread
|
||||
quickReplies?: ReadonlyArray<QuickReply<T>>
|
||||
quickReplyContext?: T
|
||||
closed?: boolean
|
||||
}>()
|
||||
|
||||
async function handleQuickReply(reply: QuickReply<T>, context: T) {
|
||||
const message = typeof reply.message === 'function' ? await reply.message(context) : reply.message
|
||||
|
||||
@@ -151,7 +180,7 @@ defineExpose({
|
||||
sendReply,
|
||||
})
|
||||
|
||||
const auth = await useAuth()
|
||||
const auth = useAuthState()
|
||||
|
||||
const emit = defineEmits<{
|
||||
updateThread: [thread: Thread]
|
||||
@@ -169,6 +198,16 @@ const members = computed(() => {
|
||||
|
||||
const replyBody = ref('')
|
||||
|
||||
const publicMessageOptions = computed<ButtonMenuOption[]>(() => [
|
||||
{
|
||||
id: 'send-public',
|
||||
label: 'Send publicly',
|
||||
icon: SendIcon,
|
||||
action: () => sendReply(false),
|
||||
disabled: !replyBody.value,
|
||||
},
|
||||
])
|
||||
|
||||
function setReplyContent(content: string) {
|
||||
replyBody.value = content
|
||||
}
|
||||
|
||||
@@ -52,14 +52,17 @@ const getQueryString = (value: QueryValue) => {
|
||||
return value ?? null
|
||||
}
|
||||
|
||||
export const useAuthState = () =>
|
||||
useState<AuthState>('auth', () => ({
|
||||
user: null,
|
||||
token: '',
|
||||
}))
|
||||
|
||||
export const useAuth = async (
|
||||
oldToken: string | null | undefined = null,
|
||||
route?: AuthInitRoute,
|
||||
) => {
|
||||
const auth = useState<AuthState>('auth', () => ({
|
||||
user: null,
|
||||
token: '',
|
||||
}))
|
||||
const auth = useAuthState()
|
||||
|
||||
if (!auth.value.user || oldToken) {
|
||||
auth.value = await initAuth(oldToken, route)
|
||||
|
||||
@@ -19,6 +19,7 @@ const validateValues = <K extends PropertyKey>(flags: Record<K, FlagValue>) => f
|
||||
export const DEFAULT_FEATURE_FLAGS = validateValues({
|
||||
// Developer flags
|
||||
developerMode: false,
|
||||
showThreadIds: false,
|
||||
demoMode: false,
|
||||
showVersionFilesInTable: false,
|
||||
showVersionEnvironmentColumn: false,
|
||||
|
||||
@@ -1025,9 +1025,6 @@
|
||||
"conversation-thread.action.approve-with-reply": {
|
||||
"message": "الموافقة مع الرد"
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "إغلاق سلسلة المحادثة"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "الإغلاق مع الرد"
|
||||
},
|
||||
|
||||
@@ -917,9 +917,6 @@
|
||||
"conversation-thread.action.approve-with-reply": {
|
||||
"message": "Schválit s odpovědí"
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "Uzavřít vlákno"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "Uzavřít s odpovědí"
|
||||
},
|
||||
|
||||
@@ -677,9 +677,6 @@
|
||||
"conversation-thread.action.approve-with-reply": {
|
||||
"message": "Godkend med svar"
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "Luk tråd"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "Luk med svar"
|
||||
},
|
||||
|
||||
@@ -1025,9 +1025,6 @@
|
||||
"conversation-thread.action.approve-with-reply": {
|
||||
"message": "Genehmigen mit Antwort"
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "Thread schliessen"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "Schliessen mit Antwort"
|
||||
},
|
||||
|
||||
@@ -1025,9 +1025,6 @@
|
||||
"conversation-thread.action.approve-with-reply": {
|
||||
"message": "Mit Antwort annehmen"
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "Thread schließen"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "Mit Antwort schließen"
|
||||
},
|
||||
|
||||
@@ -1070,8 +1070,8 @@
|
||||
"conversation-thread.action.approve-with-reply": {
|
||||
"message": "Approve with reply"
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "Close thread"
|
||||
"conversation-thread.action.close-report": {
|
||||
"message": "Close report"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "Close with reply"
|
||||
|
||||
@@ -1025,9 +1025,6 @@
|
||||
"conversation-thread.action.approve-with-reply": {
|
||||
"message": "Aprovar con respuesta"
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "Cerrar hilo"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "Cerrar con respuesta"
|
||||
},
|
||||
|
||||
@@ -1025,9 +1025,6 @@
|
||||
"conversation-thread.action.approve-with-reply": {
|
||||
"message": "Aprobar con respuesta"
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "Cerrar hilo"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "Cerrar con respuesta"
|
||||
},
|
||||
|
||||
@@ -383,9 +383,6 @@
|
||||
"conversation-thread.action.approve-with-reply": {
|
||||
"message": "Hyväksy vastauksella"
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "Sulje keskustelu"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "Sulje vastauksella"
|
||||
},
|
||||
|
||||
@@ -1025,9 +1025,6 @@
|
||||
"conversation-thread.action.approve-with-reply": {
|
||||
"message": "Approuver avec réponse"
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "Fermer le fil"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "Fermer avec réponse"
|
||||
},
|
||||
|
||||
@@ -1007,9 +1007,6 @@
|
||||
"conversation-thread.action.approve-with-reply": {
|
||||
"message": "Jóváhagyás és válasz"
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "Gondolatmenet lezárása"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "Bezárás és válasz"
|
||||
},
|
||||
|
||||
@@ -1019,9 +1019,6 @@
|
||||
"conversation-thread.action.approve-with-reply": {
|
||||
"message": "Approva con risposta"
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "Chiudi thread"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "Chiudi con risposta"
|
||||
},
|
||||
|
||||
@@ -1022,9 +1022,6 @@
|
||||
"conversation-thread.action.approve-with-reply": {
|
||||
"message": "送信して承認"
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "スレッドを閉じる"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "返信して閉じる"
|
||||
},
|
||||
|
||||
@@ -1025,9 +1025,6 @@
|
||||
"conversation-thread.action.approve-with-reply": {
|
||||
"message": "답글과 함께 승인"
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "스레드 닫기"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "답글과 함께 닫기"
|
||||
},
|
||||
|
||||
@@ -794,9 +794,6 @@
|
||||
"conversation-thread.action.approve-with-reply": {
|
||||
"message": "Luluskan dengan balasan"
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "Tutup bebenang"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "Tutup dengan balasan"
|
||||
},
|
||||
|
||||
@@ -1022,9 +1022,6 @@
|
||||
"conversation-thread.action.approve-with-reply": {
|
||||
"message": "Accepteren met antwoord"
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "Sluit thread"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "Sluit met antwoord"
|
||||
},
|
||||
|
||||
@@ -794,9 +794,6 @@
|
||||
"conversation-thread.action.approve-with-reply": {
|
||||
"message": "Godkjenn med svar"
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "Lukk tråden"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "Lukk med svar"
|
||||
},
|
||||
|
||||
@@ -1022,9 +1022,6 @@
|
||||
"conversation-thread.action.approve-with-reply": {
|
||||
"message": "Zatwierdź z odpowiedzią "
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "Zamknij wątek"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "Zamknij z odpowiedzią"
|
||||
},
|
||||
|
||||
@@ -1025,9 +1025,6 @@
|
||||
"conversation-thread.action.approve-with-reply": {
|
||||
"message": "Aprovar com resposta"
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "Encerrar tópico"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "Encerrar com resposta"
|
||||
},
|
||||
|
||||
@@ -1025,9 +1025,6 @@
|
||||
"conversation-thread.action.approve-with-reply": {
|
||||
"message": "Aprovar com comentário"
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "Fechar tópico"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "Fechar com comentário"
|
||||
},
|
||||
|
||||
@@ -1019,9 +1019,6 @@
|
||||
"conversation-thread.action.approve-with-reply": {
|
||||
"message": "Одобрить с ответом"
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "Закрыть ветку"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "Закрыть с ответом"
|
||||
},
|
||||
|
||||
@@ -959,9 +959,6 @@
|
||||
"conversation-thread.action.approve-with-reply": {
|
||||
"message": "Godkänn med svar"
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "Stäng tråd"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "Stäng med svar"
|
||||
},
|
||||
|
||||
@@ -1025,9 +1025,6 @@
|
||||
"conversation-thread.action.approve-with-reply": {
|
||||
"message": "Onaylandı, işlem tamamlandı"
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "Konuyu kapat"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "Cevapla ve kapat"
|
||||
},
|
||||
|
||||
@@ -1022,9 +1022,6 @@
|
||||
"conversation-thread.action.approve-with-reply": {
|
||||
"message": "Підтвердити з відповіддю"
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "Закрити тему"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "Закрити з відповіддю"
|
||||
},
|
||||
|
||||
@@ -908,9 +908,6 @@
|
||||
"conversation-thread.action.approve-with-reply": {
|
||||
"message": "Chấp thuận với phản hồi"
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "Đóng luồng"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "Đóng với phản hồi"
|
||||
},
|
||||
|
||||
@@ -1025,9 +1025,6 @@
|
||||
"conversation-thread.action.approve-with-reply": {
|
||||
"message": "批准并回复"
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "关闭对话消息"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "关闭并回复"
|
||||
},
|
||||
|
||||
@@ -1025,9 +1025,6 @@
|
||||
"conversation-thread.action.approve-with-reply": {
|
||||
"message": "回覆並核准"
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "關閉討論串"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "回覆並關閉"
|
||||
},
|
||||
|
||||
@@ -89,12 +89,11 @@
|
||||
@download="emit('onDownload')"
|
||||
/>
|
||||
<div class="flex flex-col">
|
||||
<nuxt-link
|
||||
class="mb-4 flex w-fit items-center gap-2 rounded-lg px-2 py-0.5 pl-0 text-link"
|
||||
<BackToParentLink
|
||||
:to="`/${project.project_type}/${project.slug ? project.slug : project.id}/versions`"
|
||||
>
|
||||
<ChevronLeftIcon class="shrink-0" /> {{ formatMessage(messages.allVersions) }}
|
||||
</nuxt-link>
|
||||
{{ formatMessage(messages.allVersions) }}
|
||||
</BackToParentLink>
|
||||
<template v-if="version">
|
||||
<Admonition
|
||||
v-if="version.files_missing_attribution?.length"
|
||||
@@ -521,6 +520,7 @@ import {
|
||||
import { moderationSettings } from '@modrinth/moderation'
|
||||
import {
|
||||
Admonition,
|
||||
BackToParentLink,
|
||||
Button,
|
||||
ButtonLink,
|
||||
Collapsible,
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
</form>
|
||||
</NewModal>
|
||||
<div>
|
||||
<form class="flex gap-2" @submit.prevent="executeSearch">
|
||||
<form class="flex items-center gap-2" @submit.prevent="executeSearch">
|
||||
<Input
|
||||
v-model="query"
|
||||
:icon="SearchIcon"
|
||||
@@ -44,17 +44,18 @@
|
||||
autocomplete="off"
|
||||
placeholder="Search external projects..."
|
||||
clearable
|
||||
wrapper-class="flex-1 w-full"
|
||||
size="medium"
|
||||
wrapper-class="min-w-0 flex-1"
|
||||
/>
|
||||
<Button type="colored" color="brand" native-type="submit">
|
||||
<Button type="colored" color="brand" size="lg" native-type="submit">
|
||||
<SearchIcon aria-hidden="true" />
|
||||
Search by title
|
||||
</Button>
|
||||
<Button native-type="button" @click="executeFlameIdLookup">
|
||||
<Button size="lg" native-type="button" @click="executeFlameIdLookup">
|
||||
<BinaryIcon aria-hidden="true" />
|
||||
Lookup CurseForge ID
|
||||
</Button>
|
||||
<Button native-type="button" @click="executeSha1Lookup">
|
||||
<Button size="lg" native-type="button" @click="executeSha1Lookup">
|
||||
<HashIcon aria-hidden="true" />
|
||||
Lookup SHA-1
|
||||
</Button>
|
||||
|
||||
@@ -1,95 +1,88 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col justify-between gap-3 lg:flex-row">
|
||||
<Input
|
||||
v-model="query"
|
||||
:icon="SearchIcon"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
:placeholder="formatMessage(commonMessages.searchPlaceholder)"
|
||||
clearable
|
||||
wrapper-class="flex-1"
|
||||
input-class="h-[40px] w-full"
|
||||
@input="goToPage(1)"
|
||||
/>
|
||||
<ModerationQueueToolbar
|
||||
v-model="query"
|
||||
:page="currentPage"
|
||||
:total-pages="totalPages"
|
||||
@search="goToPage(1)"
|
||||
@switch-page="goToPage"
|
||||
>
|
||||
<template #actions>
|
||||
<Combobox
|
||||
v-model="currentFilterType"
|
||||
class="!w-full flex-grow sm:!w-[280px] sm:flex-grow-0 lg:!w-[280px]"
|
||||
trigger-type="base"
|
||||
trigger-size="lg"
|
||||
:options="filterTypes"
|
||||
:placeholder="formatMessage(commonMessages.filterByLabel)"
|
||||
@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" />
|
||||
<ModerationFilterCount
|
||||
:label="currentFilterType"
|
||||
:count="totalProjects"
|
||||
:loading="pending"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
</Combobox>
|
||||
|
||||
<div class="flex flex-col flex-wrap 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]"
|
||||
trigger-type="base"
|
||||
trigger-size="lg"
|
||||
:options="filterTypes"
|
||||
:placeholder="formatMessage(commonMessages.filterByLabel)"
|
||||
@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 }} ({{ totalProjects }})</span
|
||||
>
|
||||
</span>
|
||||
</template>
|
||||
</Combobox>
|
||||
<Combobox
|
||||
v-model="currentSortType"
|
||||
class="!w-full flex-grow sm:!w-[240px] sm:flex-grow-0"
|
||||
trigger-type="base"
|
||||
trigger-size="lg"
|
||||
:options="sortTypes"
|
||||
:placeholder="formatMessage(commonMessages.sortByLabel)"
|
||||
@select="goToPage(1)"
|
||||
>
|
||||
<template #selected>
|
||||
<span class="flex flex-row gap-2 align-middle font-semibold">
|
||||
<SortAscIcon
|
||||
v-if="currentSortType === 'Oldest' || currentSortType === 'Least external deps'"
|
||||
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>
|
||||
|
||||
<Combobox
|
||||
v-model="currentSortType"
|
||||
class="!w-full flex-grow sm:!w-[240px] sm:flex-grow-0"
|
||||
trigger-type="base"
|
||||
trigger-size="lg"
|
||||
:options="sortTypes"
|
||||
:placeholder="formatMessage(commonMessages.sortByLabel)"
|
||||
@select="goToPage(1)"
|
||||
>
|
||||
<template #selected>
|
||||
<span class="flex flex-row gap-2 align-middle font-semibold">
|
||||
<SortAscIcon
|
||||
v-if="currentSortType === 'Oldest' || currentSortType === 'Least external deps'"
|
||||
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>
|
||||
|
||||
<Combobox
|
||||
v-model="itemsPerPage"
|
||||
class="!w-full flex-grow sm:!w-[160px] sm:flex-grow-0 lg:!w-[140px]"
|
||||
trigger-type="base"
|
||||
trigger-size="lg"
|
||||
:options="itemsPerPageOptions"
|
||||
placeholder="Items per page"
|
||||
@select="goToPage(1)"
|
||||
>
|
||||
<template #selected>
|
||||
<span class="flex flex-row gap-2 align-middle font-semibold">
|
||||
<span class="truncate text-contrast">{{ itemsPerPage }} items</span>
|
||||
</span>
|
||||
</template>
|
||||
</Combobox>
|
||||
</div>
|
||||
<Combobox
|
||||
v-model="itemsPerPage"
|
||||
class="!w-full flex-grow sm:!w-[160px] sm:flex-grow-0 lg:!w-[140px]"
|
||||
trigger-type="base"
|
||||
trigger-size="lg"
|
||||
:options="itemsPerPageOptions"
|
||||
placeholder="Items per page"
|
||||
@select="goToPage(1)"
|
||||
>
|
||||
<template #selected>
|
||||
<span class="flex flex-row gap-2 align-middle font-semibold">
|
||||
<span class="truncate text-contrast">{{ itemsPerPage }} items</span>
|
||||
</span>
|
||||
</template>
|
||||
</Combobox>
|
||||
|
||||
<Button
|
||||
type="colored"
|
||||
color="orange"
|
||||
class="flex !h-[40px] w-full items-center justify-center gap-2 sm:w-auto"
|
||||
size="lg"
|
||||
class="w-full sm:w-auto"
|
||||
:disabled="pending || paginatedProjects?.length === 0"
|
||||
@click="moderateAllInFilter()"
|
||||
>
|
||||
<ScaleIcon class="flex-shrink-0" />
|
||||
<ScaleIcon />
|
||||
<span class="hidden sm:inline">{{ formatMessage(messages.moderate) }}</span>
|
||||
<span class="sm:hidden">Moderate</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
</template>
|
||||
<template #meta>
|
||||
<div v-if="totalProjects > 0">
|
||||
Showing {{ pageStart }}–{{ pageEnd }} of {{ totalProjects }}
|
||||
Showing {{ formatNumber(pageStart) }}–{{ formatNumber(pageEnd) }} of
|
||||
{{ formatNumber(totalProjects) }}
|
||||
{{
|
||||
currentFilterType === DEFAULT_FILTER_TYPE ? 'projects' : currentFilterType.toLowerCase()
|
||||
}}
|
||||
@@ -100,39 +93,27 @@
|
||||
{{ formatMessage(messages.excludeTechnicalReview) }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<Pagination
|
||||
v-if="totalPages > 1"
|
||||
:page="currentPage"
|
||||
:count="totalPages"
|
||||
@switch-page="goToPage"
|
||||
/>
|
||||
<ConfettiExplosion v-if="visible" />
|
||||
<QueueSummaryModal
|
||||
ref="queueSummaryModal"
|
||||
:completed-ids="moderationQueue.currentQueue.completed"
|
||||
:skipped-ids="moderationQueue.currentQueue.skipped"
|
||||
@review-skipped="reviewSkippedQueue"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<template v-if="pending">
|
||||
<div
|
||||
v-for="i in 3"
|
||||
:key="`loading-skeleton-${i}`"
|
||||
class="flex h-[98px] w-full animate-pulse rounded-2xl bg-surface-3"
|
||||
></div>
|
||||
</template>
|
||||
<EmptyState
|
||||
v-else-if="paginatedProjects.length === 0"
|
||||
:type="!!query ? 'no-search-result' : 'no-tasks'"
|
||||
:heading="emptyStateHeading"
|
||||
:description="emptyStateDescription"
|
||||
/>
|
||||
</ModerationQueueToolbar>
|
||||
|
||||
<ConfettiExplosion v-if="visible" />
|
||||
<QueueSummaryModal
|
||||
ref="queueSummaryModal"
|
||||
:completed-ids="moderationQueue.currentQueue.completed"
|
||||
:skipped-ids="moderationQueue.currentQueue.skipped"
|
||||
@review-skipped="reviewSkippedQueue"
|
||||
/>
|
||||
|
||||
<ModerationQueueSkeleton v-if="pending" />
|
||||
<EmptyState
|
||||
v-else-if="paginatedProjects.length === 0"
|
||||
:type="!!query ? 'no-search-result' : 'no-tasks'"
|
||||
:heading="emptyStateHeading"
|
||||
:description="emptyStateDescription"
|
||||
/>
|
||||
<div v-else class="flex flex-col gap-3">
|
||||
<ModerationQueueCard
|
||||
v-for="item in paginatedProjects"
|
||||
v-else
|
||||
:key="item.project.id"
|
||||
:queue-entry="item"
|
||||
:show-external-dependencies="currentFilterType === MODPACK_FILTER_TYPE"
|
||||
@@ -147,7 +128,7 @@
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { ListFilterIcon, ScaleIcon, SearchIcon, SortAscIcon, SortDescIcon } from '@modrinth/assets'
|
||||
import { ListFilterIcon, ScaleIcon, SortAscIcon, SortDescIcon } from '@modrinth/assets'
|
||||
import { Button } from '@modrinth/ui'
|
||||
import {
|
||||
Combobox,
|
||||
@@ -157,15 +138,18 @@ import {
|
||||
EmptyState,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
Input,
|
||||
Pagination,
|
||||
Toggle,
|
||||
useFormatNumber,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import ConfettiExplosion from 'vue-confetti-explosion'
|
||||
|
||||
import ModerationFilterCount from '~/components/ui/moderation/ModerationFilterCount.vue'
|
||||
import ModerationQueueCard from '~/components/ui/moderation/ModerationQueueCard.vue'
|
||||
import ModerationQueueSkeleton from '~/components/ui/moderation/ModerationQueueSkeleton.vue'
|
||||
import ModerationQueueToolbar from '~/components/ui/moderation/ModerationQueueToolbar.vue'
|
||||
import QueueSummaryModal from '~/components/ui/moderation/QueueSummaryModal.vue'
|
||||
import { type ModerationProject, toModerationProjects } from '~/helpers/moderation.ts'
|
||||
import { getProjectTypeForUrlShorthand } from '~/helpers/projects.js'
|
||||
@@ -175,6 +159,7 @@ import { findNextEligibleQueueProject } from '~/services/moderation/queue-eligib
|
||||
useHead({ title: 'Projects queue - Modrinth' })
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const formatNumber = useFormatNumber()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const moderationQueue = useModerationQueue()
|
||||
const route = useRoute()
|
||||
|
||||
@@ -28,6 +28,11 @@ const { data: report } = useQuery({
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<ModerationReportCard v-if="report" :report="report" :collapsed="false" />
|
||||
<ModerationReportCard
|
||||
v-if="report"
|
||||
:report="report"
|
||||
:collapsed="false"
|
||||
:disable-collapsing="true"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,21 +1,13 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col justify-between gap-3 lg:flex-row">
|
||||
<Input
|
||||
v-model="query"
|
||||
:icon="SearchIcon"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
:placeholder="formatMessage(commonMessages.searchPlaceholder)"
|
||||
clearable
|
||||
wrapper-class="flex-1"
|
||||
input-class="h-[40px] w-full"
|
||||
@input="goToPage(1)"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="flex flex-col items-stretch justify-end gap-2 sm:flex-row sm:items-center lg:flex-shrink-0"
|
||||
>
|
||||
<ModerationQueueToolbar
|
||||
v-model="query"
|
||||
:page="currentPage"
|
||||
:total-pages="totalPages"
|
||||
@search="goToPage(1)"
|
||||
@switch-page="goToPage"
|
||||
>
|
||||
<template #actions>
|
||||
<Combobox
|
||||
v-model="currentMessageFilter"
|
||||
class="!w-full flex-grow sm:!w-[200px] sm:flex-grow-0"
|
||||
@@ -25,12 +17,14 @@
|
||||
trigger-size="lg"
|
||||
@select="goToPage(1)"
|
||||
>
|
||||
<template #selected="{ label: messageLabel }">
|
||||
<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"
|
||||
>{{ messageLabel }} ({{ sortedReports.length }})</span
|
||||
>
|
||||
<ModerationFilterCount
|
||||
:label="currentMessageFilterName"
|
||||
:count="sortedReports.length"
|
||||
:loading="isLoading"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
</Combobox>
|
||||
@@ -78,7 +72,7 @@
|
||||
<span class="min-w-0 flex-1 truncate px-0.5 font-semibold text-inherit">
|
||||
{{
|
||||
currentReporterOrProject.length === 0
|
||||
? 'All Reports'
|
||||
? 'All reports'
|
||||
: `${currentReporterOrProject.length} selected`
|
||||
}}
|
||||
</span>
|
||||
@@ -111,7 +105,7 @@
|
||||
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="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"
|
||||
@@ -169,35 +163,34 @@
|
||||
</div>
|
||||
</template>
|
||||
</TeleportPopoutMenu>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #meta>
|
||||
<div v-if="sortedReports.length > 0">
|
||||
Showing {{ formatNumber(pageStart) }}–{{ formatNumber(pageEnd) }} of
|
||||
{{ formatNumber(sortedReports.length) }} reports
|
||||
</div>
|
||||
</template>
|
||||
</ModerationQueueToolbar>
|
||||
|
||||
<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" />
|
||||
<ModerationQueueSkeleton v-if="isLoading" />
|
||||
<div
|
||||
v-else-if="paginatedReports.length === 0"
|
||||
class="universal-card flex h-24 items-center justify-center text-secondary"
|
||||
>
|
||||
No reports in queue.
|
||||
</div>
|
||||
|
||||
<div v-if="totalPages > 1" class="flex justify-center lg:hidden">
|
||||
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div v-if="paginatedReports.length === 0" class="universal-card h-24 animate-pulse"></div>
|
||||
<div v-else class="flex flex-col gap-4 overflow-x-clip">
|
||||
<ReportCard
|
||||
v-for="report in paginatedReports"
|
||||
:key="report.id"
|
||||
:report="report"
|
||||
:collapsed="true"
|
||||
dismiss-after-close
|
||||
@dismiss="dismissReport(report.id)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="totalPages > 1" class="mt-4 flex justify-center">
|
||||
<div v-if="totalPages > 1" class="flex justify-end">
|
||||
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -211,7 +204,6 @@ import {
|
||||
ChevronLeftIcon,
|
||||
LayersIcon,
|
||||
ListFilterIcon,
|
||||
SearchIcon,
|
||||
SortAscIcon,
|
||||
SortDescIcon,
|
||||
} from '@modrinth/assets'
|
||||
@@ -222,163 +214,259 @@ import {
|
||||
commonMessages,
|
||||
formatReportType,
|
||||
injectModrinthClient,
|
||||
Input,
|
||||
MultiSelect,
|
||||
type MultiSelectItem,
|
||||
Pagination,
|
||||
TeleportPopoutMenu,
|
||||
useDebugLogger,
|
||||
useFormatNumber,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import Fuse from 'fuse.js'
|
||||
|
||||
import ModerationFilterCount from '~/components/ui/moderation/ModerationFilterCount.vue'
|
||||
import ModerationQueueSkeleton from '~/components/ui/moderation/ModerationQueueSkeleton.vue'
|
||||
import ModerationQueueToolbar from '~/components/ui/moderation/ModerationQueueToolbar.vue'
|
||||
import ReportCard from '~/components/ui/moderation/ModerationReportCard.vue'
|
||||
import { enrichReportBatch } from '~/helpers/moderation.ts'
|
||||
|
||||
useHead({ title: 'Reports queue - Modrinth' })
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const formatNumber = useFormatNumber()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = await useAuth()
|
||||
const client = injectModrinthClient()
|
||||
const debug = useDebugLogger('ModerationReports')
|
||||
|
||||
const { data: allReports } = await useLazyAsyncData('new-moderation-reports', async () => {
|
||||
const startTime = performance.now()
|
||||
let currentOffset = 0
|
||||
const REPORT_ENDPOINT_COUNT = 350
|
||||
const allReports: ExtendedReport[] = []
|
||||
const { data: allReports, pending: reportsPending } = await useLazyAsyncData(
|
||||
'new-moderation-reports',
|
||||
async () => {
|
||||
const startTime = performance.now()
|
||||
let currentOffset = 0
|
||||
const REPORT_ENDPOINT_COUNT = 350
|
||||
const allReports: ExtendedReport[] = []
|
||||
|
||||
const enrichmentPromises: Promise<ExtendedReport[]>[] = []
|
||||
const enrichmentPromises: Promise<ExtendedReport[]>[] = []
|
||||
|
||||
let reports: Labrinth.Reports.v3.Report[]
|
||||
let hasMoreReports = true
|
||||
while (hasMoreReports) {
|
||||
reports = (await useBaseFetch(
|
||||
`report?count=${REPORT_ENDPOINT_COUNT}&offset=${currentOffset}&all=true`,
|
||||
{
|
||||
apiVersion: 3,
|
||||
},
|
||||
)) as Labrinth.Reports.v3.Report[]
|
||||
let reports: Labrinth.Reports.v3.Report[]
|
||||
let hasMoreReports = true
|
||||
while (hasMoreReports) {
|
||||
reports = (await useBaseFetch(
|
||||
`report?count=${REPORT_ENDPOINT_COUNT}&offset=${currentOffset}&all=true`,
|
||||
{
|
||||
apiVersion: 3,
|
||||
},
|
||||
)) as Labrinth.Reports.v3.Report[]
|
||||
|
||||
hasMoreReports = reports.length > 0
|
||||
if (!hasMoreReports) {
|
||||
break
|
||||
hasMoreReports = reports.length > 0
|
||||
if (!hasMoreReports) {
|
||||
break
|
||||
}
|
||||
|
||||
const enrichmentPromise = enrichReportBatch(reports, client)
|
||||
enrichmentPromises.push(enrichmentPromise)
|
||||
|
||||
// this is explicitly not the length of the reports array, because the API may return fewer reports due to a report in the middle not being
|
||||
// serializable if the offset is set to the reports array you can get the same report from the end multiple times.
|
||||
currentOffset += REPORT_ENDPOINT_COUNT
|
||||
|
||||
if (enrichmentPromises.length >= 3) {
|
||||
const completed = await Promise.all(enrichmentPromises.splice(0, 2))
|
||||
allReports.push(...completed.flat())
|
||||
}
|
||||
}
|
||||
|
||||
const enrichmentPromise = enrichReportBatch(reports, client)
|
||||
enrichmentPromises.push(enrichmentPromise)
|
||||
const remainingBatches = await Promise.all(enrichmentPromises)
|
||||
allReports.push(...remainingBatches.flat())
|
||||
|
||||
// this is explicitly not the length of the reports array, because the API may return fewer reports due to a report in the middle not being
|
||||
// serializable if the offset is set to the reports array you can get the same report from the end multiple times.
|
||||
currentOffset += REPORT_ENDPOINT_COUNT
|
||||
const endTime = performance.now()
|
||||
const duration = endTime - startTime
|
||||
|
||||
if (enrichmentPromises.length >= 3) {
|
||||
const completed = await Promise.all(enrichmentPromises.splice(0, 2))
|
||||
allReports.push(...completed.flat())
|
||||
}
|
||||
}
|
||||
debug(
|
||||
`Reports fetched and processed in ${duration.toFixed(2)}ms (${(duration / 1000).toFixed(2)}s)`,
|
||||
)
|
||||
|
||||
const remainingBatches = await Promise.all(enrichmentPromises)
|
||||
allReports.push(...remainingBatches.flat())
|
||||
|
||||
const endTime = performance.now()
|
||||
const duration = endTime - startTime
|
||||
|
||||
debug(
|
||||
`Reports fetched and processed in ${duration.toFixed(2)}ms (${(duration / 1000).toFixed(2)}s)`,
|
||||
)
|
||||
|
||||
return allReports
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
return allReports
|
||||
},
|
||||
)
|
||||
|
||||
const currentSortTypeSorting = ref('oldest')
|
||||
const isLoading = computed(() => reportsPending.value || allReports.value == null)
|
||||
|
||||
const SORT_VALUES = ['oldest', 'newest'] as const
|
||||
const sortTypes: ComboboxOption<string>[] = [
|
||||
{ value: 'oldest', label: 'Oldest' },
|
||||
{ value: 'newest', label: 'Newest' },
|
||||
]
|
||||
|
||||
const currentMessageFilter = ref('all')
|
||||
const messageFilterTypes: ComboboxOption<string>[] = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'unread', label: 'Unread' },
|
||||
{ value: 'read', label: 'Read' },
|
||||
{ value: 'involved', label: 'Involved' },
|
||||
]
|
||||
const MESSAGE_FILTERS = [
|
||||
{ value: 'all', name: 'All' },
|
||||
{ value: 'unread', name: 'Unread' },
|
||||
{ value: 'read', name: 'Read' },
|
||||
{ value: 'involved', name: 'Involved' },
|
||||
] as const
|
||||
const MESSAGE_FILTER_VALUES = MESSAGE_FILTERS.map((filter) => filter.value)
|
||||
|
||||
const currentProjectTypeFilter = ref('all')
|
||||
const projectTypeFilterTypes: ComboboxOption<string>[] = [
|
||||
{ value: 'all', label: 'All project types' },
|
||||
{ value: 'modpack', label: 'Modpacks' },
|
||||
{ value: 'mod', label: 'Mods' },
|
||||
{ value: 'resourcepack', label: 'Resource Packs' },
|
||||
{ value: 'datapack', label: 'Data Packs' },
|
||||
{ value: 'plugin', label: 'Plugins' },
|
||||
{ value: 'shader', label: 'Shaders' },
|
||||
{ value: 'minecraft_java_server', label: 'Servers' },
|
||||
{ value: 'shared-instance', label: 'Shared instance' },
|
||||
]
|
||||
const PROJECT_TYPE_FILTERS = [
|
||||
{ value: 'all', name: 'All project types' },
|
||||
{ value: 'modpack', name: 'Modpacks' },
|
||||
{ value: 'mod', name: 'Mods' },
|
||||
{ value: 'resourcepack', name: 'Resource Packs' },
|
||||
{ value: 'datapack', name: 'Data Packs' },
|
||||
{ value: 'plugin', name: 'Plugins' },
|
||||
{ value: 'shader', name: 'Shaders' },
|
||||
{ value: 'minecraft_java_server', name: 'Servers' },
|
||||
{ value: 'shared-instance', name: 'Shared instance' },
|
||||
] as const
|
||||
const PROJECT_TYPE_VALUES = PROJECT_TYPE_FILTERS.map((filter) => filter.value)
|
||||
|
||||
const currentReportTargetFilter = ref('all')
|
||||
const reportTargetFilterTypes: ComboboxOption<string>[] = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'project', label: 'Projects' },
|
||||
{ value: 'user', label: 'Users' },
|
||||
{ value: 'version', label: 'Versions' },
|
||||
{ value: 'shared-instance', label: 'Shared instances' },
|
||||
]
|
||||
const REPORT_TARGET_FILTERS = [
|
||||
{ value: 'all', name: 'All' },
|
||||
{ value: 'project', name: 'Projects' },
|
||||
{ value: 'user', name: 'Users' },
|
||||
{ value: 'version', name: 'Versions' },
|
||||
{ value: 'shared-instance', name: 'Shared instances' },
|
||||
] as const
|
||||
const REPORT_TARGET_VALUES = REPORT_TARGET_FILTERS.map((filter) => filter.value)
|
||||
|
||||
const currentReportIssueFilter = ref('all')
|
||||
const reportIssueFilterTypes = computed<ComboboxOption<string>[]>(() => {
|
||||
const base: ComboboxOption<string>[] = [{ value: 'all', label: 'All' }]
|
||||
if (!allReports.value) return base
|
||||
function parseAllowed<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
|
||||
const parsed = queryAsStringOrEmpty((value as string | string[] | null | undefined) ?? '')
|
||||
return (allowed as readonly string[]).includes(parsed) ? (parsed as T) : fallback
|
||||
}
|
||||
|
||||
const issueTypes = new Set(allReports.value.map((report) => report.report_type))
|
||||
function parsePage(value: unknown): number {
|
||||
const page = Number.parseInt(
|
||||
queryAsStringOrEmpty((value as string | string[] | null | undefined) ?? ''),
|
||||
10,
|
||||
)
|
||||
return Number.isInteger(page) && page > 0 ? page : 1
|
||||
}
|
||||
|
||||
const sortedTypes = Array.from(issueTypes).sort()
|
||||
return [
|
||||
...base,
|
||||
...sortedTypes.map((type) => ({
|
||||
value: type,
|
||||
label: formatReportType(formatMessage, type),
|
||||
})),
|
||||
]
|
||||
})
|
||||
function selectedValuesEqual(left: string[], right: string[]): boolean {
|
||||
if (left.length !== right.length) return false
|
||||
return left.every((value, index) => value === right[index])
|
||||
}
|
||||
|
||||
function serializeRouteQuery(query: typeof route.query): string {
|
||||
const keys = Object.keys(query).sort()
|
||||
return JSON.stringify(
|
||||
Object.fromEntries(
|
||||
keys.flatMap((key) => {
|
||||
const value = query[key]
|
||||
if (value == null || value === '') return []
|
||||
return [[key, Array.isArray(value) ? value.map(String) : String(value)]]
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const query = ref(queryAsStringOrEmpty(route.query.q ?? ''))
|
||||
const currentSortTypeSorting = ref(parseAllowed(route.query.sort, SORT_VALUES, 'oldest'))
|
||||
const currentMessageFilter = ref(parseAllowed(route.query.messages, MESSAGE_FILTER_VALUES, 'all'))
|
||||
const currentMessageFilterName = computed(
|
||||
() =>
|
||||
MESSAGE_FILTERS.find((filter) => filter.value === currentMessageFilter.value)?.name ?? 'All',
|
||||
)
|
||||
const currentProjectTypeFilter = ref(
|
||||
parseAllowed(route.query.projectType, PROJECT_TYPE_VALUES, 'all'),
|
||||
)
|
||||
const currentReportTargetFilter = ref(parseAllowed(route.query.target, REPORT_TARGET_VALUES, 'all'))
|
||||
const currentReportIssueFilter = ref(queryAsStringOrEmpty(route.query.issue ?? '') || 'all')
|
||||
const currentReporterOrProject = ref(queryAsStringArray(route.query.selected))
|
||||
const currentPage = ref(parsePage(route.query.page))
|
||||
|
||||
function writeFiltersToRoute() {
|
||||
const nextQuery = { ...route.query }
|
||||
|
||||
if (query.value) nextQuery.q = query.value
|
||||
else delete nextQuery.q
|
||||
|
||||
if (currentSortTypeSorting.value !== 'oldest') nextQuery.sort = currentSortTypeSorting.value
|
||||
else delete nextQuery.sort
|
||||
|
||||
if (currentMessageFilter.value !== 'all') nextQuery.messages = currentMessageFilter.value
|
||||
else delete nextQuery.messages
|
||||
|
||||
if (currentReportTargetFilter.value !== 'all') nextQuery.target = currentReportTargetFilter.value
|
||||
else delete nextQuery.target
|
||||
|
||||
if (currentReportIssueFilter.value !== 'all') nextQuery.issue = currentReportIssueFilter.value
|
||||
else delete nextQuery.issue
|
||||
|
||||
if (currentProjectTypeFilter.value !== 'all') {
|
||||
nextQuery.projectType = currentProjectTypeFilter.value
|
||||
} else {
|
||||
delete nextQuery.projectType
|
||||
}
|
||||
|
||||
if (currentReporterOrProject.value.length === 1) {
|
||||
nextQuery.selected = currentReporterOrProject.value[0]
|
||||
} else if (currentReporterOrProject.value.length > 1) {
|
||||
nextQuery.selected = currentReporterOrProject.value
|
||||
} else {
|
||||
delete nextQuery.selected
|
||||
}
|
||||
|
||||
if (currentPage.value > 1) nextQuery.page = String(currentPage.value)
|
||||
else delete nextQuery.page
|
||||
|
||||
if (serializeRouteQuery(route.query) === serializeRouteQuery(nextQuery)) return
|
||||
|
||||
router.replace({
|
||||
path: route.path,
|
||||
query: nextQuery,
|
||||
})
|
||||
}
|
||||
|
||||
function readFiltersFromRoute() {
|
||||
const nextQuery = queryAsStringOrEmpty(route.query.q ?? '')
|
||||
if (query.value !== nextQuery) query.value = nextQuery
|
||||
|
||||
const nextSort = parseAllowed(route.query.sort, SORT_VALUES, 'oldest')
|
||||
if (currentSortTypeSorting.value !== nextSort) currentSortTypeSorting.value = nextSort
|
||||
|
||||
const nextMessages = parseAllowed(route.query.messages, MESSAGE_FILTER_VALUES, 'all')
|
||||
if (currentMessageFilter.value !== nextMessages) currentMessageFilter.value = nextMessages
|
||||
|
||||
const nextProjectType = parseAllowed(route.query.projectType, PROJECT_TYPE_VALUES, 'all')
|
||||
if (currentProjectTypeFilter.value !== nextProjectType) {
|
||||
currentProjectTypeFilter.value = nextProjectType
|
||||
}
|
||||
|
||||
const nextTarget = parseAllowed(route.query.target, REPORT_TARGET_VALUES, 'all')
|
||||
if (currentReportTargetFilter.value !== nextTarget) currentReportTargetFilter.value = nextTarget
|
||||
|
||||
const nextIssue = queryAsStringOrEmpty(route.query.issue ?? '') || 'all'
|
||||
if (currentReportIssueFilter.value !== nextIssue) currentReportIssueFilter.value = nextIssue
|
||||
|
||||
const nextSelected = queryAsStringArray(route.query.selected)
|
||||
if (!selectedValuesEqual(currentReporterOrProject.value, nextSelected)) {
|
||||
currentReporterOrProject.value = nextSelected
|
||||
}
|
||||
|
||||
const nextPage = parsePage(route.query.page)
|
||||
if (currentPage.value !== nextPage) currentPage.value = nextPage
|
||||
}
|
||||
|
||||
watch(
|
||||
[
|
||||
query,
|
||||
currentSortTypeSorting,
|
||||
currentMessageFilter,
|
||||
currentProjectTypeFilter,
|
||||
currentReportTargetFilter,
|
||||
currentReportIssueFilter,
|
||||
currentReporterOrProject,
|
||||
currentPage,
|
||||
],
|
||||
writeFiltersToRoute,
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
watch(() => route.query, readFiltersFromRoute, { deep: true })
|
||||
|
||||
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>[] = []
|
||||
@@ -410,7 +498,7 @@ const reporterOrProjectOptions = computed<MultiSelectItem<string>[]>(() => {
|
||||
.forEach((project) => {
|
||||
options.push({
|
||||
value: `project/${project.id}`,
|
||||
label: `${project.title} (${project.report_item_count})`,
|
||||
label: `${project.title} (${formatNumber(project.report_item_count)})`,
|
||||
icon: project.icon_url ? h('img', { src: project.icon_url }) : undefined,
|
||||
})
|
||||
})
|
||||
@@ -426,7 +514,7 @@ const reporterOrProjectOptions = computed<MultiSelectItem<string>[]>(() => {
|
||||
.forEach((reporter) => {
|
||||
options.push({
|
||||
value: `reporter/${reporter.id}`,
|
||||
label: `${reporter.username} (${reporter.report_item_count})`,
|
||||
label: `${reporter.username} (${formatNumber(reporter.report_item_count)})`,
|
||||
icon: reporter.avatar_url ? h('img', { src: reporter.avatar_url }) : undefined,
|
||||
})
|
||||
})
|
||||
@@ -434,7 +522,6 @@ const reporterOrProjectOptions = computed<MultiSelectItem<string>[]>(() => {
|
||||
return options
|
||||
})
|
||||
|
||||
const currentPage = ref(1)
|
||||
const itemsPerPage = 15
|
||||
const totalPages = computed(() => Math.ceil((sortedReports.value?.length || 0) / itemsPerPage))
|
||||
|
||||
@@ -501,65 +588,164 @@ const baseFiltered = computed(() => {
|
||||
})
|
||||
|
||||
const filteredReports = computed(() => {
|
||||
const messageFilter = currentMessageFilter.value
|
||||
const projectTypeFilter = currentProjectTypeFilter.value
|
||||
const reportTargetFilter = currentReportTargetFilter.value
|
||||
const reportIssueFilter = currentReportIssueFilter.value
|
||||
|
||||
if (
|
||||
messageFilter === 'all' &&
|
||||
projectTypeFilter === 'all' &&
|
||||
reportTargetFilter === 'all' &&
|
||||
reportIssueFilter === 'all'
|
||||
) {
|
||||
return baseFiltered.value
|
||||
}
|
||||
|
||||
const messageFilterPredicate = (report: ExtendedReport) => {
|
||||
const messages = report.thread?.messages || []
|
||||
|
||||
if (messageFilter === 'all') return true
|
||||
if (messages.length === 0) return messageFilter === 'Unread'
|
||||
if (!messages[messages.length - 1].author_id) return false
|
||||
|
||||
if (messageFilter === 'involved') {
|
||||
const userId = (auth.value.user as any)?.id
|
||||
return userId && messages.some((message) => message.author_id === userId)
|
||||
}
|
||||
|
||||
const roleMap = memberRoleMap.value.get(report.id)
|
||||
if (!roleMap) return false
|
||||
|
||||
const authorRole = roleMap.get(messages[messages.length - 1].author_id)
|
||||
const isModeratorMessage = authorRole === 'moderator' || authorRole === 'admin'
|
||||
|
||||
return messageFilter === 'Read' ? isModeratorMessage : !isModeratorMessage
|
||||
}
|
||||
|
||||
const projectTypeFilterPredicate = (report: ExtendedReport) => {
|
||||
if (projectTypeFilter === 'all') return true
|
||||
if (projectTypeFilter === 'shared-instance') return report.item_type === 'shared-instance'
|
||||
return report.project?.project_type === projectTypeFilter
|
||||
}
|
||||
|
||||
const reportTargetFilterPredicate = (report: ExtendedReport) => {
|
||||
return reportTargetFilter === 'all' || report.item_type === reportTargetFilter
|
||||
}
|
||||
|
||||
const reportIssueFilterPredicate = (report: ExtendedReport) => {
|
||||
return reportIssueFilter === 'all' || report.report_type === reportIssueFilter
|
||||
}
|
||||
|
||||
return baseFiltered.value.filter((report) => {
|
||||
return (
|
||||
messageFilterPredicate(report) &&
|
||||
projectTypeFilterPredicate(report) &&
|
||||
reportTargetFilterPredicate(report) &&
|
||||
reportIssueFilterPredicate(report)
|
||||
matchesMessageFilter(report, currentMessageFilter.value) &&
|
||||
matchesProjectTypeFilter(report, currentProjectTypeFilter.value) &&
|
||||
matchesReportTargetFilter(report, currentReportTargetFilter.value) &&
|
||||
matchesReportIssueFilter(report, currentReportIssueFilter.value)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
function matchesMessageFilter(
|
||||
report: ExtendedReport,
|
||||
messageFilter: (typeof MESSAGE_FILTERS)[number]['value'] | string,
|
||||
): boolean {
|
||||
if (messageFilter === 'all') return true
|
||||
|
||||
const messages = report.thread?.messages || []
|
||||
if (messages.length === 0) return messageFilter === 'unread'
|
||||
if (!messages[messages.length - 1].author_id) return false
|
||||
|
||||
if (messageFilter === 'involved') {
|
||||
const userId = (auth.value.user as any)?.id
|
||||
return !!userId && messages.some((message) => message.author_id === userId)
|
||||
}
|
||||
|
||||
const roleMap = memberRoleMap.value.get(report.id)
|
||||
if (!roleMap) return false
|
||||
|
||||
const authorRole = roleMap.get(messages[messages.length - 1].author_id)
|
||||
const isModeratorMessage = authorRole === 'moderator' || authorRole === 'admin'
|
||||
|
||||
return messageFilter === 'read' ? isModeratorMessage : !isModeratorMessage
|
||||
}
|
||||
|
||||
function matchesProjectTypeFilter(
|
||||
report: ExtendedReport,
|
||||
projectTypeFilter: (typeof PROJECT_TYPE_FILTERS)[number]['value'] | string,
|
||||
): boolean {
|
||||
if (projectTypeFilter === 'all') return true
|
||||
if (projectTypeFilter === 'shared-instance') return report.item_type === 'shared-instance'
|
||||
return report.project?.project_type === projectTypeFilter
|
||||
}
|
||||
|
||||
function matchesReportTargetFilter(
|
||||
report: ExtendedReport,
|
||||
reportTargetFilter: (typeof REPORT_TARGET_FILTERS)[number]['value'] | string,
|
||||
): boolean {
|
||||
return reportTargetFilter === 'all' || report.item_type === reportTargetFilter
|
||||
}
|
||||
|
||||
function matchesReportIssueFilter(report: ExtendedReport, reportIssueFilter: string): boolean {
|
||||
return reportIssueFilter === 'all' || report.report_type === reportIssueFilter
|
||||
}
|
||||
|
||||
function labelWithCount(name: string, count: number): string {
|
||||
return `${name} (${formatNumber(count)})`
|
||||
}
|
||||
|
||||
const reportsForMessageCounts = computed(() =>
|
||||
baseFiltered.value.filter(
|
||||
(report) =>
|
||||
matchesProjectTypeFilter(report, currentProjectTypeFilter.value) &&
|
||||
matchesReportTargetFilter(report, currentReportTargetFilter.value) &&
|
||||
matchesReportIssueFilter(report, currentReportIssueFilter.value),
|
||||
),
|
||||
)
|
||||
const reportsForProjectTypeCounts = computed(() =>
|
||||
baseFiltered.value.filter(
|
||||
(report) =>
|
||||
matchesMessageFilter(report, currentMessageFilter.value) &&
|
||||
matchesReportTargetFilter(report, currentReportTargetFilter.value) &&
|
||||
matchesReportIssueFilter(report, currentReportIssueFilter.value),
|
||||
),
|
||||
)
|
||||
const reportsForTargetCounts = computed(() =>
|
||||
baseFiltered.value.filter(
|
||||
(report) =>
|
||||
matchesMessageFilter(report, currentMessageFilter.value) &&
|
||||
matchesProjectTypeFilter(report, currentProjectTypeFilter.value) &&
|
||||
matchesReportIssueFilter(report, currentReportIssueFilter.value),
|
||||
),
|
||||
)
|
||||
const reportsForIssueCounts = computed(() =>
|
||||
baseFiltered.value.filter(
|
||||
(report) =>
|
||||
matchesMessageFilter(report, currentMessageFilter.value) &&
|
||||
matchesProjectTypeFilter(report, currentProjectTypeFilter.value) &&
|
||||
matchesReportTargetFilter(report, currentReportTargetFilter.value),
|
||||
),
|
||||
)
|
||||
|
||||
const messageFilterTypes = computed<ComboboxOption<string>[]>(() =>
|
||||
MESSAGE_FILTERS.map((filter) => ({
|
||||
value: filter.value,
|
||||
label: isLoading.value
|
||||
? filter.name
|
||||
: labelWithCount(
|
||||
filter.name,
|
||||
reportsForMessageCounts.value.filter((report) =>
|
||||
matchesMessageFilter(report, filter.value),
|
||||
).length,
|
||||
),
|
||||
})),
|
||||
)
|
||||
|
||||
const projectTypeFilterTypes = computed<ComboboxOption<string>[]>(() =>
|
||||
PROJECT_TYPE_FILTERS.map((filter) => ({
|
||||
value: filter.value,
|
||||
label: isLoading.value
|
||||
? filter.name
|
||||
: labelWithCount(
|
||||
filter.name,
|
||||
reportsForProjectTypeCounts.value.filter((report) =>
|
||||
matchesProjectTypeFilter(report, filter.value),
|
||||
).length,
|
||||
),
|
||||
})),
|
||||
)
|
||||
|
||||
const reportTargetFilterTypes = computed<ComboboxOption<string>[]>(() =>
|
||||
REPORT_TARGET_FILTERS.map((filter) => ({
|
||||
value: filter.value,
|
||||
label: isLoading.value
|
||||
? filter.name
|
||||
: labelWithCount(
|
||||
filter.name,
|
||||
reportsForTargetCounts.value.filter((report) =>
|
||||
matchesReportTargetFilter(report, filter.value),
|
||||
).length,
|
||||
),
|
||||
})),
|
||||
)
|
||||
|
||||
const reportIssueFilterTypes = computed<ComboboxOption<string>[]>(() => {
|
||||
const issueTypes = new Set((allReports.value ?? []).map((report) => report.report_type))
|
||||
const options = [
|
||||
{ value: 'all', name: 'All' },
|
||||
...Array.from(issueTypes)
|
||||
.sort()
|
||||
.map((type) => ({
|
||||
value: type,
|
||||
name: formatReportType(formatMessage, type),
|
||||
})),
|
||||
]
|
||||
|
||||
return options.map((filter) => ({
|
||||
value: filter.value,
|
||||
label: isLoading.value
|
||||
? filter.name
|
||||
: labelWithCount(
|
||||
filter.name,
|
||||
reportsForIssueCounts.value.filter((report) =>
|
||||
matchesReportIssueFilter(report, filter.value),
|
||||
).length,
|
||||
),
|
||||
}))
|
||||
})
|
||||
|
||||
const sortedReports = computed(() => {
|
||||
const reporterOrProjectFilter = currentReporterOrProject.value
|
||||
const filtered =
|
||||
@@ -591,7 +777,37 @@ const paginatedReports = computed(() => {
|
||||
return sortedReports.value.slice(start, end)
|
||||
})
|
||||
|
||||
const pageStart = computed(() =>
|
||||
sortedReports.value.length === 0 ? 0 : itemsPerPage * (currentPage.value - 1) + 1,
|
||||
)
|
||||
const pageEnd = computed(
|
||||
() =>
|
||||
itemsPerPage * (currentPage.value - 1) + Math.min(itemsPerPage, paginatedReports.value.length),
|
||||
)
|
||||
|
||||
function goToPage(page: number) {
|
||||
currentPage.value = page
|
||||
}
|
||||
|
||||
watch(totalPages, (pages) => {
|
||||
if (isLoading.value) return
|
||||
|
||||
if (pages === 0) {
|
||||
if (currentPage.value !== 1) goToPage(1)
|
||||
return
|
||||
}
|
||||
|
||||
if (currentPage.value > pages) {
|
||||
goToPage(pages)
|
||||
}
|
||||
})
|
||||
|
||||
function dismissReport(reportId: string) {
|
||||
if (!allReports.value) return
|
||||
|
||||
allReports.value = allReports.value.filter((report) => report.id !== reportId)
|
||||
if (currentPage.value > totalPages.value) {
|
||||
currentPage.value = Math.max(1, totalPages.value)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { ArrowLeftIcon, LoaderCircleIcon } from '@modrinth/assets'
|
||||
import { ButtonLink, injectModrinthClient } from '@modrinth/ui'
|
||||
import { LoaderCircleIcon } from '@modrinth/assets'
|
||||
import { BackToParentLink, injectModrinthClient } from '@modrinth/ui'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
|
||||
import MaliciousSummaryModal, {
|
||||
type UnsafeFile,
|
||||
} from '~/components/ui/moderation/MaliciousSummaryModal.vue'
|
||||
import ModerationTechRevCard from '~/components/ui/moderation/ModerationTechRevCard.vue'
|
||||
import { flattenFileReports } from '~/components/ui/moderation/tech-review/helpers'
|
||||
import { useTechReviewSources } from '~/components/ui/moderation/tech-review/use-tech-review-sources'
|
||||
|
||||
const client = injectModrinthClient()
|
||||
const queryClient = useQueryClient()
|
||||
@@ -18,133 +20,6 @@ const projectId = String(useRouteId('project'))
|
||||
|
||||
useHead({ title: () => `Tech review - ${projectId} - Modrinth` })
|
||||
|
||||
const CACHE_TTL = 24 * 60 * 60 * 1000
|
||||
const CACHE_KEY_PREFIX = 'tech_review_source_'
|
||||
|
||||
type CachedSource = {
|
||||
source: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
function getCachedSource(detailId: string): string | null {
|
||||
try {
|
||||
const cached = localStorage.getItem(`${CACHE_KEY_PREFIX}${detailId}`)
|
||||
if (!cached) return null
|
||||
|
||||
const data: CachedSource = JSON.parse(cached)
|
||||
const now = Date.now()
|
||||
|
||||
if (now - data.timestamp > CACHE_TTL) {
|
||||
localStorage.removeItem(`${CACHE_KEY_PREFIX}${detailId}`)
|
||||
return null
|
||||
}
|
||||
|
||||
return data.source
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function setCachedSource(detailId: string, source: string): void {
|
||||
try {
|
||||
const data: CachedSource = {
|
||||
source,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
localStorage.setItem(`${CACHE_KEY_PREFIX}${detailId}`, JSON.stringify(data))
|
||||
} catch (error) {
|
||||
console.error('Failed to cache source:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function clearExpiredCache(): void {
|
||||
try {
|
||||
const now = Date.now()
|
||||
const keys = Object.keys(localStorage)
|
||||
|
||||
for (const key of keys) {
|
||||
if (key.startsWith(CACHE_KEY_PREFIX)) {
|
||||
const cached = localStorage.getItem(key)
|
||||
if (cached) {
|
||||
const data: CachedSource = JSON.parse(cached)
|
||||
if (now - data.timestamp > CACHE_TTL) {
|
||||
localStorage.removeItem(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to clear expired cache:', error)
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.client) {
|
||||
clearExpiredCache()
|
||||
}
|
||||
|
||||
const loadingIssues = reactive<Set<string>>(new Set())
|
||||
const decompiledSources = reactive<Map<string, string>>(new Map())
|
||||
const loadedIssues = reactive<Set<string>>(new Set())
|
||||
|
||||
async function loadIssueSource(issueId: string): Promise<void> {
|
||||
if (loadingIssues.has(issueId) || loadedIssues.has(issueId)) return
|
||||
|
||||
loadingIssues.add(issueId)
|
||||
|
||||
try {
|
||||
const issueData = await client.labrinth.tech_review_internal.getIssue(issueId)
|
||||
|
||||
for (const detail of issueData.details) {
|
||||
if (detail.decompiled_source) {
|
||||
decompiledSources.set(detail.id, detail.decompiled_source)
|
||||
setCachedSource(detail.id, detail.decompiled_source)
|
||||
}
|
||||
}
|
||||
loadedIssues.add(issueId)
|
||||
} catch (error) {
|
||||
console.error('Failed to load issue source:', error)
|
||||
} finally {
|
||||
loadingIssues.delete(issueId)
|
||||
}
|
||||
}
|
||||
|
||||
function findIssuesByIds(issueIds: Set<string>): Labrinth.TechReview.Internal.FileIssue[] {
|
||||
const issues: Labrinth.TechReview.Internal.FileIssue[] = []
|
||||
|
||||
if (!reviewItem.value) return []
|
||||
|
||||
for (const report of reviewItem.value.reports) {
|
||||
for (const issue of report.issues) {
|
||||
if (issueIds.has(issue.id)) {
|
||||
issues.push(issue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
function handleLoadIssueSources(issueIds: string[]): void {
|
||||
const uniqueIssueIds = new Set(issueIds)
|
||||
const issues = findIssuesByIds(uniqueIssueIds)
|
||||
|
||||
for (const issue of issues) {
|
||||
for (const detail of issue.details) {
|
||||
if (!decompiledSources.has(detail.id)) {
|
||||
const cached = getCachedSource(detail.id)
|
||||
if (cached) {
|
||||
decompiledSources.set(detail.id, cached)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hasUncached = issue.details.some((detail) => !decompiledSources.has(detail.id))
|
||||
if (hasUncached) {
|
||||
loadIssueSource(issue.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
data: projectReportData,
|
||||
isLoading: isLoadingReport,
|
||||
@@ -194,11 +69,6 @@ const isLoading = computed(
|
||||
|
||||
const hasError = computed(() => isReportError.value || isProjectError.value)
|
||||
|
||||
type FlattenedFileReport = Labrinth.TechReview.Internal.FileReport & {
|
||||
id: string
|
||||
version_id: string
|
||||
}
|
||||
|
||||
const ownership = computed<Labrinth.TechReview.Internal.Ownership | null>(() => {
|
||||
if (organizationData.value) {
|
||||
return {
|
||||
@@ -229,15 +99,7 @@ const reviewItem = computed(() => {
|
||||
|
||||
const { project_report, thread } = projectReportData.value
|
||||
|
||||
const reports: FlattenedFileReport[] = project_report
|
||||
? project_report.versions.flatMap((version) =>
|
||||
version.files.map((file) => ({
|
||||
...file,
|
||||
id: file.report_id,
|
||||
version_id: version.version_id,
|
||||
})),
|
||||
)
|
||||
: []
|
||||
const reports = project_report ? flattenFileReports(project_report.versions) : []
|
||||
|
||||
return {
|
||||
project: projectData.value,
|
||||
@@ -247,6 +109,10 @@ const reviewItem = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const { loadingIssues, decompiledSources, handleLoadIssueSources } = useTechReviewSources(
|
||||
() => reviewItem.value?.reports.flatMap((report) => report.issues) ?? [],
|
||||
)
|
||||
|
||||
const focusedDetailId = computed(() => route.query.detail?.toString() ?? null)
|
||||
|
||||
async function handleMarkComplete(projectId: string) {
|
||||
@@ -301,13 +167,8 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<ButtonLink :to="'/moderation/technical-review'">
|
||||
<ArrowLeftIcon class="size-5" />
|
||||
Back to queue
|
||||
</ButtonLink>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<BackToParentLink :to="'/moderation/technical-review'"> Back to queue </BackToParentLink>
|
||||
|
||||
<div v-if="isLoading" class="flex flex-col gap-4">
|
||||
<div class="universal-card flex h-48 items-center justify-center">
|
||||
@@ -333,12 +194,12 @@ onUnmounted(() => {
|
||||
:loading-issues="loadingIssues"
|
||||
:decompiled-sources="decompiledSources"
|
||||
:collapsed="false"
|
||||
disable-collapsing
|
||||
@refetch="refetch"
|
||||
@load-issue-sources="handleLoadIssueSources"
|
||||
@mark-complete="handleMarkComplete"
|
||||
@show-malicious-summary="handleShowMaliciousSummary"
|
||||
/>
|
||||
|
||||
<MaliciousSummaryModal ref="maliciousSummaryModalRef" :unsafe-files="currentUnsafeFiles" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,22 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
BlendIcon,
|
||||
ListFilterIcon,
|
||||
LoaderCircleIcon,
|
||||
SearchIcon,
|
||||
SortAscIcon,
|
||||
SortDescIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { BlendIcon, ListFilterIcon, SortAscIcon, SortDescIcon, SpinnerIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Combobox,
|
||||
type ComboboxOption,
|
||||
commonMessages,
|
||||
injectModrinthClient,
|
||||
Input,
|
||||
Pagination,
|
||||
TeleportPopoutMenu,
|
||||
Toggle,
|
||||
useFormatNumber,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useInfiniteQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
@@ -26,7 +19,11 @@ import { nextTick, reactive } from 'vue'
|
||||
import MaliciousSummaryModal, {
|
||||
type UnsafeFile,
|
||||
} from '~/components/ui/moderation/MaliciousSummaryModal.vue'
|
||||
import ModerationQueueSkeleton from '~/components/ui/moderation/ModerationQueueSkeleton.vue'
|
||||
import ModerationQueueToolbar from '~/components/ui/moderation/ModerationQueueToolbar.vue'
|
||||
import ModerationTechRevCard from '~/components/ui/moderation/ModerationTechRevCard.vue'
|
||||
import { flattenFileReports } from '~/components/ui/moderation/tech-review/helpers'
|
||||
import { useTechReviewSources } from '~/components/ui/moderation/tech-review/use-tech-review-sources'
|
||||
|
||||
useHead({ title: 'Tech review queue - Modrinth' })
|
||||
|
||||
@@ -34,187 +31,14 @@ const client = injectModrinthClient()
|
||||
const queryClient = useQueryClient()
|
||||
const keybinds = useModerationKeybinds()
|
||||
|
||||
const currentPage = ref(1)
|
||||
const API_PAGE_SIZE = 50
|
||||
const UI_PAGE_SIZE = 4
|
||||
const { formatMessage } = useVIntl()
|
||||
const formatNumber = useFormatNumber()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const CACHE_TTL = 24 * 60 * 60 * 1000
|
||||
const CACHE_KEY_PREFIX = 'tech_review_source_'
|
||||
|
||||
type CachedSource = {
|
||||
source: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
function getCachedSource(detailId: string): string | null {
|
||||
try {
|
||||
const cached = localStorage.getItem(`${CACHE_KEY_PREFIX}${detailId}`)
|
||||
if (!cached) return null
|
||||
|
||||
const data: CachedSource = JSON.parse(cached)
|
||||
const now = Date.now()
|
||||
|
||||
if (now - data.timestamp > CACHE_TTL) {
|
||||
localStorage.removeItem(`${CACHE_KEY_PREFIX}${detailId}`)
|
||||
return null
|
||||
}
|
||||
|
||||
return data.source
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function setCachedSource(detailId: string, source: string): void {
|
||||
try {
|
||||
const data: CachedSource = {
|
||||
source,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
localStorage.setItem(`${CACHE_KEY_PREFIX}${detailId}`, JSON.stringify(data))
|
||||
} catch (error) {
|
||||
console.error('Failed to cache source:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function clearExpiredCache(): void {
|
||||
try {
|
||||
const now = Date.now()
|
||||
const keys = Object.keys(localStorage)
|
||||
|
||||
for (const key of keys) {
|
||||
if (key.startsWith(CACHE_KEY_PREFIX)) {
|
||||
const cached = localStorage.getItem(key)
|
||||
if (cached) {
|
||||
const data: CachedSource = JSON.parse(cached)
|
||||
if (now - data.timestamp > CACHE_TTL) {
|
||||
localStorage.removeItem(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to clear expired cache:', error)
|
||||
}
|
||||
}
|
||||
|
||||
clearExpiredCache()
|
||||
|
||||
const loadingIssues = reactive<Set<string>>(new Set())
|
||||
const decompiledSources = reactive<Map<string, string>>(new Map())
|
||||
const loadedIssues = reactive<Set<string>>(new Set())
|
||||
|
||||
async function loadIssueSource(issueId: string): Promise<void> {
|
||||
if (loadingIssues.has(issueId) || loadedIssues.has(issueId)) return
|
||||
|
||||
loadingIssues.add(issueId)
|
||||
|
||||
try {
|
||||
const issueData = await client.labrinth.tech_review_internal.getIssue(issueId)
|
||||
|
||||
for (const detail of issueData.details) {
|
||||
if (detail.decompiled_source) {
|
||||
decompiledSources.set(detail.id, detail.decompiled_source)
|
||||
setCachedSource(detail.id, detail.decompiled_source)
|
||||
}
|
||||
}
|
||||
loadedIssues.add(issueId)
|
||||
} catch (error) {
|
||||
console.error('Failed to load issue source:', error)
|
||||
} finally {
|
||||
loadingIssues.delete(issueId)
|
||||
}
|
||||
}
|
||||
|
||||
function findIssuesByIds(issueIds: Set<string>): Labrinth.TechReview.Internal.FileIssue[] {
|
||||
const issues: Labrinth.TechReview.Internal.FileIssue[] = []
|
||||
|
||||
for (const review of reviewItems.value) {
|
||||
for (const report of review.reports) {
|
||||
for (const issue of report.issues) {
|
||||
if (issueIds.has(issue.id)) {
|
||||
issues.push(issue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
function handleLoadIssueSources(issueIds: string[]): void {
|
||||
const uniqueIssueIds = new Set(issueIds)
|
||||
const issues = findIssuesByIds(uniqueIssueIds)
|
||||
|
||||
for (const issue of issues) {
|
||||
for (const detail of issue.details) {
|
||||
if (!decompiledSources.has(detail.id)) {
|
||||
const cached = getCachedSource(detail.id)
|
||||
if (cached) {
|
||||
decompiledSources.set(detail.id, cached)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hasUncached = issue.details.some((detail) => !decompiledSources.has(detail.id))
|
||||
if (hasUncached) {
|
||||
loadIssueSource(issue.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
goToPage(1)
|
||||
},
|
||||
{ immediate: false },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => route.query.q,
|
||||
(newQueryParam) => {
|
||||
const newValue = newQueryParam?.toString() || ''
|
||||
if (query.value !== newValue) {
|
||||
query.value = newValue
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const currentFilterType = ref('All flags')
|
||||
|
||||
const filterTypes = computed<ComboboxOption<string>[]>(() => {
|
||||
const base: ComboboxOption<string>[] = [{ value: 'All flags', label: 'All flags' }]
|
||||
if (!reviewItems.value) return base
|
||||
|
||||
const issueTypes = new Set(
|
||||
reviewItems.value
|
||||
.flatMap((review) => review.reports)
|
||||
.flatMap((report) => report.issues)
|
||||
.map((issue) => issue.issue_type),
|
||||
)
|
||||
|
||||
const sortedTypes = Array.from(issueTypes).sort()
|
||||
return [...base, ...sortedTypes.map((type) => ({ value: type, label: type }))]
|
||||
})
|
||||
|
||||
const currentSortType = ref('Severity highest')
|
||||
const SORT_VALUES = ['Severity highest', 'Severity lowest', 'Oldest', 'Newest'] as const
|
||||
const sortTypes: ComboboxOption<string>[] = [
|
||||
{ value: 'Severity highest', label: 'Severity highest' },
|
||||
{ value: 'Severity lowest', label: 'Severity lowest' },
|
||||
@@ -222,26 +46,200 @@ const sortTypes: ComboboxOption<string>[] = [
|
||||
{ value: 'Newest', label: 'Newest' },
|
||||
]
|
||||
|
||||
const currentResponseFilter = ref('All')
|
||||
const RESPONSE_FILTER_VALUES = ['All', 'Unread', 'Read'] as const
|
||||
const responseFilterTypes: ComboboxOption<string>[] = [
|
||||
{ value: 'All', label: 'All' },
|
||||
{ value: 'Unread', label: 'Unread' },
|
||||
{ value: 'Read', label: 'Read' },
|
||||
]
|
||||
|
||||
const currentProjectTypeFilter = ref('All project types')
|
||||
const projectTypeFilterTypes: ComboboxOption<string>[] = [
|
||||
{ value: 'All project types', label: 'All project types' },
|
||||
{ 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' },
|
||||
{ value: 'Servers', label: 'Servers' },
|
||||
]
|
||||
const PROJECT_TYPE_FILTERS = [
|
||||
{ value: 'All project types', name: 'All project types' },
|
||||
{ value: 'Modpacks', name: 'Modpacks' },
|
||||
{ value: 'Mods', name: 'Mods' },
|
||||
{ value: 'Resource Packs', name: 'Resource Packs' },
|
||||
{ value: 'Data Packs', name: 'Data Packs' },
|
||||
{ value: 'Plugins', name: 'Plugins' },
|
||||
{ value: 'Shaders', name: 'Shaders' },
|
||||
{ value: 'Servers', name: 'Servers' },
|
||||
] as const
|
||||
const PROJECT_TYPE_VALUES = PROJECT_TYPE_FILTERS.map((filter) => filter.value)
|
||||
|
||||
const inOtherQueueFilter = ref(true)
|
||||
function parseAllowed<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
|
||||
const parsed = queryAsStringOrEmpty((value as string | string[] | null | undefined) ?? '')
|
||||
return (allowed as readonly string[]).includes(parsed) ? (parsed as T) : fallback
|
||||
}
|
||||
|
||||
function parsePage(value: unknown): number {
|
||||
const page = Number.parseInt(
|
||||
queryAsStringOrEmpty((value as string | string[] | null | undefined) ?? ''),
|
||||
10,
|
||||
)
|
||||
return Number.isInteger(page) && page > 0 ? page : 1
|
||||
}
|
||||
|
||||
function parseBoolean(value: unknown, fallback: boolean): boolean {
|
||||
const parsed = queryAsStringOrEmpty(
|
||||
(value as string | string[] | null | undefined) ?? '',
|
||||
).toLowerCase()
|
||||
if (parsed === 'true' || parsed === '1') return true
|
||||
if (parsed === 'false' || parsed === '0') return false
|
||||
return fallback
|
||||
}
|
||||
|
||||
function serializeRouteQuery(query: typeof route.query): string {
|
||||
const keys = Object.keys(query).sort()
|
||||
return JSON.stringify(
|
||||
Object.fromEntries(
|
||||
keys.flatMap((key) => {
|
||||
const value = query[key]
|
||||
if (value == null || value === '') return []
|
||||
return [[key, Array.isArray(value) ? value.map(String) : String(value)]]
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const query = ref(queryAsStringOrEmpty(route.query.q ?? ''))
|
||||
const currentFilterType = ref(queryAsStringOrEmpty(route.query.flags ?? '') || 'All flags')
|
||||
const currentSortType = ref(parseAllowed(route.query.sort, SORT_VALUES, 'Severity highest'))
|
||||
const currentResponseFilter = ref(parseAllowed(route.query.response, RESPONSE_FILTER_VALUES, 'All'))
|
||||
const currentProjectTypeFilter = ref(
|
||||
parseAllowed(route.query.projectType, PROJECT_TYPE_VALUES, 'All project types'),
|
||||
)
|
||||
const inOtherQueueFilter = ref(parseBoolean(route.query.underReview, true))
|
||||
const currentPage = ref(parsePage(route.query.page))
|
||||
|
||||
let syncingFromRoute = false
|
||||
|
||||
function writeFiltersToRoute() {
|
||||
if (syncingFromRoute) return
|
||||
|
||||
const nextQuery = { ...route.query }
|
||||
|
||||
if (query.value) nextQuery.q = query.value
|
||||
else delete nextQuery.q
|
||||
|
||||
if (currentSortType.value !== 'Severity highest') nextQuery.sort = currentSortType.value
|
||||
else delete nextQuery.sort
|
||||
|
||||
if (currentResponseFilter.value !== 'All') nextQuery.response = currentResponseFilter.value
|
||||
else delete nextQuery.response
|
||||
|
||||
if (currentFilterType.value !== 'All flags') nextQuery.flags = currentFilterType.value
|
||||
else delete nextQuery.flags
|
||||
|
||||
if (currentProjectTypeFilter.value !== 'All project types') {
|
||||
nextQuery.projectType = currentProjectTypeFilter.value
|
||||
} else {
|
||||
delete nextQuery.projectType
|
||||
}
|
||||
|
||||
if (!inOtherQueueFilter.value) nextQuery.underReview = 'false'
|
||||
else delete nextQuery.underReview
|
||||
|
||||
if (currentPage.value > 1) nextQuery.page = String(currentPage.value)
|
||||
else delete nextQuery.page
|
||||
|
||||
if (serializeRouteQuery(route.query) === serializeRouteQuery(nextQuery)) return
|
||||
|
||||
router.replace({
|
||||
path: route.path,
|
||||
query: nextQuery,
|
||||
})
|
||||
}
|
||||
|
||||
function readFiltersFromRoute() {
|
||||
syncingFromRoute = true
|
||||
|
||||
const nextQuery = queryAsStringOrEmpty(route.query.q ?? '')
|
||||
if (query.value !== nextQuery) query.value = nextQuery
|
||||
|
||||
const nextFlags = queryAsStringOrEmpty(route.query.flags ?? '') || 'All flags'
|
||||
if (currentFilterType.value !== nextFlags) currentFilterType.value = nextFlags
|
||||
|
||||
const nextSort = parseAllowed(route.query.sort, SORT_VALUES, 'Severity highest')
|
||||
if (currentSortType.value !== nextSort) currentSortType.value = nextSort
|
||||
|
||||
const nextResponse = parseAllowed(route.query.response, RESPONSE_FILTER_VALUES, 'All')
|
||||
if (currentResponseFilter.value !== nextResponse) currentResponseFilter.value = nextResponse
|
||||
|
||||
const nextProjectType = parseAllowed(
|
||||
route.query.projectType,
|
||||
PROJECT_TYPE_VALUES,
|
||||
'All project types',
|
||||
)
|
||||
if (currentProjectTypeFilter.value !== nextProjectType) {
|
||||
currentProjectTypeFilter.value = nextProjectType
|
||||
}
|
||||
|
||||
const nextUnderReview = parseBoolean(route.query.underReview, true)
|
||||
if (inOtherQueueFilter.value !== nextUnderReview) inOtherQueueFilter.value = nextUnderReview
|
||||
|
||||
const nextPage = parsePage(route.query.page)
|
||||
if (currentPage.value !== nextPage) currentPage.value = nextPage
|
||||
|
||||
nextTick(() => {
|
||||
syncingFromRoute = false
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
[
|
||||
query,
|
||||
currentFilterType,
|
||||
currentSortType,
|
||||
currentResponseFilter,
|
||||
currentProjectTypeFilter,
|
||||
inOtherQueueFilter,
|
||||
currentPage,
|
||||
],
|
||||
writeFiltersToRoute,
|
||||
)
|
||||
|
||||
watch(() => route.query, readFiltersFromRoute, { deep: true })
|
||||
|
||||
const filterTypes = computed<ComboboxOption<string>[]>(() => {
|
||||
const issues =
|
||||
reviewItems.value?.flatMap((review) => review.reports.flatMap((report) => report.issues)) ?? []
|
||||
const counts = new Map<string, number>()
|
||||
for (const issue of issues) {
|
||||
counts.set(issue.issue_type, (counts.get(issue.issue_type) ?? 0) + 1)
|
||||
}
|
||||
|
||||
const options: ComboboxOption<string>[] = [
|
||||
{
|
||||
value: 'All flags',
|
||||
label: isLoading.value ? 'All flags' : `All flags (${formatNumber(issues.length)})`,
|
||||
},
|
||||
]
|
||||
for (const type of Array.from(counts.keys()).sort()) {
|
||||
options.push({
|
||||
value: type,
|
||||
label: isLoading.value ? type : `${type} (${formatNumber(counts.get(type) ?? 0)})`,
|
||||
})
|
||||
}
|
||||
return options
|
||||
})
|
||||
|
||||
const projectTypeFilterTypes = computed<ComboboxOption<string>[]>(() => {
|
||||
const items = reviewItems.value ?? []
|
||||
const showCounts = !isLoading.value && currentProjectTypeFilter.value === 'All project types'
|
||||
|
||||
return PROJECT_TYPE_FILTERS.map((filter) => {
|
||||
if (!showCounts) {
|
||||
return { value: filter.value, label: filter.name }
|
||||
}
|
||||
|
||||
const apiType = toApiProjectType(filter.value)
|
||||
const count =
|
||||
filter.value === 'All project types'
|
||||
? items.length
|
||||
: items.filter((item) => apiType && item.project.project_types.includes(apiType)).length
|
||||
|
||||
return { value: filter.value, label: `${filter.name} (${formatNumber(count)})` }
|
||||
})
|
||||
})
|
||||
|
||||
const techReviewQueryKey = computed(
|
||||
() =>
|
||||
@@ -275,13 +273,11 @@ const searchResults = computed(() => {
|
||||
return fuse.value.search(query.value).map((result) => result.item)
|
||||
})
|
||||
|
||||
const baseFiltered = computed(() => {
|
||||
const filteredItems = computed(() => {
|
||||
if (!reviewItems.value) return []
|
||||
return query.value && searchResults.value ? searchResults.value : [...reviewItems.value]
|
||||
})
|
||||
|
||||
const filteredItems = computed(() => baseFiltered.value)
|
||||
|
||||
const filteredIssuesCount = computed(() => {
|
||||
return filteredItems.value.reduce((total, review) => {
|
||||
return total + review.reports.reduce((sum, report) => sum + report.issues.length, 0)
|
||||
@@ -295,6 +291,15 @@ const paginatedItems = computed(() => {
|
||||
const end = start + UI_PAGE_SIZE
|
||||
return filteredItems.value.slice(start, end)
|
||||
})
|
||||
const pageStart = computed(() =>
|
||||
filteredItems.value.length === 0 ? 0 : (currentPage.value - 1) * UI_PAGE_SIZE + 1,
|
||||
)
|
||||
const pageEnd = computed(() =>
|
||||
Math.min(
|
||||
(currentPage.value - 1) * UI_PAGE_SIZE + paginatedItems.value.length,
|
||||
filteredItems.value.length,
|
||||
),
|
||||
)
|
||||
function goToPage(page: number, top = false) {
|
||||
currentPage.value = page
|
||||
|
||||
@@ -388,7 +393,7 @@ const {
|
||||
})
|
||||
},
|
||||
getNextPageParam: (lastPage, allPages) => {
|
||||
// If we got a full page, there's probably more
|
||||
// full page = maybe more
|
||||
return lastPage.project_reports.length >= API_PAGE_SIZE ? allPages.length : undefined
|
||||
},
|
||||
initialPageParam: 0,
|
||||
@@ -423,11 +428,6 @@ const mergedSearchResponse = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
type FlattenedFileReport = Labrinth.TechReview.Internal.FileReport & {
|
||||
id: string
|
||||
version_id: string
|
||||
}
|
||||
|
||||
const reviewItems = computed(() => {
|
||||
if (!mergedSearchResponse.value?.project_reports?.length) {
|
||||
return []
|
||||
@@ -435,47 +435,29 @@ const reviewItems = computed(() => {
|
||||
|
||||
const response = mergedSearchResponse.value
|
||||
|
||||
return response.project_reports
|
||||
.map((projectReport) => {
|
||||
const project = response.projects[projectReport.project_id]
|
||||
const thread = project?.thread_id ? response.threads[project.thread_id] : undefined
|
||||
return response.project_reports.flatMap((projectReport) => {
|
||||
const project = response.projects[projectReport.project_id]
|
||||
const thread = project?.thread_id ? response.threads[project.thread_id] : undefined
|
||||
if (!thread) return []
|
||||
|
||||
if (!thread) return null
|
||||
|
||||
const reports: FlattenedFileReport[] = projectReport.versions.flatMap((version) =>
|
||||
version.files.map((file) => ({
|
||||
...file,
|
||||
id: file.report_id,
|
||||
version_id: version.version_id,
|
||||
})),
|
||||
)
|
||||
|
||||
return {
|
||||
return [
|
||||
{
|
||||
project,
|
||||
project_owner: response.ownership[projectReport.project_id],
|
||||
thread,
|
||||
reports,
|
||||
}
|
||||
})
|
||||
.filter(
|
||||
(
|
||||
item,
|
||||
): item is {
|
||||
project: Labrinth.TechReview.Internal.ProjectModerationInfo
|
||||
project_owner: Labrinth.TechReview.Internal.Ownership
|
||||
thread: Labrinth.TechReview.Internal.Thread
|
||||
reports: FlattenedFileReport[]
|
||||
} => item !== null,
|
||||
)
|
||||
reports: flattenFileReports(projectReport.versions),
|
||||
},
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
function handleMarkComplete(projectId: string) {
|
||||
// Find the index of the current card before removing it
|
||||
const currentIndex = paginatedItems.value.findIndex((item) => item.project.id === projectId)
|
||||
const { loadingIssues, decompiledSources, handleLoadIssueSources } = useTechReviewSources(() =>
|
||||
reviewItems.value.flatMap((review) => review.reports.flatMap((report) => report.issues)),
|
||||
)
|
||||
|
||||
// Find the thread ID for this project so we can remove it from the threads cache
|
||||
const projectData = reviewItems.value.find((item) => item.project.id === projectId)
|
||||
const threadId = projectData?.thread?.id
|
||||
function handleMarkComplete(projectId: string) {
|
||||
const currentIndex = paginatedItems.value.findIndex((item) => item.project.id === projectId)
|
||||
const threadId = reviewItems.value.find((item) => item.project.id === projectId)?.thread?.id
|
||||
|
||||
queryClient.setQueryData(
|
||||
techReviewQueryKey.value,
|
||||
@@ -493,7 +475,7 @@ function handleMarkComplete(projectId: string) {
|
||||
...oldData,
|
||||
pages: oldData.pages.map((page) => ({
|
||||
...page,
|
||||
// Keep the raw page length stable; getNextPageParam uses it to know if more API pages exist.
|
||||
// leave this as-is so getNextPageParam still sees a full page
|
||||
project_reports: page.project_reports,
|
||||
projects: Object.fromEntries(
|
||||
Object.entries(page.projects).filter(([id]) => id !== projectId),
|
||||
@@ -509,25 +491,18 @@ function handleMarkComplete(projectId: string) {
|
||||
},
|
||||
)
|
||||
|
||||
// Also invalidate the query to ensure consistency with server state
|
||||
// This triggers a background refetch after the optimistic update
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['tech-reviews'],
|
||||
refetchType: 'none', // Don't refetch immediately, just mark as stale
|
||||
refetchType: 'none',
|
||||
})
|
||||
|
||||
// Scroll to the next card after Vue updates the DOM
|
||||
nextTick(() => {
|
||||
// Get the project ID at the same position (next project after removal)
|
||||
const nextItem = paginatedItems.value[currentIndex]
|
||||
if (nextItem) {
|
||||
const nextCard = cardRefs.get(nextItem.project.id)
|
||||
if (nextCard) {
|
||||
nextCard.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
})
|
||||
}
|
||||
cardRefs.get(nextItem.project.id)?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -550,13 +525,16 @@ watch(
|
||||
currentProjectTypeFilter,
|
||||
],
|
||||
() => {
|
||||
if (syncingFromRoute) return
|
||||
goToPage(1)
|
||||
},
|
||||
)
|
||||
|
||||
watch(totalPages, (pages) => {
|
||||
if (isLoading.value) return
|
||||
|
||||
if (pages === 0) {
|
||||
goToPage(1)
|
||||
if (currentPage.value !== 1) goToPage(1)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -626,32 +604,14 @@ onUnmounted(() => {
|
||||
:progress="batchScanProgressInformation"
|
||||
/> -->
|
||||
|
||||
<div class="flex flex-col justify-between gap-2 lg:flex-row">
|
||||
<Input
|
||||
v-model="query"
|
||||
:icon="SearchIcon"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
:placeholder="formatMessage(commonMessages.searchPlaceholder)"
|
||||
clearable
|
||||
wrapper-class="flex-1 lg:max-w-52"
|
||||
input-class="!h-10"
|
||||
@input="goToPage(1)"
|
||||
/>
|
||||
|
||||
<div v-if="totalPages > 1" class="hidden flex-1 justify-center lg:flex">
|
||||
<LoaderCircleIcon
|
||||
v-if="isFetchingNextPage"
|
||||
v-tooltip="`Pages are still being fetched...`"
|
||||
aria-hidden="true"
|
||||
class="my-auto mr-2 size-6 animate-spin text-green"
|
||||
/>
|
||||
<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"
|
||||
>
|
||||
<ModerationQueueToolbar
|
||||
v-model="query"
|
||||
:page="currentPage"
|
||||
:total-pages="totalPages"
|
||||
@search="goToPage(1)"
|
||||
@switch-page="goToPage"
|
||||
>
|
||||
<template #actions>
|
||||
<Combobox
|
||||
v-model="currentResponseFilter"
|
||||
class="!w-full flex-grow sm:!w-[120px] sm:flex-grow-0"
|
||||
@@ -695,16 +655,19 @@ onUnmounted(() => {
|
||||
<template #panel>
|
||||
<div class="flex min-w-64 flex-col gap-3">
|
||||
<label class="flex cursor-pointer items-center justify-between gap-2 text-sm">
|
||||
<span class="whitespace-nowrap font-semibold">In project queue</span>
|
||||
<span class="whitespace-nowrap font-semibold">Only under review</span>
|
||||
<Toggle v-model="inOtherQueueFilter" />
|
||||
</label>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm font-semibold text-secondary"
|
||||
>Flag type ({{ filteredIssuesCount }})</span
|
||||
>
|
||||
<span class="flex items-center gap-1.5 text-sm font-semibold text-secondary">
|
||||
Flag type
|
||||
<SpinnerIcon v-if="isLoading" class="size-3.5 animate-spin" aria-hidden="true" />
|
||||
<template v-else>({{ formatNumber(filteredIssuesCount) }})</template>
|
||||
</span>
|
||||
<Combobox
|
||||
v-model="currentFilterType"
|
||||
class="!w-full"
|
||||
dropdown-class="!z-[10000]"
|
||||
:options="filterTypes"
|
||||
:placeholder="formatMessage(commonMessages.filterByLabel)"
|
||||
searchable
|
||||
@@ -722,6 +685,7 @@ onUnmounted(() => {
|
||||
<Combobox
|
||||
v-model="currentProjectTypeFilter"
|
||||
class="!w-full"
|
||||
dropdown-class="!z-[10000]"
|
||||
:options="projectTypeFilterTypes"
|
||||
:placeholder="formatMessage(commonMessages.filterByLabel)"
|
||||
searchable
|
||||
@@ -737,23 +701,29 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</template>
|
||||
</TeleportPopoutMenu>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #meta>
|
||||
<div v-if="filteredItems.length > 0" class="flex items-center gap-2">
|
||||
<SpinnerIcon
|
||||
v-if="isFetchingNextPage"
|
||||
v-tooltip="`Pages are still being fetched...`"
|
||||
aria-hidden="true"
|
||||
class="size-4 animate-spin"
|
||||
/>
|
||||
Showing {{ formatNumber(pageStart) }}–{{ formatNumber(pageEnd) }} of
|
||||
{{ formatNumber(filteredItems.length) }} projects
|
||||
</div>
|
||||
</template>
|
||||
</ModerationQueueToolbar>
|
||||
|
||||
<div v-if="totalPages > 1" class="flex justify-center lg:hidden">
|
||||
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
|
||||
<ModerationQueueSkeleton v-if="isLoading" />
|
||||
<div
|
||||
v-else-if="paginatedItems.length === 0"
|
||||
class="universal-card flex h-24 items-center justify-center text-secondary"
|
||||
>
|
||||
No projects in queue.
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div v-if="isLoading" class="flex flex-col gap-4">
|
||||
<div v-for="i in UI_PAGE_SIZE" :key="i" class="universal-card h-48 animate-pulse"></div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="paginatedItems.length === 0"
|
||||
class="universal-card flex h-24 items-center justify-center text-secondary"
|
||||
>
|
||||
No projects in queue.
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-4">
|
||||
<div
|
||||
v-for="item in paginatedItems"
|
||||
:key="item.project.id"
|
||||
@@ -780,7 +750,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="totalPages > 1" class="mt-4 flex justify-center">
|
||||
<div v-if="totalPages > 1" class="flex justify-end">
|
||||
<Pagination
|
||||
:page="currentPage"
|
||||
:count="totalPages"
|
||||
|
||||
@@ -396,6 +396,8 @@ pub struct ProjectReport {
|
||||
pub struct VersionReport {
|
||||
/// ID of the project version this report is for.
|
||||
pub version_id: VersionId,
|
||||
/// Version number of the project version this report is for.
|
||||
pub version_number: String,
|
||||
/// Reports for this version's files.
|
||||
#[serde(default)]
|
||||
pub files: Vec<FileReport>,
|
||||
@@ -663,6 +665,7 @@ async fn fetch_project_reports(
|
||||
|
||||
version_reports.push(VersionReport {
|
||||
version_id: VersionId::from(version_query.inner.id),
|
||||
version_number: version_query.inner.version_number.clone(),
|
||||
files: file_reports,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user