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,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'