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)"

View File

@@ -124,6 +124,8 @@ import ModerationQueueCard from '~/components/ui/moderation/ModerationQueueCard.
import { enrichProjectBatch, type ModerationProject } from '~/helpers/moderation.ts'
import { useModerationStore } from '~/store/moderation.ts'
useHead({ title: 'Projects queue - Modrinth' })
const { formatMessage } = useVIntl()
const { addNotification } = injectNotificationManager()
const moderationStore = useModerationStore()

View File

@@ -92,6 +92,8 @@ import Fuse from 'fuse.js'
import ReportCard from '~/components/ui/moderation/ModerationReportCard.vue'
import { enrichReportBatch } from '~/helpers/moderation.ts'
useHead({ title: 'Reports queue - Modrinth' })
const { formatMessage } = useVIntl()
const route = useRoute()
const router = useRouter()

View File

@@ -0,0 +1,304 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { ArrowLeftIcon, LoaderCircleIcon } from '@modrinth/assets'
import { ButtonStyled, 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'
const route = useRoute()
const router = useRouter()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const projectId = computed(() => route.params.id as string)
useHead({ title: () => `Tech review - ${projectId.value} - 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 = ref<Set<string>>(new Set())
const decompiledSources = ref<Map<string, string>>(new Map())
async function loadIssueSource(issueId: string): Promise<void> {
if (loadingIssues.value.has(issueId)) return
loadingIssues.value.add(issueId)
try {
const issueData = await client.labrinth.tech_review_internal.getIssue(issueId)
for (const detail of issueData.details) {
if (detail.decompiled_source) {
decompiledSources.value.set(detail.id, detail.decompiled_source)
setCachedSource(detail.id, detail.decompiled_source)
}
}
} catch (error) {
console.error('Failed to load issue source:', error)
} finally {
loadingIssues.value.delete(issueId)
}
}
function tryLoadCachedSourcesForFile(reportId: string): void {
if (!reviewItem.value) return
const report = reviewItem.value.reports.find((r) => r.id === reportId)
if (report) {
for (const issue of report.issues) {
for (const detail of issue.details) {
if (!decompiledSources.value.has(detail.id)) {
const cached = getCachedSource(detail.id)
if (cached) {
decompiledSources.value.set(detail.id, cached)
}
}
}
}
}
}
function handleLoadFileSources(reportId: string): void {
tryLoadCachedSourcesForFile(reportId)
if (!reviewItem.value) return
const report = reviewItem.value.reports.find((r) => r.id === reportId)
if (report) {
for (const issue of report.issues) {
const hasUncached = issue.details.some((d) => !decompiledSources.value.has(d.id))
if (hasUncached) {
loadIssueSource(issue.id)
}
}
}
}
const {
data: projectReportData,
isLoading: isLoadingReport,
isError: isReportError,
} = useQuery({
queryKey: ['tech-review-project-report', projectId],
queryFn: async () => {
return await client.labrinth.tech_review_internal.getProjectReport(projectId.value)
},
retry: false,
})
const {
data: projectData,
isLoading: isLoadingProject,
isError: isProjectError,
} = useQuery({
queryKey: ['project', projectId],
queryFn: async () => {
return await client.labrinth.projects_v3.get(projectId.value)
},
retry: false,
})
const { data: organizationData, isLoading: isLoadingOrg } = useQuery({
queryKey: ['project-organization', projectId],
queryFn: async () => {
return await client.labrinth.projects_v3.getOrganization(projectId.value)
},
})
const { data: membersData, isLoading: isLoadingMembers } = useQuery({
queryKey: ['project-members', projectId],
queryFn: async () => {
return await client.labrinth.projects_v3.getMembers(projectId.value)
},
enabled: computed(() => !organizationData.value && !isLoadingOrg.value),
})
const isLoading = computed(
() =>
isLoadingReport.value ||
isLoadingProject.value ||
isLoadingOrg.value ||
(isLoadingMembers.value && !organizationData.value),
)
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 {
kind: 'organization',
id: organizationData.value.id,
name: organizationData.value.name,
icon_url: organizationData.value.icon_url ?? undefined,
}
}
if (membersData.value) {
const owner = membersData.value.find((m) => m.is_owner)
if (owner) {
return {
kind: 'user',
id: owner.user.id,
name: owner.user.username,
icon_url: owner.user.avatar_url ?? undefined,
}
}
}
return null
})
const reviewItem = computed(() => {
if (!projectReportData.value || !projectData.value || !ownership.value) return null
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,
})),
)
: []
return {
project: projectData.value,
project_owner: ownership.value,
thread,
reports,
}
})
function handleMarkComplete(_projectId: string) {
queryClient.invalidateQueries({ queryKey: ['tech-reviews'] })
router.push('/moderation/technical-review')
}
const maliciousSummaryModalRef = ref<InstanceType<typeof MaliciousSummaryModal>>()
const currentUnsafeFiles = ref<UnsafeFile[]>([])
function handleShowMaliciousSummary(unsafeFiles: UnsafeFile[]) {
currentUnsafeFiles.value = unsafeFiles
maliciousSummaryModalRef.value?.show()
}
function refetch() {
queryClient.invalidateQueries({ queryKey: ['tech-review-project-report', projectId.value] })
}
</script>
<template>
<div class="flex flex-col gap-4">
<div>
<ButtonStyled
><nuxt-link :to="'/moderation/technical-review'">
<ArrowLeftIcon class="size-5" />
Back to queue
</nuxt-link></ButtonStyled
>
</div>
<div v-if="isLoading" class="flex flex-col gap-4">
<div class="universal-card flex h-48 items-center justify-center">
<LoaderCircleIcon class="size-8 animate-spin text-secondary" />
</div>
</div>
<div
v-else-if="hasError"
class="universal-card flex h-24 items-center justify-center text-secondary"
>
Project not found in the tech review queue.
</div>
<div
v-else-if="!reviewItem"
class="universal-card flex h-24 items-center justify-center text-secondary"
>
No review data available for this project.
</div>
<ModerationTechRevCard
v-else
:item="reviewItem"
:loading-issues="loadingIssues"
:decompiled-sources="decompiledSources"
@refetch="refetch"
@load-file-sources="handleLoadFileSources"
@mark-complete="handleMarkComplete"
@show-malicious-summary="handleShowMaliciousSummary"
/>
<MaliciousSummaryModal ref="maliciousSummaryModalRef" :unsafe-files="currentUnsafeFiles" />
</div>
</template>

View File

@@ -1,23 +1,36 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { ListFilterIcon, SearchIcon, SortAscIcon, SortDescIcon, XIcon } from '@modrinth/assets'
import {
BlendIcon,
ListFilterIcon,
LoaderCircleIcon,
SearchIcon,
SortAscIcon,
SortDescIcon,
XIcon,
} from '@modrinth/assets'
import {
Button,
Combobox,
type ComboboxOption,
defineMessages,
FloatingPanel,
injectModrinthClient,
Pagination,
Toggle,
useVIntl,
} from '@modrinth/ui'
import { useInfiniteQuery, useQueryClient } from '@tanstack/vue-query'
import Fuse from 'fuse.js'
import { nextTick } from 'vue'
import MaliciousSummaryModal, {
type UnsafeFile,
} from '~/components/ui/moderation/MaliciousSummaryModal.vue'
import ModerationTechRevCard from '~/components/ui/moderation/ModerationTechRevCard.vue'
useHead({ title: 'Tech review queue - Modrinth' })
const client = injectModrinthClient()
const queryClient = useQueryClient()
@@ -196,10 +209,10 @@ watch(
},
)
const currentFilterType = ref('All issues')
const currentFilterType = ref('All flags')
const filterTypes = computed<ComboboxOption<string>[]>(() => {
const base: ComboboxOption<string>[] = [{ value: 'All issues', label: 'All issues' }]
const base: ComboboxOption<string>[] = [{ value: 'All flags', label: 'All flags' }]
if (!reviewItems.value) return base
const issueTypes = new Set(
@@ -213,14 +226,23 @@ const filterTypes = computed<ComboboxOption<string>[]>(() => {
return [...base, ...sortedTypes.map((type) => ({ value: type, label: type }))]
})
const currentSortType = ref('Severe first')
const currentSortType = ref('Severity highest')
const sortTypes: ComboboxOption<string>[] = [
{ value: 'Severity highest', label: 'Severity highest' },
{ value: 'Severity lowest', label: 'Severity lowest' },
{ value: 'Oldest', label: 'Oldest' },
{ value: 'Newest', label: 'Newest' },
{ value: 'Severe first', label: 'Severe first' },
{ value: 'Severe last', label: 'Severe last' },
]
const currentResponseFilter = ref('All')
const responseFilterTypes: ComboboxOption<string>[] = [
{ value: 'All', label: 'All' },
{ value: 'Unread', label: 'Unread' },
{ value: 'Read', label: 'Read' },
]
const inOtherQueueFilter = ref(true)
const fuse = computed(() => {
if (!reviewItems.value || reviewItems.value.length === 0) return null
return new Fuse(reviewItems.value, {
@@ -246,36 +268,11 @@ const baseFiltered = computed(() => {
return query.value && searchResults.value ? searchResults.value : [...reviewItems.value]
})
const typeFiltered = computed(() => {
if (currentFilterType.value === 'All issues') return baseFiltered.value
const type = currentFilterType.value
return baseFiltered.value.filter((review) => {
return review.reports.some((report: Labrinth.TechReview.Internal.FileReport) =>
report.issues.some(
(issue: Labrinth.TechReview.Internal.FileIssue) => issue.issue_type === type,
),
)
})
})
const filteredItems = computed(() => typeFiltered.value)
const filteredItems = computed(() => baseFiltered.value)
const filteredIssuesCount = computed(() => {
return filteredItems.value.reduce((total, review) => {
if (currentFilterType.value === 'All issues') {
return total + review.reports.reduce((sum, report) => sum + report.issues.length, 0)
} else {
return (
total +
review.reports.reduce((sum, report) => {
return (
sum +
report.issues.filter((issue) => issue.issue_type === currentFilterType.value).length
)
}, 0)
)
}
return total + review.reports.reduce((sum, report) => sum + report.issues.length, 0)
}, 0)
})
@@ -304,9 +301,9 @@ function toApiSort(label: string): Labrinth.TechReview.Internal.SearchProjectsSo
return 'created_asc'
case 'Newest':
return 'created_desc'
case 'Severe first':
case 'Severity highest':
return 'severity_desc'
case 'Severe last':
case 'Severity lowest':
return 'severity_asc'
default:
return 'severity_desc'
@@ -321,12 +318,41 @@ const {
hasNextPage,
refetch,
} = useInfiniteQuery({
queryKey: ['tech-reviews', currentSortType],
enabled: true,
queryKey: [
'tech-reviews',
currentSortType,
currentResponseFilter,
inOtherQueueFilter,
currentFilterType,
],
queryFn: async ({ pageParam = 0 }) => {
const filter: Labrinth.TechReview.Internal.SearchProjectsFilter = {
project_type: [],
replied_to: undefined,
project_status: [],
issue_type: [],
}
if (currentResponseFilter.value === 'Unread') {
filter.replied_to = 'unreplied'
} else if (currentResponseFilter.value === 'Read') {
filter.replied_to = 'replied'
}
if (inOtherQueueFilter.value) {
filter.project_status = ['processing']
}
if (currentFilterType.value !== 'All flags') {
filter.issue_type = [currentFilterType.value]
}
return await client.labrinth.tech_review_internal.searchProjects({
limit: API_PAGE_SIZE,
page: pageParam,
sort_by: toApiSort(currentSortType.value),
filter,
})
},
getNextPageParam: (lastPage, allPages) => {
@@ -412,8 +438,15 @@ const reviewItems = computed(() => {
})
function handleMarkComplete(projectId: string) {
// Find the index of the current card before removing it
const currentIndex = paginatedItems.value.findIndex((item) => item.project.id === projectId)
// 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
queryClient.setQueryData(
['tech-reviews', currentSortType],
['tech-reviews', currentSortType, currentResponseFilter, inOtherQueueFilter, currentFilterType],
(
oldData:
| {
@@ -432,6 +465,9 @@ function handleMarkComplete(projectId: string) {
projects: Object.fromEntries(
Object.entries(page.projects).filter(([id]) => id !== projectId),
),
threads: Object.fromEntries(
Object.entries(page.threads).filter(([id]) => id !== threadId),
),
ownership: Object.fromEntries(
Object.entries(page.ownership).filter(([id]) => id !== projectId),
),
@@ -439,17 +475,36 @@ 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
})
// Scroll to the next card after Vue updates the DOM
nextTick(() => {
const targetIndex = currentIndex
if (targetIndex >= 0 && cardRefs.value[targetIndex]) {
cardRefs.value[targetIndex].scrollIntoView({
behavior: 'smooth',
block: 'start',
})
}
})
}
const maliciousSummaryModalRef = ref<InstanceType<typeof MaliciousSummaryModal>>()
const currentUnsafeFiles = ref<UnsafeFile[]>([])
const cardRefs = ref<HTMLElement[]>([])
function handleShowMaliciousSummary(unsafeFiles: UnsafeFile[]) {
currentUnsafeFiles.value = unsafeFiles
maliciousSummaryModalRef.value?.show()
}
watch(currentSortType, () => {
watch([currentSortType, currentResponseFilter, inOtherQueueFilter, currentFilterType], () => {
goToPage(1)
})
@@ -471,7 +526,7 @@ watch(currentSortType, () => {
/> -->
<div class="flex flex-col justify-between gap-2 lg:flex-row">
<div class="iconified-input flex-1 lg:max-w-md">
<div class="iconified-input flex-1 lg:max-w-56">
<SearchIcon aria-hidden="true" class="text-lg" />
<input
v-model="query"
@@ -488,39 +543,41 @@ watch(currentSortType, () => {
</div>
<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 justify-end gap-2 sm:flex-row lg:flex-shrink-0">
<div
class="flex flex-col items-stretch justify-end gap-2 sm:flex-row sm:items-center lg:flex-shrink-0"
>
<Combobox
v-model="currentFilterType"
class="!w-full flex-grow sm:!w-[280px] sm:flex-grow-0 lg:!w-[280px]"
:options="filterTypes"
:placeholder="formatMessage(messages.filterBy)"
searchable
@select="goToPage(1)"
v-model="currentResponseFilter"
class="!w-full flex-grow sm:!w-[120px] sm:flex-grow-0"
:options="responseFilterTypes"
>
<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 }} ({{ filteredIssuesCount }})</span
>
<span class="truncate text-contrast">{{ currentResponseFilter }}</span>
</span>
</template>
</Combobox>
<Combobox
v-model="currentSortType"
class="!w-full flex-grow sm:!w-[150px] sm:flex-grow-0 lg:!w-[175px]"
class="!w-full flex-grow sm:!w-[215px] sm:flex-grow-0"
:options="sortTypes"
:placeholder="formatMessage(messages.sortBy)"
@select="goToPage(1)"
>
<template #selected>
<span class="flex flex-row gap-2 align-middle font-semibold">
<SortAscIcon
v-if="currentSortType === 'Oldest'"
v-if="currentSortType === 'Oldest' || currentSortType === 'Severity lowest'"
class="size-5 flex-shrink-0 text-secondary"
/>
<SortDescIcon v-else class="size-5 flex-shrink-0 text-secondary" />
@@ -529,9 +586,36 @@ watch(currentSortType, () => {
</template>
</Combobox>
<!-- <ButtonStyled color="orange">
<button class="!h-10"><ShieldAlertIcon /> Batch scan</button>
</ButtonStyled> -->
<FloatingPanel button-class="!h-10 !shadow-none !text-contrast">
<BlendIcon class="size-5" /> Advanced filters
<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 mod queue</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
>
<Combobox
v-model="currentFilterType"
class="!w-full"
:options="filterTypes"
:placeholder="formatMessage(messages.filterBy)"
searchable
>
<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 }}</span>
</span>
</template>
</Combobox>
</div>
</div>
</template>
</FloatingPanel>
</div>
</div>
@@ -540,14 +624,24 @@ watch(currentSortType, () => {
</div>
<div class="flex flex-col gap-4">
<div v-if="isLoading || isFetchingNextPage" class="universal-card h-24 animate-pulse"></div>
<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-for="(item, idx) in paginatedItems" :key="item.project.id ?? idx">
<div
v-for="(item, idx) in paginatedItems"
:key="item.project.id ?? idx"
:ref="
(el) => {
if (el) cardRefs[idx] = el as HTMLElement
}
"
>
<ModerationTechRevCard
:item="item"
:loading-issues="loadingIssues"

View File

@@ -1,4 +1,5 @@
import { AbstractModule } from '../../../core/abstract-module'
import { ModrinthApiError } from '../../../core/errors'
import type { Labrinth } from '../types'
export class LabrinthProjectsV3Module extends AbstractModule {
@@ -67,4 +68,39 @@ export class LabrinthProjectsV3Module extends AbstractModule {
body: data,
})
}
/**
* Get the organization that owns a project
*
* @param id - Project ID or slug
* @returns Promise resolving to the organization data, or null if the project is not owned by an organization
*/
public async getOrganization(id: string): Promise<Labrinth.Projects.v3.Organization | null> {
try {
return await this.client.request<Labrinth.Projects.v3.Organization>(
`/project/${id}/organization`,
{ api: 'labrinth', version: 3, method: 'GET' },
)
} catch (error) {
// 404 means the project is not owned by an organization
if (error instanceof ModrinthApiError && error.statusCode === 404) {
return null
}
throw error
}
}
/**
* Get the team members of a project
*
* @param id - Project ID or slug
* @returns Promise resolving to an array of team members
*/
public async getMembers(id: string): Promise<Labrinth.Projects.v3.TeamMember[]> {
return this.client.request<Labrinth.Projects.v3.TeamMember[]>(`/project/${id}/members`, {
api: 'labrinth',
version: 3,
method: 'GET',
})
}
}

View File

@@ -121,4 +121,23 @@ export class LabrinthTechReviewInternalModule extends AbstractModule {
body: data,
})
}
/**
* Get the project report and thread for a specific project.
*
* @param projectId - The project ID
* @returns The project report (may be null if no reports exist) and the moderation thread
*/
public async getProjectReport(
projectId: string,
): Promise<Labrinth.TechReview.Internal.ProjectReportResponse> {
return this.client.request<Labrinth.TechReview.Internal.ProjectReportResponse>(
`/moderation/tech-review/project/${projectId}`,
{
api: 'labrinth',
version: 'internal',
method: 'GET',
},
)
}
}

View File

@@ -363,6 +363,40 @@ export namespace Labrinth {
environment?: Environment
[key: string]: unknown
}
export type Organization = {
id: string
slug: string
name: string
team_id: string
description: string
icon_url: string | null
color: number
members: OrganizationMember[]
}
export type OrganizationMember = {
team_id: string
user: Users.v3.User
role: string
is_owner: boolean
permissions: number
organization_permissions: number
accepted: boolean
payouts_split: number
ordering: number
}
export type TeamMember = {
team_id: string
user: Users.v3.User
role: string
permissions: number
accepted: boolean
payouts_split: number
ordering: number
is_owner: boolean
}
}
}
@@ -749,6 +783,9 @@ export namespace Labrinth {
export type SearchProjectsFilter = {
project_type?: string[]
replied_to?: 'replied' | 'unreplied'
project_status?: string[]
issue_type?: string[]
}
export type SearchProjectsSort =
@@ -912,6 +949,11 @@ export namespace Labrinth {
export type DelphiSeverity = 'low' | 'medium' | 'high' | 'severe'
export type DelphiReportIssueStatus = 'pending' | 'safe' | 'unsafe'
export type ProjectReportResponse = {
project_report: ProjectReport | null
thread: Thread
}
}
}
}

View File

@@ -8,6 +8,7 @@ import _ArrowBigRightDashIcon from './icons/arrow-big-right-dash.svg?component'
import _ArrowBigUpDashIcon from './icons/arrow-big-up-dash.svg?component'
import _ArrowDownIcon from './icons/arrow-down.svg?component'
import _ArrowDownLeftIcon from './icons/arrow-down-left.svg?component'
import _ArrowLeftIcon from './icons/arrow-left.svg?component'
import _ArrowLeftRightIcon from './icons/arrow-left-right.svg?component'
import _ArrowUpIcon from './icons/arrow-up.svg?component'
import _ArrowUpRightIcon from './icons/arrow-up-right.svg?component'
@@ -17,6 +18,7 @@ import _BadgeDollarSignIcon from './icons/badge-dollar-sign.svg?component'
import _BanIcon from './icons/ban.svg?component'
import _BellIcon from './icons/bell.svg?component'
import _BellRingIcon from './icons/bell-ring.svg?component'
import _BlendIcon from './icons/blend.svg?component'
import _BlocksIcon from './icons/blocks.svg?component'
import _BoldIcon from './icons/bold.svg?component'
import _BookIcon from './icons/book.svg?component'
@@ -241,6 +243,7 @@ export const ArrowBigRightDashIcon = _ArrowBigRightDashIcon
export const ArrowBigUpDashIcon = _ArrowBigUpDashIcon
export const ArrowDownIcon = _ArrowDownIcon
export const ArrowDownLeftIcon = _ArrowDownLeftIcon
export const ArrowLeftIcon = _ArrowLeftIcon
export const ArrowLeftRightIcon = _ArrowLeftRightIcon
export const ArrowUpIcon = _ArrowUpIcon
export const ArrowUpRightIcon = _ArrowUpRightIcon
@@ -250,6 +253,7 @@ export const BadgeDollarSignIcon = _BadgeDollarSignIcon
export const BanIcon = _BanIcon
export const BellIcon = _BellIcon
export const BellRingIcon = _BellRingIcon
export const BlendIcon = _BlendIcon
export const BlocksIcon = _BlocksIcon
export const BoldIcon = _BoldIcon
export const BookIcon = _BookIcon

View File

@@ -0,0 +1,16 @@
<!-- @license lucide-static v0.562.0 - ISC -->
<svg
class="lucide lucide-arrow-left"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m12 19-7-7 7-7" />
<path d="M19 12H5" />
</svg>

After

Width:  |  Height:  |  Size: 344 B

View File

@@ -0,0 +1,16 @@
<!-- @license lucide-static v0.562.0 - ISC -->
<svg
class="lucide lucide-blend"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="9" cy="9" r="7" />
<circle cx="15" cy="15" r="7" />
</svg>

After

Width:  |  Height:  |  Size: 353 B

View File

@@ -0,0 +1,5 @@
## Indefinitely Rejected
A project you uploaded has been found to contain or distribute malicious files, this is strictly prohibited and a violation of [Modrinth's Terms of Use](https://modrinth.com/legal/terms).
Our Moderation team has determined this project, and all projects associated with your account should be rejected indefinitely.
We believe this is the best course of action at this time and ask that you **do not resubmit this project**.

View File

@@ -0,0 +1,7 @@
## Source Code Requested
To ensure the safety of all Modrinth users, we ask that you provide the source code for this project before resubmission so that it can be reviewed by our Moderation Team.
We also ask that you provide the source for any included binary files, as well as detailed build instructions allowing us to verify that the compiled code you are distributing matches the provided source.
We understand that you may not want to publish the source code for this project, so you are welcome to share it privately to the [Modrinth Content Moderation Team](https://github.com/ModrinthModeration) on GitHub.

View File

@@ -0,0 +1,5 @@
## Source Code Requested
To ensure the safety of all Modrinth users, we ask that you provide the source code for this project, steps on how to build it, and the process you used to obfuscate it before resubmission so that it can be reviewed by our Moderation Team.
We understand that you may not want to publish the source code for this project, so you are welcome to share it privately to the [Modrinth Content Moderation Team](https://github.com/ModrinthModeration) on GitHub.

View File

@@ -0,0 +1,5 @@
## Source Code Requested
To ensure the safety of all Modrinth users, we ask that you provide the source code for this project before resubmission so that it can be reviewed by our Moderation Team.
We understand that you may not want to publish the source code for this project, so you are welcome to share it privately to the [Modrinth Content Moderation Team](https://github.com/ModrinthModeration) on GitHub.

View File

@@ -0,0 +1,7 @@
## Description Clarity
Per section 2 of [Modrinth's Content Rules](https://modrinth.com/legal/rules) It's important that your Description accurately and honestly represents the content of your project.
Currently, some elements in your Description may be confusing or misleading.
Please edit your description to ensure it accurately represents the current functionality of your project.
Avoid making hyperbolic claims that could misrepresent the facts of your project.
Ensure that your Description is accurate and not likely to confuse users.

View File

@@ -8,4 +8,30 @@ export interface TechReviewContext {
reports: Labrinth.TechReview.Internal.FileReport[]
}
export default [] as ReadonlyArray<QuickReply<TechReviewContext>>
export default [
{
label: '⚠️ Unclear/Misleading',
message: async () => (await import('./messages/tech-review/unclear-misleading.md?raw')).default,
private: false,
},
{
label: '📝 Request Source',
message: async () => (await import('./messages/tech-review/request-source.md?raw')).default,
private: false,
},
{
label: '🔒 Request Source (Obf)',
message: async () => (await import('./messages/tech-review/request-source-obf.md?raw')).default,
private: false,
},
{
label: '📦 Request Source (Bin)',
message: async () => (await import('./messages/tech-review/request-source-bin.md?raw')).default,
private: false,
},
{
label: '🚫 Malware',
message: async () => (await import('./messages/tech-review/malware.md?raw')).default,
private: false,
},
] as ReadonlyArray<QuickReply<TechReviewContext>>

View File

@@ -0,0 +1,310 @@
<script setup lang="ts">
import { onClickOutside } from '@vueuse/core'
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import ButtonStyled from './ButtonStyled.vue'
const PANEL_VIEWPORT_MARGIN = 8
const props = withDefaults(
defineProps<{
placement?: 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end'
distance?: number
disabled?: boolean
buttonClass?: string
panelClass?: string
}>(),
{
placement: 'bottom-end',
distance: 8,
disabled: false,
},
)
const emit = defineEmits<{
open: []
close: []
}>()
const isOpen = ref(false)
const triggerRef = ref<HTMLElement>()
const panelRef = ref<HTMLElement>()
const rafId = ref<number | null>(null)
const openDirection = ref<'up' | 'down'>('down')
const horizontalAlignment = ref<'start' | 'end'>('end')
const panelStyle = ref({
top: '0px',
left: '0px',
})
const transformOrigin = computed(() => {
const vertical = openDirection.value === 'down' ? 'top' : 'bottom'
const horizontal = horizontalAlignment.value === 'end' ? 'right' : 'left'
return `${vertical} ${horizontal}`
})
function determineOpenDirection(
triggerRect: DOMRect,
panelRect: DOMRect,
viewportHeight: number,
): 'up' | 'down' {
const preferDown = props.placement.startsWith('bottom')
const hasSpaceBelow =
triggerRect.bottom + props.distance + panelRect.height + PANEL_VIEWPORT_MARGIN <= viewportHeight
const hasSpaceAbove =
triggerRect.top - props.distance - panelRect.height - PANEL_VIEWPORT_MARGIN >= 0
if (preferDown) {
return hasSpaceBelow ? 'down' : hasSpaceAbove ? 'up' : 'down'
} else {
return hasSpaceAbove ? 'up' : hasSpaceBelow ? 'down' : 'up'
}
}
function calculateVerticalPosition(
triggerRect: DOMRect,
panelRect: DOMRect,
direction: 'up' | 'down',
): number {
return direction === 'up'
? triggerRect.top - panelRect.height - props.distance
: triggerRect.bottom + props.distance
}
function calculateHorizontalPosition(
triggerRect: DOMRect,
panelRect: DOMRect,
viewportWidth: number,
): number {
const alignEnd = props.placement.endsWith('end')
let left: number
if (alignEnd) {
left = triggerRect.right - panelRect.width
} else {
left = triggerRect.left
}
if (left + panelRect.width > viewportWidth - PANEL_VIEWPORT_MARGIN) {
left = Math.max(PANEL_VIEWPORT_MARGIN, viewportWidth - panelRect.width - PANEL_VIEWPORT_MARGIN)
}
if (left < PANEL_VIEWPORT_MARGIN) {
left = PANEL_VIEWPORT_MARGIN
}
return left
}
async function updatePanelPosition() {
if (!triggerRef.value || !panelRef.value) return
await nextTick()
const triggerRect = triggerRef.value.getBoundingClientRect()
const panelRect = panelRef.value.getBoundingClientRect()
const viewportHeight = window.innerHeight
const viewportWidth = window.innerWidth
const direction = determineOpenDirection(triggerRect, panelRect, viewportHeight)
const top = calculateVerticalPosition(triggerRect, panelRect, direction)
const left = calculateHorizontalPosition(triggerRect, panelRect, viewportWidth)
panelStyle.value = {
top: `${top}px`,
left: `${left}px`,
}
openDirection.value = direction
horizontalAlignment.value = props.placement.endsWith('end') ? 'end' : 'start'
}
function startPositionTracking() {
function track() {
updatePanelPosition()
rafId.value = requestAnimationFrame(track)
}
rafId.value = requestAnimationFrame(track)
}
function stopPositionTracking() {
if (rafId.value !== null) {
cancelAnimationFrame(rafId.value)
rafId.value = null
}
}
function focusPanelContent() {
if (!panelRef.value) return
const focusable = panelRef.value.querySelector<HTMLElement>(
'button:not([data-focus-trap]), [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
)
if (focusable) {
focusable.focus()
}
}
async function open() {
if (props.disabled || isOpen.value) return
isOpen.value = true
emit('open')
await nextTick()
await updatePanelPosition()
startPositionTracking()
setTimeout(() => {
focusPanelContent()
}, 50)
}
function close() {
if (!isOpen.value) return
stopPositionTracking()
isOpen.value = false
emit('close')
nextTick(() => {
triggerRef.value?.focus()
})
}
function toggle() {
if (isOpen.value) {
close()
} else {
open()
}
}
onClickOutside(
panelRef,
() => {
close()
},
{ ignore: [triggerRef, '#teleports'] },
)
function handleTriggerKeydown(event: KeyboardEvent) {
switch (event.key) {
case 'Enter':
case ' ':
event.preventDefault()
toggle()
break
case 'ArrowDown':
event.preventDefault()
open()
break
case 'ArrowUp':
event.preventDefault()
open()
break
case 'Escape':
if (isOpen.value) {
event.preventDefault()
close()
}
break
}
}
function handlePanelKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.preventDefault()
close()
}
}
function handleWindowResize() {
if (isOpen.value) {
updatePanelPosition()
}
}
onMounted(() => {
window.addEventListener('resize', handleWindowResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleWindowResize)
stopPositionTracking()
})
defineExpose({
open,
close,
toggle,
})
</script>
<template>
<div class="relative inline-block">
<ButtonStyled v-bind="$attrs">
<button
ref="triggerRef"
:class="buttonClass"
:disabled="disabled"
:aria-expanded="isOpen"
aria-haspopup="true"
@click="toggle"
@keydown="handleTriggerKeydown"
>
<slot></slot>
</button>
</ButtonStyled>
<Teleport to="body">
<Transition
enter-active-class="floating-panel-enter-active"
enter-from-class="floating-panel-enter-from"
enter-to-class="floating-panel-enter-to"
leave-active-class="floating-panel-leave-active"
leave-from-class="floating-panel-leave-from"
leave-to-class="floating-panel-leave-to"
>
<div
v-if="isOpen"
ref="panelRef"
class="fixed z-[9995] w-fit rounded-[14px] border border-surface-5 bg-surface-3 border-solid border-px p-3 shadow-2xl"
:class="panelClass"
:style="[panelStyle, { transformOrigin }]"
role="dialog"
tabindex="-1"
@keydown="handlePanelKeydown"
@mousedown.stop
>
<button class="sr-only" data-focus-trap @focusin="close"></button>
<slot name="panel"></slot>
<button class="sr-only" data-focus-trap @focusin="close"></button>
</div>
</Transition>
</Teleport>
</div>
</template>
<style scoped>
/* .floating-panel-enter-active,
.floating-panel-leave-active {
transition:
transform 0.125s ease-in-out,
opacity 0.125s ease-in-out;
}
.floating-panel-enter-from,
.floating-panel-leave-to {
transform: scale(0.85);
opacity: 0;
}
.floating-panel-enter-to,
.floating-panel-leave-from {
transform: scale(1);
opacity: 1;
} */
</style>

View File

@@ -27,6 +27,7 @@ export { default as FileInput } from './FileInput.vue'
export type { FilterBarOption } from './FilterBar.vue'
export { default as FilterBar } from './FilterBar.vue'
export { default as FloatingActionBar } from './FloatingActionBar.vue'
export { default as FloatingPanel } from './FloatingPanel.vue'
export { default as HeadingLink } from './HeadingLink.vue'
export { default as HorizontalRule } from './HorizontalRule.vue'
export { default as IconSelect } from './IconSelect.vue'