+
+
+
+
+
+
{{ item.project.name }}
@@ -1478,28 +249,28 @@ function copyId() {
-
+
{{
capitalizeString(highestSeverity.toLowerCase())
}}
-
-
+
+
{{ item.project_owner.name }}
-
({{ item.project_owner.id }})
+
@@ -1516,767 +287,101 @@ function copyId() {
>
-
-
-
+
+
-
-
-
-
+
-
+
+ {{ getVersionLabel(selectedFile) }}
- Open
+ Slicer
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
- Set Status
-
-
- Approve
-
-
-
- Withhold
-
-
-
- Send to review
-
-
-
- Reject
-
-
-
-
-
-
-
-
-
-
-
-
-
0 && viewFileFlags(file)"
- >
- {{ truncateMiddle(file.file_name, 50) }}
-
-
- {{
- formatBytes(file.file_size)
- }}
-
-
- {{
- capitalizeString(getFileHighestSeverity(file))
- }}
-
-
-
- {{ getFileMarkedCount(file) }}/{{ getFileDetailCount(file) }} flags
-
-
-
- Manual review
-
-
- No flags
-
-
-
-
-
-
-
-
-
- Open
-
-
-
-
-
-
-
-
- Remaining issues ({{ remainingUnmarkedCount }})
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ segment }}
-
-
-
-
-
-
-
- Remaining issues ({{ getJarRemainingUnmarkedCount(jarGroup) }})
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
{{
- truncateMiddle(classItem.filePath)
- }}
-
-
- {{
- capitalizeString(getHighestSeverityInClass(classItem.flags))
- }}
-
-
-
-
- {{ getMarkedFlagsCount(classItem.flags) }}/{{ classItem.flags.length }} flags
-
-
-
-
-
-
- Loading source...
-
-
-
-
-
-
-
-
-
-
-
-
{{
- flag.issueType.replace(/_/g, ' ')
- }}
-
- {{
- capitalizeString(flag.detail.severity)
- }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Loading source...
-
-
-
-
- Source code not available or failed to decompile for this file.
-
-
-
-
-
-
-
+
+
+
+
-
-
diff --git a/apps/frontend/src/components/ui/moderation/tech-review/TechRevFileDetailTab.vue b/apps/frontend/src/components/ui/moderation/tech-review/TechRevFileDetailTab.vue
new file mode 100644
index 0000000000..7c4d154f51
--- /dev/null
+++ b/apps/frontend/src/components/ui/moderation/tech-review/TechRevFileDetailTab.vue
@@ -0,0 +1,824 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ segment }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{
+ truncateMiddle(classItem.filePath)
+ }}
+
+
+ {{
+ capitalizeString(getHighestSeverityInClass(classItem.flags))
+ }}
+
+
+
+
+ {{ getMarkedFlagsCount(classItem.flags) }}/{{ classItem.flags.length }} flags
+
+
+
+
+
+
+ Loading source...
+
+
+
+
+
+
+
+
+
+
+
+
{{
+ flag.issueType.replace(/_/g, ' ')
+ }}
+
+ {{
+ capitalizeString(flag.detail.severity)
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Loading source...
+
+
+
+
+ Source code not available or failed to decompile for this file.
+
+
+
+
+
+
+
+
+
diff --git a/apps/frontend/src/components/ui/moderation/tech-review/TechRevFilesTab.vue b/apps/frontend/src/components/ui/moderation/tech-review/TechRevFilesTab.vue
new file mode 100644
index 0000000000..96fccf3b42
--- /dev/null
+++ b/apps/frontend/src/components/ui/moderation/tech-review/TechRevFilesTab.vue
@@ -0,0 +1,133 @@
+
+
+
+
+
+
0 && emit('viewFlags', file)"
+ >
+ {{ truncateMiddle(file.file_name, 50) }}
+
+
+ {{ formatBytes(file.file_size) }}
+
+
+ {{
+ capitalizeString(getFileHighestSeverity(file))
+ }}
+
+
+
+ {{ getFileMarkedCount(file) }}/{{ getFileDetailCount(file) }} flags
+
+
+
+ Manual review
+
+
+ No flags
+
+
+
+
+
+ {{ getVersionLabel(file) }}
+
+
+ Slicer
+
+
+
+
+
+
+
diff --git a/apps/frontend/src/components/ui/moderation/tech-review/TechRevThreadTab.vue b/apps/frontend/src/components/ui/moderation/tech-review/TechRevThreadTab.vue
new file mode 100644
index 0000000000..fe473219d5
--- /dev/null
+++ b/apps/frontend/src/components/ui/moderation/tech-review/TechRevThreadTab.vue
@@ -0,0 +1,416 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Set status
+
+
+
+
+
+
+
+
diff --git a/apps/frontend/src/components/ui/moderation/tech-review/TechRevVerdictButtons.vue b/apps/frontend/src/components/ui/moderation/tech-review/TechRevVerdictButtons.vue
new file mode 100644
index 0000000000..c6df2d508a
--- /dev/null
+++ b/apps/frontend/src/components/ui/moderation/tech-review/TechRevVerdictButtons.vue
@@ -0,0 +1,155 @@
+
+
+
+
+ {{ remainingLabel }}
+
+
+
+
+
+
diff --git a/apps/frontend/src/components/ui/moderation/tech-review/helpers.ts b/apps/frontend/src/components/ui/moderation/tech-review/helpers.ts
new file mode 100644
index 0000000000..00d2809bd0
--- /dev/null
+++ b/apps/frontend/src/components/ui/moderation/tech-review/helpers.ts
@@ -0,0 +1,104 @@
+import type { Labrinth } from '@modrinth/api-client'
+
+import type { DetailDecision, FlattenedFileReport } from './types'
+
+export const severityOrder: Record
= {
+ 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,
+): 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(') {
+ const client = injectModrinthClient()
+
+ const detailDecisions = reactive