polish(frontend): technical review QA (#5097)

* feat: filtering + sorting alignment

* polish: malicious summary modal changes

* feat: better filter row using floating panel

* fix: re-enable request

* fix: lint

* polish: jump back to files tab qol

* feat: scroll to top of next card when done

* fix: show lock icon on preview msg

* feat: download no _blank

* feat: show also marked in notif

* feat: auto expand if only one class in the file

* feat: proper page titles

* fix: text-contrast typo

* fix: lint

* feat: QA changes

* feat: individual report page + more qa

* fix: back btn

* fix: broken import

* feat: quick reply msgs

* fix: in other queue filter

* fix: caching threads wrongly

* fix: flag filter

* feat: toggle enabled by default

* fix: dont make btns opacity 50

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
Calum H.
2026-01-20 19:56:24 +00:00
committed by GitHub
parent 2af6a1b36f
commit a869086ce9
20 changed files with 1046 additions and 83 deletions

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { ClipboardCopyIcon, LoaderCircleIcon, XIcon } from '@modrinth/assets'
import { ClipboardCopyIcon, DownloadIcon, LoaderCircleIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, CopyCode, NewModal } from '@modrinth/ui'
import { ref, useTemplateRef } from 'vue'
@@ -38,15 +38,16 @@ async function fetchVersionHashes(versionIds: string[]) {
// TODO: switch to api-client once truman's vers stuff is merged
const version = (await useBaseFetch(`version/${versionId}`)) as {
files: Array<{
id?: string
filename: string
file_name?: string
hashes: { sha512: string; sha1: string }
}>
}
const filesMap = new Map<string, string>()
for (const file of version.files) {
const name = file.file_name ?? file.filename
filesMap.set(name, file.hashes.sha512)
if (file.id) {
filesMap.set(file.id, file.hashes.sha512)
}
}
versionDataCache.value.set(versionId, { files: filesMap, loading: false })
} catch (error) {
@@ -60,8 +61,8 @@ async function fetchVersionHashes(versionIds: string[]) {
}
}
function getFileHash(versionId: string, fileName: string): string | undefined {
return versionDataCache.value.get(versionId)?.files.get(fileName)
function getFileHash(versionId: string, fileId: string): string | undefined {
return versionDataCache.value.get(versionId)?.files.get(fileId)
}
function isHashLoading(versionId: string): boolean {
@@ -114,6 +115,7 @@ defineExpose({ show, hide })
<th class="pb-2">Version ID</th>
<th class="pb-2">File Name</th>
<th class="pb-2">CDN Link</th>
<th class="pb-2">Download</th>
</tr>
</thead>
<tbody>
@@ -124,11 +126,11 @@ defineExpose({ show, hide })
class="size-4 animate-spin text-secondary"
/>
<ButtonStyled
v-else-if="getFileHash(item.file.version_id, item.file.file_name)"
v-else-if="getFileHash(item.file.version_id, item.file.file_id)"
size="small"
type="standard"
>
<button @click="copy(getFileHash(item.file.version_id, item.file.file_name)!)">
<button @click="copy(getFileHash(item.file.version_id, item.file.file_id)!)">
<ClipboardCopyIcon class="size-4" />
Copy
</button>
@@ -141,7 +143,7 @@ defineExpose({ show, hide })
<td class="py-1 pr-2">
<CopyCode :text="item.file.file_name" />
</td>
<td class="py-1">
<td class="py-1 pr-2">
<ButtonStyled size="small" type="standard">
<button @click="copy(item.file.download_url)">
<ClipboardCopyIcon class="size-4" />
@@ -149,6 +151,13 @@ defineExpose({ show, hide })
</button>
</ButtonStyled>
</td>
<td class="py-1">
<ButtonStyled circular size="small">
<a :href="item.file.download_url" :download="item.file.file_name" target="_blank">
<DownloadIcon />
</a>
</ButtonStyled>
</td>
</tr>
</tbody>
</table>

View File

@@ -12,6 +12,7 @@ import {
LinkIcon,
LoaderCircleIcon,
ShieldCheckIcon,
TimerIcon,
} from '@modrinth/assets'
import { type TechReviewContext, techReviewQuickReplies } from '@modrinth/moderation'
import {
@@ -113,8 +114,8 @@ const quickActions = computed<OverflowMenuOption[]>(() => {
navigator.clipboard.writeText(props.item.project.id).then(() => {
addNotification({
type: 'success',
title: 'Technical Report ID copied',
text: 'The ID of this report has been copied to your clipboard.',
title: 'Project ID copied',
text: 'The ID of this project has been copied to your clipboard.',
})
})
},
@@ -265,6 +266,13 @@ const severityColor = computed(() => {
}
})
const isProjectApproved = computed(() => {
const status = props.item.project.status
return (
status === 'approved' || status === 'archived' || status === 'unlisted' || status === 'private'
)
})
const formattedDate = computed(() => {
const dates = props.item.reports.map((r) => new Date(r.created))
const earliest = new Date(Math.min(...dates.map((d) => d.getTime())))
@@ -369,12 +377,16 @@ async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe')
if (detailKey) break
}
let otherMatchedCount = 0
if (detailKey) {
for (const report of props.item.reports) {
for (const issue of report.issues) {
for (const detail of issue.details) {
if (detail.key === detailKey) {
detailDecisions.value.set(detail.id, decision)
if (detail.id !== detailId) {
otherMatchedCount++
}
}
}
}
@@ -391,17 +403,31 @@ async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe')
}
}
// Jump back to Files tab when all flags in the current file are marked
if (selectedFile.value) {
const markedCount = getFileMarkedCount(selectedFile.value)
const totalCount = getFileDetailCount(selectedFile.value)
if (markedCount === totalCount) {
backToFileList()
}
}
const otherText =
otherMatchedCount > 0
? ` (${otherMatchedCount} other trace${otherMatchedCount === 1 ? '' : 's'} also marked)`
: ''
if (verdict === 'safe') {
addNotification({
type: 'success',
title: 'Issue marked as pass',
text: 'This issue has been marked as a false positive.',
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.',
text: `This issue has been flagged as malicious.${otherText}`,
})
}
} catch (error) {
@@ -472,6 +498,17 @@ const groupedByClass = computed<ClassGroup[]>(() => {
})
})
// Auto-expand if there's only one class in the file
watch(
groupedByClass,
(classes) => {
if (classes.length === 1) {
expandedClasses.value.add(classes[0].filePath)
}
},
{ immediate: true },
)
function getHighestSeverityInClass(
flags: ClassGroup['flags'],
): Labrinth.TechReview.Internal.DelphiSeverity {
@@ -623,7 +660,7 @@ const threadWithPreview = computed(() => {
body: {
type: 'text',
body: reviewSummaryPreview.value,
private: false,
private: true,
replying_to: null,
associated_images: [],
},
@@ -747,9 +784,21 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
</div>
<div
class="rounded-full border border-solid border-surface-5 bg-surface-4 px-2.5 py-1"
class="flex items-center gap-1 rounded-full border border-solid px-2.5 py-1"
:class="
isProjectApproved
? 'border-green bg-highlight-green'
: 'border-orange bg-highlight-orange'
"
>
<span class="text-sm font-medium text-secondary">Auto-Flagged</span>
<CheckIcon v-if="isProjectApproved" aria-hidden="true" class="h-4 w-4 text-green" />
<TimerIcon v-else aria-hidden="true" class="h-4 w-4 text-orange" />
<span
class="text-sm font-medium"
:class="isProjectApproved ? 'text-green' : 'text-orange'"
>
{{ isProjectApproved ? 'Live' : 'In review' }}
</span>
</div>
<div class="rounded-full px-2.5 py-1" :class="severityColor">
@@ -929,8 +978,6 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
:href="file.download_url"
:title="`Download ${file.file_name}`"
:download="file.file_name"
target="_blank"
rel="noopener noreferrer"
class="!border-px !border-surface-4"
tabindex="0"
>
@@ -1008,15 +1055,21 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
v-for="flag in classItem.flags"
:key="`${flag.issueId}-${flag.detail.id}`"
class="grid grid-cols-[1fr_auto_auto] items-center rounded-lg border-[1px] border-b border-solid border-surface-5 bg-surface-3 py-2 pl-4 last:border-b-0"
:class="{
'opacity-50': isPreReviewed(flag.detail.id, flag.detail.status),
}"
>
<span class="text-base font-semibold text-contrast">{{
flag.issueType.replace(/_/g, ' ')
}}</span>
<span
class="text-base font-semibold text-contrast"
:class="{
'opacity-50': isPreReviewed(flag.detail.id, flag.detail.status),
}"
>{{ flag.issueType.replace(/_/g, ' ') }}</span
>
<div class="flex w-20 justify-center">
<div
class="flex w-20 justify-center"
:class="{
'opacity-50': isPreReviewed(flag.detail.id, flag.detail.status),
}"
>
<div
class="rounded-full border-solid px-2.5 py-1"
:class="getSeverityBadgeColor(flag.detail.severity)"