forked from didirus/AstralRinth
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:
@@ -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>
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
304
apps/frontend/src/pages/moderation/technical-review/[id].vue
Normal file
304
apps/frontend/src/pages/moderation/technical-review/[id].vue
Normal 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>
|
||||
@@ -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"
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
16
packages/assets/icons/arrow-left.svg
Normal file
16
packages/assets/icons/arrow-left.svg
Normal 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 |
16
packages/assets/icons/blend.svg
Normal file
16
packages/assets/icons/blend.svg
Normal 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 |
@@ -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**.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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>>
|
||||
|
||||
310
packages/ui/src/components/base/FloatingPanel.vue
Normal file
310
packages/ui/src/components/base/FloatingPanel.vue
Normal 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>
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user