mirror of
https://github.com/modrinth/code.git
synced 2026-08-29 02:54:51 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bee2f83d8 | ||
|
|
7823f83d04 | ||
|
|
a6a9139019 | ||
|
|
b9c3b363e8 | ||
|
|
0ab9100c46 | ||
|
|
a1b73d089e | ||
|
|
c2cbf16434 | ||
|
|
ff4c9be910 | ||
|
|
98f8bbcad9 | ||
|
|
a9b774005b | ||
|
|
5d47594302 | ||
|
|
8a0d683ae2 | ||
|
|
ffd864a9e2 | ||
|
|
263aaedc8f |
@@ -73,7 +73,7 @@ jobs:
|
||||
- name: Build frontend
|
||||
run: pnpm web:build
|
||||
env:
|
||||
NITRO_PRESET: node-server
|
||||
NITRO_PRESET: node_cluster
|
||||
BUILD_ENV: ${{ steps.meta.outputs.env }}
|
||||
CF_PAGES_BRANCH: ${{ github.ref_name }}
|
||||
CF_PAGES_COMMIT_SHA: ${{ github.sha }}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useDroppable } from '@dnd-kit/vue'
|
||||
import { defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
import type { InstanceScreenshot } from '@/helpers/instance'
|
||||
|
||||
@@ -12,6 +12,9 @@ const props = defineProps<{
|
||||
id: string
|
||||
title: string
|
||||
screenshots: InstanceScreenshot[]
|
||||
renderedScreenshots?: InstanceScreenshot[]
|
||||
virtualGridHeight?: number
|
||||
virtualGridTop?: number
|
||||
selectedKeys: ReadonlySet<string>
|
||||
selectionActive: boolean
|
||||
activeDraggedKeys: ReadonlySet<string>
|
||||
@@ -43,6 +46,53 @@ const messages = defineMessages({
|
||||
},
|
||||
})
|
||||
|
||||
const shouldShowGrid = computed(() =>
|
||||
Boolean(props.hideHeader || props.forceOpen || !collapsed.value),
|
||||
)
|
||||
const visibleScreenshots = ref<InstanceScreenshot[]>(props.renderedScreenshots ?? props.screenshots)
|
||||
const renderGrid = ref(shouldShowGrid.value)
|
||||
const virtualGridStyle = computed(() =>
|
||||
props.virtualGridHeight === undefined ? undefined : { height: `${props.virtualGridHeight}px` },
|
||||
)
|
||||
const visibleGridStyle = computed(() =>
|
||||
props.virtualGridTop === undefined
|
||||
? undefined
|
||||
: { transform: `translateY(${props.virtualGridTop}px)` },
|
||||
)
|
||||
let unmountGridTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
watch(
|
||||
() => props.renderedScreenshots ?? props.screenshots,
|
||||
(screenshots) => {
|
||||
if (shouldShowGrid.value) visibleScreenshots.value = screenshots
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
shouldShowGrid,
|
||||
(showGrid, previouslyShown) => {
|
||||
if (unmountGridTimeout) clearTimeout(unmountGridTimeout)
|
||||
if (showGrid) {
|
||||
visibleScreenshots.value = props.renderedScreenshots ?? props.screenshots
|
||||
renderGrid.value = true
|
||||
return
|
||||
}
|
||||
if (!previouslyShown) {
|
||||
renderGrid.value = false
|
||||
return
|
||||
}
|
||||
unmountGridTimeout = setTimeout(() => {
|
||||
renderGrid.value = false
|
||||
unmountGridTimeout = undefined
|
||||
}, 300)
|
||||
},
|
||||
{ flush: 'post' },
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (unmountGridTimeout) clearTimeout(unmountGridTimeout)
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'activate', screenshot: InstanceScreenshot, event: MouseEvent | KeyboardEvent): void
|
||||
(e: 'toggle-selection' | 'copy' | 'edit', screenshot: InstanceScreenshot): void
|
||||
@@ -98,44 +148,48 @@ function getSelectionKey(screenshot: InstanceScreenshot) {
|
||||
<template #actions="{ startEditing }">
|
||||
<slot name="actions" :start-editing="startEditing" />
|
||||
</template>
|
||||
<TransitionGroup
|
||||
tag="div"
|
||||
class="grid min-h-[45px] w-full grid-cols-1 gap-3 sm:grid-cols-2 2xl:grid-cols-4"
|
||||
move-class="transition-transform duration-200 ease-out motion-reduce:transition-none"
|
||||
:enter-active-class="
|
||||
animateEntry
|
||||
? 'transition-[opacity,transform] duration-[150ms] ease-out motion-reduce:transition-none'
|
||||
: ''
|
||||
"
|
||||
:enter-from-class="animateEntry ? 'opacity-0' : ''"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
>
|
||||
<ScreenshotCard
|
||||
v-for="screenshot in screenshots"
|
||||
:key="getSelectionKey(screenshot)"
|
||||
:screenshot="screenshot"
|
||||
:selection-key="getSelectionKey(screenshot)"
|
||||
:selected="selectedKeys.has(getSelectionKey(screenshot))"
|
||||
:selection-active="selectionActive"
|
||||
:active-dragged="activeDraggedKeys.has(getSelectionKey(screenshot))"
|
||||
:can-drag="canDrag"
|
||||
:show-instance-name="showInstanceName"
|
||||
:highlighted="highlightedScreenshotId === screenshot.id"
|
||||
:copied="copiedScreenshotIds.has(screenshot.id)"
|
||||
@activate="(event) => emit('activate', screenshot, event)"
|
||||
@toggle-selection="emit('toggle-selection', screenshot)"
|
||||
@copy="emit('copy', screenshot)"
|
||||
@edit="emit('edit', screenshot)"
|
||||
@more="(event) => emit('more', screenshot, event)"
|
||||
/>
|
||||
<p
|
||||
v-if="screenshots.length === 0"
|
||||
key="empty-group"
|
||||
class="col-span-full m-0 pl-0.5 pt-1 text-base font-base text-secondary opacity-80"
|
||||
<div v-if="renderGrid" class="relative min-h-[45px] w-full" :style="virtualGridStyle">
|
||||
<TransitionGroup
|
||||
tag="div"
|
||||
class="grid min-h-[45px] w-full grid-cols-1 gap-3 sm:grid-cols-2 2xl:grid-cols-4"
|
||||
:class="{ 'absolute inset-x-0 top-0': virtualGridHeight !== undefined }"
|
||||
:style="visibleGridStyle"
|
||||
move-class="transition-transform duration-200 ease-out motion-reduce:transition-none"
|
||||
:enter-active-class="
|
||||
animateEntry
|
||||
? 'transition-[opacity,transform] duration-[150ms] ease-out motion-reduce:transition-none'
|
||||
: ''
|
||||
"
|
||||
:enter-from-class="animateEntry ? 'opacity-0' : ''"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
>
|
||||
{{ formatMessage(messages.emptyGroup) }}
|
||||
</p>
|
||||
</TransitionGroup>
|
||||
<ScreenshotCard
|
||||
v-for="screenshot in visibleScreenshots"
|
||||
:key="getSelectionKey(screenshot)"
|
||||
:screenshot="screenshot"
|
||||
:selection-key="getSelectionKey(screenshot)"
|
||||
:selected="selectedKeys.has(getSelectionKey(screenshot))"
|
||||
:selection-active="selectionActive"
|
||||
:active-dragged="activeDraggedKeys.has(getSelectionKey(screenshot))"
|
||||
:can-drag="canDrag"
|
||||
:show-instance-name="showInstanceName"
|
||||
:highlighted="highlightedScreenshotId === screenshot.id"
|
||||
:copied="copiedScreenshotIds.has(screenshot.id)"
|
||||
@activate="(event) => emit('activate', screenshot, event)"
|
||||
@toggle-selection="emit('toggle-selection', screenshot)"
|
||||
@copy="emit('copy', screenshot)"
|
||||
@edit="emit('edit', screenshot)"
|
||||
@more="(event) => emit('more', screenshot, event)"
|
||||
/>
|
||||
<p
|
||||
v-if="screenshots.length === 0"
|
||||
key="empty-group"
|
||||
class="col-span-full m-0 pl-0.5 pt-1 text-base font-base text-secondary opacity-80"
|
||||
>
|
||||
{{ formatMessage(messages.emptyGroup) }}
|
||||
</p>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</ScreenshotSection>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -36,12 +36,13 @@ import {
|
||||
ReadyTransition,
|
||||
useFormatDateTime,
|
||||
useReadyState,
|
||||
useScrollViewport,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { save } from '@tauri-apps/plugin-dialog'
|
||||
import { readFile } from '@tauri-apps/plugin-fs'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import { useElementSize, useStorage, useWindowSize } from '@vueuse/core'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
@@ -107,6 +108,28 @@ type ScreenshotDropData = {
|
||||
customGroupId?: string | null
|
||||
}
|
||||
|
||||
type ScreenshotGroupLayout = {
|
||||
group: ScreenshotGroupData
|
||||
top: number
|
||||
height: number
|
||||
isOpen: boolean
|
||||
gridHeight: number
|
||||
gridTop: number
|
||||
}
|
||||
|
||||
type VisibleScreenshotGroupLayout = ScreenshotGroupLayout & {
|
||||
renderedScreenshots: InstanceScreenshot[]
|
||||
virtualGridTop: number
|
||||
}
|
||||
|
||||
const SCREENSHOT_GRID_GAP = 12
|
||||
const SCREENSHOT_GROUP_SPACING = 12
|
||||
const SCREENSHOT_GROUP_HEADER_HEIGHT = 40
|
||||
const SCREENSHOT_GROUP_CONTENT_SPACING = 10
|
||||
const SCREENSHOT_GROUP_OVERSCAN = 900
|
||||
const SCREENSHOT_GRID_MIN_HEIGHT = 45
|
||||
const FALLBACK_SCREENSHOT_CARD_WIDTH = 320
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
instanceId?: string
|
||||
@@ -151,6 +174,7 @@ const copiedScreenshotIds = ref(new Set<string>())
|
||||
const copiedResetTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
const screenshotsPage = ref<HTMLElement>()
|
||||
const regrouping = ref(false)
|
||||
const screenshotsScrolling = ref(false)
|
||||
const screenshotToDelete = ref<InstanceScreenshot | null>(null)
|
||||
const deleteFromPreview = ref(false)
|
||||
const activeDrag = ref<ActiveScreenshotDrag | null>(null)
|
||||
@@ -173,6 +197,25 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { formatMessage } = useVIntl()
|
||||
const { addNotification, handleError } = injectNotificationManager()
|
||||
let screenshotsScrollIdleTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
const {
|
||||
listContainer: screenshotListContainer,
|
||||
containerOffset: screenshotListOffset,
|
||||
relativeScrollTop: screenshotListScrollTop,
|
||||
scrollContainer: screenshotScrollContainer,
|
||||
viewportHeight: screenshotViewportHeight,
|
||||
} = useScrollViewport({
|
||||
onScroll: () => {
|
||||
screenshotsScrolling.value = true
|
||||
if (screenshotsScrollIdleTimeout) clearTimeout(screenshotsScrollIdleTimeout)
|
||||
screenshotsScrollIdleTimeout = setTimeout(() => {
|
||||
screenshotsScrolling.value = false
|
||||
screenshotsScrollIdleTimeout = undefined
|
||||
}, 120)
|
||||
},
|
||||
})
|
||||
const { width: screenshotListWidth } = useElementSize(screenshotListContainer)
|
||||
const { width: windowWidth } = useWindowSize()
|
||||
const formatDateTime = useFormatDateTime({ dateStyle: 'long', timeStyle: 'short' })
|
||||
const formatMonth = useFormatDateTime({ month: 'long', year: 'numeric' })
|
||||
const messages = defineMessages({
|
||||
@@ -469,6 +512,93 @@ const groupedScreenshots = computed((): ScreenshotGroupData[] => {
|
||||
})
|
||||
})
|
||||
|
||||
const screenshotColumnCount = computed(() => {
|
||||
if (windowWidth.value >= 1536) return 4
|
||||
if (windowWidth.value >= 640) return 2
|
||||
return 1
|
||||
})
|
||||
|
||||
const screenshotCardWidth = computed(() => {
|
||||
if (screenshotListWidth.value <= 0) return FALLBACK_SCREENSHOT_CARD_WIDTH
|
||||
const gapsWidth = (screenshotColumnCount.value - 1) * SCREENSHOT_GRID_GAP
|
||||
return Math.max(0, (screenshotListWidth.value - gapsWidth) / screenshotColumnCount.value)
|
||||
})
|
||||
|
||||
const screenshotCardHeight = computed(() => (screenshotCardWidth.value * 9) / 16)
|
||||
const screenshotRowHeight = computed(() => screenshotCardHeight.value + SCREENSHOT_GRID_GAP)
|
||||
|
||||
const screenshotGroupLayouts = computed<ScreenshotGroupLayout[]>(() => {
|
||||
const layouts: ScreenshotGroupLayout[] = []
|
||||
let top = 0
|
||||
|
||||
for (const group of groupedScreenshots.value) {
|
||||
const isHeaderHidden = groupBy.value === 'none'
|
||||
const isOpen = isHeaderHidden || search.value.length > 0 || !collapsedGroups.value[group.id]
|
||||
const rowCount = Math.ceil(group.screenshots.length / screenshotColumnCount.value)
|
||||
const gridHeight =
|
||||
rowCount === 0
|
||||
? SCREENSHOT_GRID_MIN_HEIGHT
|
||||
: rowCount * screenshotCardHeight.value + Math.max(0, rowCount - 1) * SCREENSHOT_GRID_GAP
|
||||
const headerHeight = isHeaderHidden ? 0 : SCREENSHOT_GROUP_HEADER_HEIGHT
|
||||
const gridTop = top + headerHeight + SCREENSHOT_GROUP_CONTENT_SPACING
|
||||
const height =
|
||||
headerHeight +
|
||||
(isOpen ? SCREENSHOT_GROUP_CONTENT_SPACING + gridHeight : 0) +
|
||||
SCREENSHOT_GROUP_SPACING
|
||||
|
||||
layouts.push({ group, top, height, isOpen, gridHeight, gridTop })
|
||||
top += height
|
||||
}
|
||||
|
||||
return layouts
|
||||
})
|
||||
|
||||
const screenshotListHeight = computed(() => {
|
||||
const lastGroup = screenshotGroupLayouts.value[screenshotGroupLayouts.value.length - 1]
|
||||
return lastGroup ? lastGroup.top + lastGroup.height : 0
|
||||
})
|
||||
|
||||
const visibleScreenshotGroups = computed<VisibleScreenshotGroupLayout[]>(() => {
|
||||
const hasViewport = Boolean(screenshotListContainer.value && screenshotScrollContainer.value)
|
||||
const viewportStart = hasViewport
|
||||
? Math.max(0, screenshotListScrollTop.value - SCREENSHOT_GROUP_OVERSCAN)
|
||||
: 0
|
||||
const viewportEnd = hasViewport
|
||||
? screenshotListScrollTop.value + screenshotViewportHeight.value + SCREENSHOT_GROUP_OVERSCAN
|
||||
: SCREENSHOT_GROUP_OVERSCAN
|
||||
|
||||
return screenshotGroupLayouts.value
|
||||
.filter((layout) => layout.top + layout.height >= viewportStart && layout.top <= viewportEnd)
|
||||
.map((layout) => {
|
||||
if (!layout.isOpen || layout.group.screenshots.length === 0) {
|
||||
return { ...layout, renderedScreenshots: [], virtualGridTop: 0 }
|
||||
}
|
||||
|
||||
const rowCount = Math.ceil(layout.group.screenshots.length / screenshotColumnCount.value)
|
||||
const firstRow = Math.min(
|
||||
rowCount,
|
||||
Math.max(0, Math.floor((viewportStart - layout.gridTop) / screenshotRowHeight.value)),
|
||||
)
|
||||
const lastRow = Math.min(
|
||||
rowCount,
|
||||
Math.max(
|
||||
firstRow,
|
||||
Math.ceil(
|
||||
(viewportEnd - layout.gridTop + SCREENSHOT_GRID_GAP) / screenshotRowHeight.value,
|
||||
),
|
||||
),
|
||||
)
|
||||
const firstScreenshot = firstRow * screenshotColumnCount.value
|
||||
const lastScreenshot = lastRow * screenshotColumnCount.value
|
||||
|
||||
return {
|
||||
...layout,
|
||||
renderedScreenshots: layout.group.screenshots.slice(firstScreenshot, lastScreenshot),
|
||||
virtualGridTop: firstRow * screenshotRowHeight.value,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const previewItems = computed(() =>
|
||||
filteredScreenshots.value.map((screenshot) => ({
|
||||
id: getSelectionKey(screenshot),
|
||||
@@ -959,10 +1089,27 @@ async function revealScreenshot(id: string) {
|
||||
if (group) setGroupCollapsed(group.id, false)
|
||||
|
||||
await nextTick()
|
||||
const card = document.querySelector<HTMLElement>(`[data-screenshot-id="${CSS.escape(id)}"]`)
|
||||
card?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
card?.focus()
|
||||
await waitForScreenshotViewport()
|
||||
highlightedScreenshotId.value = id
|
||||
|
||||
const layout = screenshotGroupLayouts.value.find((candidate) => candidate.group.id === group?.id)
|
||||
const screenshotIndex = layout?.group.screenshots.findIndex((screenshot) => screenshot.id === id)
|
||||
const scrollTarget = screenshotScrollContainer.value
|
||||
if (layout && screenshotIndex !== undefined && screenshotIndex >= 0 && scrollTarget) {
|
||||
const row = Math.floor(screenshotIndex / screenshotColumnCount.value)
|
||||
const top = Math.max(
|
||||
0,
|
||||
screenshotListOffset.value +
|
||||
layout.gridTop +
|
||||
row * screenshotRowHeight.value +
|
||||
screenshotCardHeight.value / 2 -
|
||||
screenshotViewportHeight.value / 2,
|
||||
)
|
||||
scrollTarget.scrollTo({ top, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
const card = await waitForScreenshotCard(id)
|
||||
card?.focus({ preventScroll: true })
|
||||
revealTimeout = setTimeout(() => {
|
||||
if (highlightedScreenshotId.value === id) highlightedScreenshotId.value = undefined
|
||||
if (revealedScreenshotId.value === id) revealedScreenshotId.value = undefined
|
||||
@@ -970,6 +1117,23 @@ async function revealScreenshot(id: string) {
|
||||
}, 2400)
|
||||
}
|
||||
|
||||
async function waitForScreenshotViewport() {
|
||||
for (let frame = 0; frame < 10; frame++) {
|
||||
if (screenshotListContainer.value && screenshotScrollContainer.value) return
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForScreenshotCard(id: string) {
|
||||
const selector = `[data-screenshot-id="${CSS.escape(id)}"]`
|
||||
for (let frame = 0; frame < 60; frame++) {
|
||||
const card = screenshotListContainer.value?.querySelector<HTMLElement>(selector)
|
||||
if (card) return card
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
const screenshot = screenshotToDelete.value
|
||||
if (!screenshot) return
|
||||
@@ -1171,6 +1335,7 @@ watch(activeDropGroupId, (groupId) => {
|
||||
onBeforeUnmount(() => {
|
||||
clearGroupHoverOpenTimeout()
|
||||
if (revealTimeout) clearTimeout(revealTimeout)
|
||||
if (screenshotsScrollIdleTimeout) clearTimeout(screenshotsScrollIdleTimeout)
|
||||
for (const timeout of copiedResetTimeouts.values()) clearTimeout(timeout)
|
||||
copiedResetTimeouts.clear()
|
||||
})
|
||||
@@ -1312,65 +1477,85 @@ onBeforeUnmount(() => {
|
||||
@drag-over="handleDragOver"
|
||||
@drag-end="handleDragEnd"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<ScreenshotGroupSection
|
||||
v-for="group in groupedScreenshots"
|
||||
:id="group.id"
|
||||
<div
|
||||
ref="screenshotListContainer"
|
||||
class="relative w-full"
|
||||
:style="{ height: `${screenshotListHeight}px`, overflowAnchor: 'none' }"
|
||||
>
|
||||
<div
|
||||
v-for="{
|
||||
group,
|
||||
top,
|
||||
gridHeight,
|
||||
renderedScreenshots,
|
||||
virtualGridTop,
|
||||
} in visibleScreenshotGroups"
|
||||
:key="group.id"
|
||||
:title="group.title"
|
||||
:screenshots="group.screenshots"
|
||||
:selected-keys="selectedKeys"
|
||||
:selection-active="selectionActive"
|
||||
:active-dragged-keys="activeDraggedKeys"
|
||||
:show-drop-outline="activeDropGroupId === group.id && canDropScreenshotsOnGroup(group)"
|
||||
:can-drag="groupBy === 'custom' || (isGlobal && groupBy === 'instance')"
|
||||
:drop-instance-id="groupBy === 'instance' ? group.dropInstanceId : undefined"
|
||||
:drop-custom-group="groupBy === 'custom'"
|
||||
:drop-custom-group-id="group.customGroupId ?? undefined"
|
||||
:show-instance-name="isGlobal && groupBy !== 'instance'"
|
||||
:highlighted-screenshot-id="highlightedScreenshotId"
|
||||
:copied-screenshot-ids="copiedScreenshotIds"
|
||||
:animate-entry="!regrouping"
|
||||
:force-open="search.length > 0"
|
||||
:hide-header="groupBy === 'none'"
|
||||
:editable-title="Boolean(group.customGroupId)"
|
||||
:start-editing-title="groupIdPendingNameEdit === group.customGroupId"
|
||||
:max-title-length="MAX_INSTANCE_GROUP_NAME_LENGTH"
|
||||
:validate-title="validateCustomGroupName"
|
||||
:on-title-change="(name: string) => renameCustomGroup(group.customGroupId, name)"
|
||||
:collapsed="Boolean(collapsedGroups[group.id])"
|
||||
@update:collapsed="(value) => setGroupCollapsed(group.id, value)"
|
||||
@activate="activateScreenshot"
|
||||
@toggle-selection="toggleScreenshotSelection"
|
||||
@copy="copyScreenshot"
|
||||
@edit="editScreenshot"
|
||||
@more="showScreenshotOptions"
|
||||
class="absolute inset-x-0 transition-transform duration-300 ease-in-out will-change-transform motion-reduce:transition-none"
|
||||
:style="{ transform: `translateY(${top}px)` }"
|
||||
>
|
||||
<template v-if="group.customGroupId" #actions="{ startEditing }">
|
||||
<div
|
||||
class="flex shrink-0 items-center opacity-0 transition-opacity duration-250 group-hover/header:opacity-100 focus-within:opacity-100"
|
||||
>
|
||||
<IconButton
|
||||
v-tooltip="formatMessage(messages.editGroup)"
|
||||
:label="formatMessage(messages.editGroup)"
|
||||
type="quiet"
|
||||
size="sm"
|
||||
@click.stop="startEditing"
|
||||
<ScreenshotGroupSection
|
||||
:id="group.id"
|
||||
:title="group.title"
|
||||
:screenshots="group.screenshots"
|
||||
:rendered-screenshots="renderedScreenshots"
|
||||
:virtual-grid-height="gridHeight"
|
||||
:virtual-grid-top="virtualGridTop"
|
||||
:selected-keys="selectedKeys"
|
||||
:selection-active="selectionActive"
|
||||
:active-dragged-keys="activeDraggedKeys"
|
||||
:show-drop-outline="
|
||||
activeDropGroupId === group.id && canDropScreenshotsOnGroup(group)
|
||||
"
|
||||
:can-drag="groupBy === 'custom' || (isGlobal && groupBy === 'instance')"
|
||||
:drop-instance-id="groupBy === 'instance' ? group.dropInstanceId : undefined"
|
||||
:drop-custom-group="groupBy === 'custom'"
|
||||
:drop-custom-group-id="group.customGroupId ?? undefined"
|
||||
:show-instance-name="isGlobal && groupBy !== 'instance'"
|
||||
:highlighted-screenshot-id="highlightedScreenshotId"
|
||||
:copied-screenshot-ids="copiedScreenshotIds"
|
||||
:animate-entry="!regrouping && !screenshotsScrolling"
|
||||
:force-open="search.length > 0"
|
||||
:hide-header="groupBy === 'none'"
|
||||
:editable-title="Boolean(group.customGroupId)"
|
||||
:start-editing-title="groupIdPendingNameEdit === group.customGroupId"
|
||||
:max-title-length="MAX_INSTANCE_GROUP_NAME_LENGTH"
|
||||
:validate-title="validateCustomGroupName"
|
||||
:on-title-change="(name: string) => renameCustomGroup(group.customGroupId, name)"
|
||||
:collapsed="Boolean(collapsedGroups[group.id])"
|
||||
@update:collapsed="(value) => setGroupCollapsed(group.id, value)"
|
||||
@activate="activateScreenshot"
|
||||
@toggle-selection="toggleScreenshotSelection"
|
||||
@copy="copyScreenshot"
|
||||
@edit="editScreenshot"
|
||||
@more="showScreenshotOptions"
|
||||
>
|
||||
<template v-if="group.customGroupId" #actions="{ startEditing }">
|
||||
<div
|
||||
class="flex shrink-0 items-center opacity-0 transition-opacity duration-250 group-hover/header:opacity-100 focus-within:opacity-100"
|
||||
>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
v-tooltip="formatMessage(messages.deleteGroup)"
|
||||
:label="formatMessage(messages.deleteGroup)"
|
||||
type="quiet"
|
||||
size="sm"
|
||||
@click.stop="requestCustomGroupDeletion(group.customGroupId)"
|
||||
>
|
||||
<TrashIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
</template>
|
||||
</ScreenshotGroupSection>
|
||||
<IconButton
|
||||
v-tooltip="formatMessage(messages.editGroup)"
|
||||
:label="formatMessage(messages.editGroup)"
|
||||
type="quiet"
|
||||
size="sm"
|
||||
@click.stop="startEditing"
|
||||
>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
v-tooltip="formatMessage(messages.deleteGroup)"
|
||||
:label="formatMessage(messages.deleteGroup)"
|
||||
type="quiet"
|
||||
size="sm"
|
||||
@click.stop="requestCustomGroupDeletion(group.customGroupId)"
|
||||
>
|
||||
<TrashIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
</template>
|
||||
</ScreenshotGroupSection>
|
||||
</div>
|
||||
</div>
|
||||
<Teleport to="body">
|
||||
<div class="pointer-events-none fixed inset-0 z-[9999]">
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<router-link
|
||||
class="mb-4 flex w-fit items-center gap-2 rounded-lg px-2 py-0.5 pl-0 text-link"
|
||||
:to="buildProjectHref(`/project/${route.params.id}/versions`)"
|
||||
>
|
||||
<ChevronLeftIcon class="shrink-0" /> {{ formatMessage(messages.allVersions) }}
|
||||
</router-link>
|
||||
<BackToParentLink :to="buildProjectHref(`/project/${route.params.id}/versions`)">
|
||||
{{ formatMessage(messages.allVersions) }}
|
||||
</BackToParentLink>
|
||||
<VersionPage
|
||||
v-if="version"
|
||||
:version="version"
|
||||
@@ -85,14 +82,13 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronLeftIcon,
|
||||
DownloadIcon,
|
||||
ExternalIcon,
|
||||
MoreVerticalIcon,
|
||||
ReportIcon,
|
||||
VersionIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { Button, ButtonLink, TeleportOverflowMenu } from '@modrinth/ui'
|
||||
import { BackToParentLink, Button, ButtonLink, TeleportOverflowMenu } from '@modrinth/ui'
|
||||
import {
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM node:24-slim
|
||||
FROM docker.io/tailscale/tailscale:latest AS tailscale
|
||||
|
||||
FROM node:24-trixie-slim
|
||||
|
||||
LABEL org.opencontainers.image.source=https://github.com/modrinth/code
|
||||
LABEL org.opencontainers.image.title=frontend
|
||||
@@ -11,6 +13,67 @@ RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends dumb-init \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Debug
|
||||
COPY --from=tailscale /usr/local/bin/tailscale /usr/local/bin/tailscaled /usr/local/bin/
|
||||
|
||||
COPY --chmod=0755 <<'EOF' /usr/local/bin/entrypoint.sh
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
TAILSCALE_DIR=/tmp/tailscale
|
||||
TAILSCALE_SOCKET="$TAILSCALE_DIR/tailscaled.sock"
|
||||
|
||||
start_tailscale() {
|
||||
echo "entrypoint: starting tailscale as euid=$(id -u) groups=$(id -G)" >&2
|
||||
mkdir -p "$TAILSCALE_DIR"
|
||||
|
||||
# Userspace networking needs no TUN device or extra capabilities.
|
||||
# --state=mem: registers an ephemeral node; --statedir gives the SSH host keys
|
||||
# somewhere writable to live, which mem: on its own does not.
|
||||
tailscaled \
|
||||
--tun=userspace-networking \
|
||||
--state=mem: \
|
||||
--statedir="$TAILSCALE_DIR" \
|
||||
--socket="$TAILSCALE_SOCKET" &
|
||||
|
||||
waited=0
|
||||
while [ ! -S "$TAILSCALE_SOCKET" ]; do
|
||||
if [ "$waited" -ge 50 ]; then
|
||||
echo "entrypoint: tailscaled socket never appeared" >&2
|
||||
return 1
|
||||
fi
|
||||
waited=$((waited + 1))
|
||||
sleep 0.1
|
||||
done
|
||||
|
||||
if [ -n "${TAILSCALE_HOSTNAME:-}" ]; then
|
||||
node_hostname="$TAILSCALE_HOSTNAME"
|
||||
elif [ -n "${BUNNYNET_MC_PODID:-}" ] && [ -n "${BUNNYNET_MC_REGION:-}" ]; then
|
||||
node_hostname="frontend-$BUNNYNET_MC_PODID-$BUNNYNET_MC_REGION"
|
||||
else
|
||||
node_hostname="frontend-$(hostname)"
|
||||
fi
|
||||
|
||||
tailscale --socket="$TAILSCALE_SOCKET" up \
|
||||
--authkey="$TAILSCALE_AUTH_KEY" \
|
||||
--hostname="$node_hostname" \
|
||||
--accept-dns=false \
|
||||
--timeout=30s \
|
||||
--ssh
|
||||
}
|
||||
|
||||
if [ -n "${TAILSCALE_AUTH_KEY:-}" ]; then
|
||||
start_tailscale || echo "entrypoint: tailscale setup failed, serving without SSH" >&2
|
||||
fi
|
||||
|
||||
# Only drop if we actually started as root; some runtimes pin their own uid.
|
||||
if [ "$(id -u)" = "0" ]; then
|
||||
exec setpriv --reuid=node --regid=node --init-groups -- "$@"
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
EOF
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=3000
|
||||
@@ -20,11 +83,9 @@ WORKDIR /app
|
||||
# Nitro bundles every runtime dependency into .output, so no node_modules is needed.
|
||||
COPY --chown=node:node .output ./.output
|
||||
|
||||
USER node
|
||||
# Stays root so tailscaled can setgroups/setuid for SSH sessions; the entrypoint
|
||||
# drops the server itself to the node user.
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD node -e "fetch('http://127.0.0.1:'+process.env.PORT+'/robots.txt').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"
|
||||
|
||||
ENTRYPOINT ["dumb-init", "--"]
|
||||
ENTRYPOINT ["dumb-init", "--", "/usr/local/bin/entrypoint.sh"]
|
||||
CMD ["node", "/app/.output/server/index.mjs"]
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
import { useAuth } from './composables/auth'
|
||||
|
||||
const auth = await useAuth()
|
||||
const userPreferences = setupProviders(auth)
|
||||
const { userPreferences } = setupProviders(auth)
|
||||
const cosmetics = useCosmetics()
|
||||
const theme = useTheme()
|
||||
const { locale, setLocale } = injectI18n()
|
||||
|
||||
@@ -32,50 +32,19 @@
|
||||
>
|
||||
<MailIcon />
|
||||
</ButtonLink>
|
||||
<IconButton
|
||||
v-tooltip="copied ? `Copied to clipboard` : `Copy link`"
|
||||
:label="copied ? `Copied to clipboard` : `Copy link`"
|
||||
:disabled="copied"
|
||||
class="relative grid place-items-center overflow-hidden"
|
||||
@click="copyToClipboard(url)"
|
||||
>
|
||||
<CheckIcon
|
||||
class="absolute transition-all ease-in-out"
|
||||
:class="copied ? 'translate-y-0' : 'translate-y-7'"
|
||||
/>
|
||||
<LinkIcon
|
||||
class="absolute transition-all ease-in-out"
|
||||
:class="copied ? '-translate-y-7' : 'translate-y-0'"
|
||||
/>
|
||||
</IconButton>
|
||||
<CopyLinkButton :url="url" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
BlueskyIcon,
|
||||
CheckIcon,
|
||||
LinkIcon,
|
||||
MailIcon,
|
||||
MastodonIcon,
|
||||
TwitterIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { ButtonLink, IconButton } from '@modrinth/ui'
|
||||
import { BlueskyIcon, MailIcon, MastodonIcon, TwitterIcon } from '@modrinth/assets'
|
||||
import { ButtonLink, CopyLinkButton } from '@modrinth/ui'
|
||||
|
||||
const props = defineProps<{
|
||||
title?: string
|
||||
url: string
|
||||
}>()
|
||||
|
||||
const copied = ref(false)
|
||||
const encodedUrl = computed(() => encodeURIComponent(props.url))
|
||||
const encodedTitle = computed(() => (props.title ? encodeURIComponent(props.title) : undefined))
|
||||
|
||||
async function copyToClipboard(text: string) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
copied.value = true
|
||||
setTimeout(() => {
|
||||
copied.value = false
|
||||
}, 3000)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<form class="flex flex-col gap-2 sm:flex-row" @submit.prevent="executeSearch">
|
||||
<form class="flex flex-col gap-2 sm:flex-row sm:items-center" @submit.prevent="executeSearch">
|
||||
<Input
|
||||
v-model="query"
|
||||
:icon="SearchIcon"
|
||||
@@ -8,9 +8,10 @@
|
||||
autocomplete="off"
|
||||
placeholder="Search global trace keys..."
|
||||
clearable
|
||||
wrapper-class="flex-1 w-full"
|
||||
size="medium"
|
||||
wrapper-class="min-w-0 flex-1"
|
||||
/>
|
||||
<Button type="colored" color="brand" native-type="submit" :disabled="isLoading">
|
||||
<Button type="colored" color="brand" size="lg" native-type="submit" :disabled="isLoading">
|
||||
<SearchIcon aria-hidden="true" />
|
||||
Search
|
||||
</Button>
|
||||
@@ -20,7 +21,7 @@
|
||||
v-if="!isLoading && !loadError && total > 0"
|
||||
class="mt-4 flex flex-wrap items-center justify-between gap-3"
|
||||
>
|
||||
<p class="m-0 text-sm text-secondary">Showing {{ pageStart }}-{{ pageEnd }} of {{ total }}</p>
|
||||
<p class="m-0">Showing {{ pageStart }}-{{ pageEnd }} of {{ total }}</p>
|
||||
<Pagination :page="currentPage" :count="pageCount" @switch-page="switchPage" />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<span class="flex min-w-0 items-center gap-1.5 text-contrast">
|
||||
<span class="truncate">{{ label }}</span>
|
||||
<SpinnerIcon v-if="loading" class="size-4 shrink-0 animate-spin" aria-hidden="true" />
|
||||
<span v-else class="shrink-0">({{ formatNumber(count) }})</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SpinnerIcon } from '@modrinth/assets'
|
||||
import { useFormatNumber } from '@modrinth/ui'
|
||||
|
||||
defineProps<{
|
||||
label: string
|
||||
count: number
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
const formatNumber = useFormatNumber()
|
||||
</script>
|
||||
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div
|
||||
v-for="i in 3"
|
||||
:key="`loading-skeleton-${i}`"
|
||||
class="flex h-[98px] w-full animate-pulse rounded-2xl bg-surface-3"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col justify-between gap-2 lg:flex-row">
|
||||
<Input
|
||||
v-model="query"
|
||||
:icon="SearchIcon"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
:placeholder="formatMessage(commonMessages.searchPlaceholder)"
|
||||
clearable
|
||||
size="medium"
|
||||
wrapper-class="min-w-0 flex-1"
|
||||
@input="$emit('search')"
|
||||
/>
|
||||
<div
|
||||
class="flex flex-col items-stretch justify-end gap-2 sm:flex-row sm:items-center lg:flex-shrink-0"
|
||||
>
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-3">
|
||||
<slot name="meta" />
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center justify-end gap-2 sm:ml-auto">
|
||||
<slot name="pagination-extra" />
|
||||
<Pagination
|
||||
v-if="totalPages > 1"
|
||||
:page="page"
|
||||
:count="totalPages"
|
||||
@switch-page="$emit('switch-page', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SearchIcon } from '@modrinth/assets'
|
||||
import { commonMessages, Input, Pagination, useVIntl } from '@modrinth/ui'
|
||||
|
||||
const query = defineModel<string>({ required: true })
|
||||
|
||||
defineProps<{
|
||||
page: number
|
||||
totalPages: number
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
search: []
|
||||
'switch-page': [page: number]
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
</script>
|
||||
@@ -1,6 +1,9 @@
|
||||
<template>
|
||||
<div class="overflow-hidden rounded-2xl">
|
||||
<div class="bg-bg-raised p-4">
|
||||
<div
|
||||
class="relative overflow-hidden rounded-2xl border border-solid border-surface-4 transition-[transform,opacity] duration-[400ms] ease-in"
|
||||
:class="{ 'pointer-events-none translate-x-[120%] opacity-0': isSwipingAway }"
|
||||
>
|
||||
<div class="border-0 border-b border-solid border-surface-4 bg-bg-raised p-4">
|
||||
<div
|
||||
class="flex w-full flex-col items-start justify-between gap-3 sm:flex-row sm:items-center sm:gap-0"
|
||||
>
|
||||
@@ -180,6 +183,7 @@
|
||||
v-model:collapsed="isThreadCollapsed"
|
||||
:expand-text="expandText"
|
||||
collapse-text="Collapse thread"
|
||||
:disabled="disableCollapsing"
|
||||
>
|
||||
<div class="bg-surface-2 pt-2">
|
||||
<ThreadView
|
||||
@@ -231,7 +235,7 @@
|
||||
@click="reopenReport()"
|
||||
>
|
||||
<CheckCircleIcon class="size-4" />
|
||||
Reopen Thread
|
||||
Reopen report
|
||||
</Button>
|
||||
</template>
|
||||
<template #additionalActions="{ hasReply }">
|
||||
@@ -261,6 +265,17 @@
|
||||
</ThreadView>
|
||||
</div>
|
||||
</CollapsibleRegion>
|
||||
<div
|
||||
v-if="pendingDismiss"
|
||||
:key="dismissAnimationId"
|
||||
class="pointer-events-none absolute inset-x-0 bottom-0 h-1.5 overflow-hidden bg-highlight-red"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
class="report-dismiss-progress h-full w-full bg-red"
|
||||
:style="{ animationDuration: `${DISMISS_DELAY_MS}ms` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
@@ -288,7 +303,8 @@ import {
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { formatProjectType } from '@modrinth/utils'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import { isStaff } from '~/helpers/users.js'
|
||||
|
||||
@@ -302,16 +318,22 @@ import SharedInstanceReportContext, {
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
const client = injectModrinthClient()
|
||||
const auth = await useAuth()
|
||||
const queryClient = useQueryClient()
|
||||
const auth = useAuthState()
|
||||
|
||||
type SharedInstanceVersionDependency = Labrinth.Versions.v2.Dependency & {
|
||||
project_id?: string
|
||||
version_id?: string
|
||||
}
|
||||
|
||||
const DISMISS_DELAY_MS = 3000
|
||||
const SWIPE_DURATION_MS = 400
|
||||
|
||||
const props = defineProps<{
|
||||
report: ExtendedReport
|
||||
collapsed: boolean
|
||||
disableCollapsing?: boolean
|
||||
dismissAfterClose?: boolean
|
||||
sharedInstanceDetailsLoader?: () => Promise<SharedInstanceReportDetails>
|
||||
sharedInstanceVersionContentLoader?: (
|
||||
instanceId: string,
|
||||
@@ -319,6 +341,10 @@ const props = defineProps<{
|
||||
) => Promise<ContentItem[]>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
dismiss: []
|
||||
}>()
|
||||
|
||||
const reportThread = ref<{
|
||||
setReplyContent: (content: string) => void
|
||||
sendReply: (privateMessage?: boolean) => Promise<void>
|
||||
@@ -339,17 +365,38 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const didCloseReport = ref(false)
|
||||
const reportClosed = computed(() => {
|
||||
return didCloseReport.value || props.report.closed
|
||||
})
|
||||
const closedOverride = ref<boolean | null>(null)
|
||||
const pendingDismiss = ref(false)
|
||||
const isSwipingAway = ref(false)
|
||||
const dismissAnimationId = ref(0)
|
||||
const thread = ref(props.report.thread)
|
||||
let dismissTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
let swipeTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const reportClosed = computed(() => closedOverride.value ?? props.report.closed)
|
||||
|
||||
watch(
|
||||
() => props.report.thread,
|
||||
(value) => {
|
||||
thread.value = value
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.report.closed,
|
||||
(closed) => {
|
||||
if (closedOverride.value === closed) {
|
||||
closedOverride.value = null
|
||||
}
|
||||
},
|
||||
)
|
||||
const sharedInstanceQuarantined = computed(
|
||||
() =>
|
||||
sharedInstanceDetails.value?.quarantine ?? props.report.shared_instance?.quarantine ?? false,
|
||||
)
|
||||
|
||||
const threadWithReportBody = computed(() => {
|
||||
if (!props.report.thread) return null
|
||||
if (!thread.value) return null
|
||||
|
||||
const reportBodyMessage = {
|
||||
id: `report-body-${props.report.id}`,
|
||||
@@ -366,16 +413,15 @@ const threadWithReportBody = computed(() => {
|
||||
}
|
||||
|
||||
return {
|
||||
...props.report.thread,
|
||||
messages: [reportBodyMessage, ...props.report.thread.messages],
|
||||
members: [props.report.reporter_user, ...props.report.thread.members],
|
||||
...thread.value,
|
||||
messages: [reportBodyMessage, ...thread.value.messages],
|
||||
members: [props.report.reporter_user, ...thread.value.members],
|
||||
}
|
||||
})
|
||||
|
||||
const remainingMessageCount = computed(() => {
|
||||
if (!props.report.thread?.messages) return 0
|
||||
// Thread messages count (report body is injected separately)
|
||||
return props.report.thread.messages.length
|
||||
if (!thread.value?.messages) return 0
|
||||
return thread.value.messages.length
|
||||
})
|
||||
|
||||
const expandText = computed(() => {
|
||||
@@ -396,8 +442,10 @@ async function closeReport(reply = false) {
|
||||
closed: true,
|
||||
},
|
||||
})
|
||||
await refreshReportCaches()
|
||||
didCloseReport.value = true
|
||||
await refreshThread()
|
||||
closedOverride.value = true
|
||||
startDismissCountdown()
|
||||
void refreshReportQuery()
|
||||
} catch (err: any) {
|
||||
addNotification({
|
||||
title: 'Error closing report',
|
||||
@@ -408,6 +456,8 @@ async function closeReport(reply = false) {
|
||||
}
|
||||
|
||||
async function reopenReport() {
|
||||
cancelDismissCountdown()
|
||||
|
||||
try {
|
||||
await useBaseFetch(`report/${props.report.id}`, {
|
||||
method: 'PATCH',
|
||||
@@ -415,41 +465,84 @@ async function reopenReport() {
|
||||
closed: false,
|
||||
},
|
||||
})
|
||||
await refreshReportCaches()
|
||||
didCloseReport.value = false
|
||||
await refreshThread()
|
||||
closedOverride.value = false
|
||||
void refreshReportQuery()
|
||||
} catch (err: any) {
|
||||
addNotification({
|
||||
title: 'Error reopening report',
|
||||
text: err.data ? err.data.description : err,
|
||||
type: 'error',
|
||||
})
|
||||
if (reportClosed.value) {
|
||||
startDismissCountdown()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cancelDismissCountdown() {
|
||||
pendingDismiss.value = false
|
||||
if (dismissTimeout !== null) {
|
||||
clearTimeout(dismissTimeout)
|
||||
dismissTimeout = null
|
||||
}
|
||||
}
|
||||
|
||||
function startDismissCountdown() {
|
||||
if (!props.dismissAfterClose || isSwipingAway.value) return
|
||||
|
||||
cancelDismissCountdown()
|
||||
dismissAnimationId.value += 1
|
||||
pendingDismiss.value = true
|
||||
dismissTimeout = setTimeout(() => {
|
||||
dismissTimeout = null
|
||||
swipeAway()
|
||||
}, DISMISS_DELAY_MS)
|
||||
}
|
||||
|
||||
function swipeAway() {
|
||||
if (isSwipingAway.value) return
|
||||
|
||||
isSwipingAway.value = true
|
||||
swipeTimeout = setTimeout(() => {
|
||||
swipeTimeout = null
|
||||
emit('dismiss')
|
||||
}, SWIPE_DURATION_MS)
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
cancelDismissCountdown()
|
||||
if (swipeTimeout !== null) {
|
||||
clearTimeout(swipeTimeout)
|
||||
swipeTimeout = null
|
||||
}
|
||||
})
|
||||
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
const formatDateTime = useFormatDateTime({
|
||||
timeStyle: 'short',
|
||||
dateStyle: 'long',
|
||||
})
|
||||
|
||||
async function refreshReportCaches() {
|
||||
await Promise.allSettled([refreshThread(), refreshNuxtData('new-moderation-reports')])
|
||||
}
|
||||
|
||||
async function refreshThread() {
|
||||
const threadId = props.report.thread?.id ?? props.report.thread_id
|
||||
const threadId = thread.value?.id ?? props.report.thread?.id ?? props.report.thread_id
|
||||
if (!threadId) return
|
||||
|
||||
const thread = await useBaseFetch(`thread/${threadId}`)
|
||||
updateThread(thread)
|
||||
const nextThread = await useBaseFetch(`thread/${threadId}`)
|
||||
updateThread(nextThread)
|
||||
}
|
||||
|
||||
function updateThread(newThread: any) {
|
||||
thread.value = newThread
|
||||
if (props.report.thread) {
|
||||
Object.assign(props.report.thread, newThread)
|
||||
}
|
||||
}
|
||||
|
||||
function refreshReportQuery() {
|
||||
return queryClient.invalidateQueries({ queryKey: ['report', props.report.id] })
|
||||
}
|
||||
|
||||
async function getSharedInstanceVersion(
|
||||
instanceId: string,
|
||||
versionNumber: number,
|
||||
@@ -845,3 +938,21 @@ async function banSharedInstanceOwner(owner: SharedInstanceReportUser) {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.report-dismiss-progress {
|
||||
transform-origin: left center;
|
||||
animation-name: report-dismiss-fill;
|
||||
animation-timing-function: linear;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
@keyframes report-dismiss-fill {
|
||||
from {
|
||||
transform: scaleX(0);
|
||||
}
|
||||
to {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,824 @@
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
CopyIcon,
|
||||
LoaderCircleIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { Collapsible, IconButton, injectNotificationManager, Toggle } from '@modrinth/ui'
|
||||
import { capitalizeString, highlightCodeLines } from '@modrinth/utils'
|
||||
import { computed, nextTick, reactive, ref, watch } from 'vue'
|
||||
|
||||
import {
|
||||
canUpdateGlobalDetail,
|
||||
getFileDetailCount,
|
||||
getSeverityBadgeColor,
|
||||
severityOrder,
|
||||
truncateMiddle,
|
||||
verdictToDecision,
|
||||
} from './helpers'
|
||||
import TechRevVerdictButtons from './TechRevVerdictButtons.vue'
|
||||
import type { ClassGroup, FlagItem, FlattenedFileReport, JarGroup } from './types'
|
||||
import { injectTechReviewDecisions } from './use-tech-review-decisions'
|
||||
|
||||
const props = defineProps<{
|
||||
file: FlattenedFileReport
|
||||
focusedDetailId?: string | null
|
||||
loadingIssues: Set<string>
|
||||
decompiledSources: Map<string, string>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
refetch: []
|
||||
loadIssueSources: [issueIds: string[]]
|
||||
allFlagsResolved: []
|
||||
}>()
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const {
|
||||
updatingDetails,
|
||||
updatingGlobalDetailKeys,
|
||||
getDetailDecision,
|
||||
isPreReviewed,
|
||||
getFileMarkedCount,
|
||||
getMarkedFlagsCount,
|
||||
isDetailGloballyPassed,
|
||||
isDetailGloballyResolved,
|
||||
applyDecisionToRelatedDetails,
|
||||
getToggledDetailVerdict,
|
||||
updateIssueDetails,
|
||||
updateGlobalIssueDetails,
|
||||
} = injectTechReviewDecisions()
|
||||
|
||||
const hideGloballyPassed = ref(true)
|
||||
const isBatchUpdating = ref(false)
|
||||
const expandedClasses = reactive<Set<string>>(new Set())
|
||||
const autoExpandedFileIds = reactive<Set<string>>(new Set())
|
||||
const showCopyFeedback = reactive<Map<string, boolean>>(new Map())
|
||||
const highlightedSourceCache = reactive<Map<string, { source: string; lines: string[] }>>(new Map())
|
||||
const LAZY_LOAD_CLASS_SOURCE_MINIMUM = 2
|
||||
|
||||
const globallyPassedCount = computed(() => {
|
||||
return props.file.issues.reduce(
|
||||
(count, issue) => count + issue.details.filter(isDetailGloballyPassed).length,
|
||||
0,
|
||||
)
|
||||
})
|
||||
|
||||
const globallyResolvedCount = computed(() => {
|
||||
return props.file.issues.reduce(
|
||||
(count, issue) => count + issue.details.filter(isDetailGloballyResolved).length,
|
||||
0,
|
||||
)
|
||||
})
|
||||
|
||||
const remainingUnmarkedCount = computed(() => {
|
||||
return getFileDetailCount(props.file) - getFileMarkedCount(props.file)
|
||||
})
|
||||
|
||||
const selectedFileFlags = computed<FlagItem[]>(() =>
|
||||
props.file.issues.flatMap((issue) =>
|
||||
issue.details.map((detail) => ({
|
||||
issueId: issue.id,
|
||||
issueType: issue.issue_type,
|
||||
detail,
|
||||
})),
|
||||
),
|
||||
)
|
||||
|
||||
function getJarFlags(jarGroup: JarGroup): FlagItem[] {
|
||||
return jarGroup.classes.flatMap((classItem) => classItem.flags)
|
||||
}
|
||||
|
||||
function getJarRemainingUnmarkedCount(jarGroup: JarGroup): number {
|
||||
const flags = getJarFlags(jarGroup)
|
||||
return flags.length - getMarkedFlagsCount(flags)
|
||||
}
|
||||
|
||||
function getRemainingGlobalDetailCount(flags: FlagItem[]): number {
|
||||
return new Set(
|
||||
flags
|
||||
.filter(
|
||||
(flag) =>
|
||||
getDetailDecision(flag.detail.id, flag.detail.status) === 'pending' &&
|
||||
canUpdateGlobalDetail(flag.detail),
|
||||
)
|
||||
.map((flag) => flag.detail.key),
|
||||
).size
|
||||
}
|
||||
|
||||
function maybeReturnToFileList() {
|
||||
if (getFileMarkedCount(props.file) === getFileDetailCount(props.file)) {
|
||||
emit('allFlagsResolved')
|
||||
}
|
||||
}
|
||||
|
||||
async function batchMarkRemainingGlobally(flags: FlagItem[], verdict: 'safe' | 'unsafe') {
|
||||
if (isBatchUpdating.value) return
|
||||
|
||||
const detailsByKey = new Map(
|
||||
flags
|
||||
.filter(
|
||||
(flag) =>
|
||||
getDetailDecision(flag.detail.id, flag.detail.status) === 'pending' &&
|
||||
canUpdateGlobalDetail(flag.detail),
|
||||
)
|
||||
.map((flag) => [flag.detail.key, flag.detail]),
|
||||
)
|
||||
const details = [...detailsByKey.values()]
|
||||
|
||||
if (details.length === 0) return
|
||||
|
||||
isBatchUpdating.value = true
|
||||
try {
|
||||
await updateGlobalIssueDetails(details.map((detail) => ({ detail_key: detail.key, verdict })))
|
||||
|
||||
applyDecisionToRelatedDetails(
|
||||
details.map((detail) => detail.id),
|
||||
verdictToDecision(verdict),
|
||||
'global',
|
||||
)
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: `Globally marked ${details.length} trace keys as ${verdict}`,
|
||||
text: `All remaining eligible traces have been globally marked as ${
|
||||
verdict === 'safe' ? 'false positives' : 'malicious'
|
||||
}.`,
|
||||
})
|
||||
|
||||
maybeReturnToFileList()
|
||||
emit('refetch')
|
||||
} catch (error) {
|
||||
console.error('Failed to batch update global traces:', error)
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Global batch update failed',
|
||||
text: 'An error occurred while globally updating traces.',
|
||||
})
|
||||
} finally {
|
||||
isBatchUpdating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function batchMarkRemaining(flags: FlagItem[], verdict: 'safe' | 'unsafe', inJar = false) {
|
||||
if (isBatchUpdating.value) return
|
||||
|
||||
const detailIds = flags
|
||||
.filter((flag) => getDetailDecision(flag.detail.id, flag.detail.status) === 'pending')
|
||||
.map((flag) => flag.detail.id)
|
||||
|
||||
if (detailIds.length === 0) return
|
||||
|
||||
isBatchUpdating.value = true
|
||||
try {
|
||||
await updateIssueDetails(detailIds.map((detail_id) => ({ detail_id, verdict })))
|
||||
applyDecisionToRelatedDetails(detailIds, verdictToDecision(verdict), 'local')
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: `Marked ${detailIds.length} traces as ${verdict}`,
|
||||
text: `All remaining traces${inJar ? ' in this JAR' : ''} have been marked as ${
|
||||
verdict === 'safe' ? 'false positives' : 'malicious'
|
||||
}.`,
|
||||
})
|
||||
|
||||
maybeReturnToFileList()
|
||||
emit('refetch')
|
||||
} catch (error) {
|
||||
console.error('Failed to batch update:', error)
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Batch update failed',
|
||||
text: 'An error occurred while updating traces.',
|
||||
})
|
||||
} finally {
|
||||
isBatchUpdating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function updateLocalDetailAction(
|
||||
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
|
||||
decision: 'safe' | 'malware',
|
||||
) {
|
||||
return updateDetailStatus(detail.id, getToggledDetailVerdict(detail, decision, 'local'))
|
||||
}
|
||||
|
||||
function updateGlobalDetailAction(
|
||||
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
|
||||
decision: 'safe' | 'malware',
|
||||
) {
|
||||
return updateGlobalDetailStatus(detail, getToggledDetailVerdict(detail, decision, 'global'))
|
||||
}
|
||||
|
||||
async function updateDetailStatus(
|
||||
detailId: string,
|
||||
verdict: Labrinth.TechReview.Internal.DelphiReportIssueStatus,
|
||||
) {
|
||||
const detail = props.file.issues.flatMap((issue) => issue.details).find((d) => d.id === detailId)
|
||||
const priorDecision = detail ? getDetailDecision(detail.id, detail.status) : 'pending'
|
||||
|
||||
updatingDetails.add(detailId)
|
||||
|
||||
const previousMarkedCount = getFileMarkedCount(props.file)
|
||||
|
||||
try {
|
||||
await updateIssueDetails([{ detail_id: detailId, verdict }])
|
||||
|
||||
const { otherMatchedCount } = applyDecisionToRelatedDetails(
|
||||
[detailId],
|
||||
verdictToDecision(verdict),
|
||||
'local',
|
||||
)
|
||||
|
||||
if (verdict !== 'pending' && priorDecision === 'pending') {
|
||||
for (const classGroup of groupedByClass.value) {
|
||||
const hasThisDetail = classGroup.flags.some((f) => f.detail.id === detailId)
|
||||
if (hasThisDetail && getMarkedFlagsCount(classGroup.flags) === classGroup.flags.length) {
|
||||
expandedClasses.delete(classGroup.key)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (verdict !== 'pending') {
|
||||
const markedCount = getFileMarkedCount(props.file)
|
||||
const totalCount = getFileDetailCount(props.file)
|
||||
if (previousMarkedCount != markedCount && markedCount === totalCount) {
|
||||
emit('allFlagsResolved')
|
||||
}
|
||||
}
|
||||
|
||||
const otherText =
|
||||
otherMatchedCount > 0
|
||||
? ` (${otherMatchedCount} other trace${otherMatchedCount === 1 ? '' : 's'} also marked)`
|
||||
: ''
|
||||
|
||||
if (verdict === 'pending') {
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Local trace verdict unset',
|
||||
text: `The project-local verdict has been removed.${otherText}`,
|
||||
})
|
||||
} else if (verdict === 'safe') {
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Issue marked as pass',
|
||||
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.${otherText}`,
|
||||
})
|
||||
}
|
||||
|
||||
emit('refetch')
|
||||
} catch (error) {
|
||||
console.error('Failed to update detail status:', error)
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Failed to update issue',
|
||||
text: 'An error occurred while updating the issue status.',
|
||||
})
|
||||
} finally {
|
||||
updatingDetails.delete(detailId)
|
||||
}
|
||||
}
|
||||
|
||||
async function updateGlobalDetailStatus(
|
||||
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
|
||||
verdict: Labrinth.TechReview.Internal.DelphiReportIssueStatus,
|
||||
) {
|
||||
if (!canUpdateGlobalDetail(detail)) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Global update unavailable',
|
||||
text: 'Generated trace keys cannot be marked globally.',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
updatingGlobalDetailKeys.add(detail.key)
|
||||
|
||||
const previousMarkedCount = getFileMarkedCount(props.file)
|
||||
|
||||
try {
|
||||
await updateGlobalIssueDetails([{ detail_key: detail.key, verdict }])
|
||||
|
||||
const { otherMatchedCount } = applyDecisionToRelatedDetails(
|
||||
[detail.id],
|
||||
verdictToDecision(verdict),
|
||||
'global',
|
||||
)
|
||||
|
||||
if (verdict !== 'pending') {
|
||||
for (const classGroup of groupedByClass.value) {
|
||||
if (getMarkedFlagsCount(classGroup.flags) === classGroup.flags.length) {
|
||||
expandedClasses.delete(classGroup.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (verdict !== 'pending') {
|
||||
const markedCount = getFileMarkedCount(props.file)
|
||||
const totalCount = getFileDetailCount(props.file)
|
||||
if (previousMarkedCount != markedCount && markedCount === totalCount) {
|
||||
emit('allFlagsResolved')
|
||||
}
|
||||
}
|
||||
|
||||
const otherText =
|
||||
otherMatchedCount > 0
|
||||
? ` (${otherMatchedCount} other trace${otherMatchedCount === 1 ? '' : 's'} also marked in this project)`
|
||||
: ''
|
||||
|
||||
if (verdict === 'pending') {
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Global trace verdict unset',
|
||||
text: `The global verdict for this trace key has been removed.${otherText}`,
|
||||
})
|
||||
} else {
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title:
|
||||
verdict === 'safe' ? 'Trace globally marked as pass' : 'Trace globally marked as fail',
|
||||
text:
|
||||
verdict === 'safe'
|
||||
? `This trace key has been marked as a global false positive.${otherText}`
|
||||
: `This trace key has been globally flagged as malicious.${otherText}`,
|
||||
})
|
||||
}
|
||||
|
||||
emit('refetch')
|
||||
} catch (error) {
|
||||
console.error('Failed to update global detail status:', error)
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Failed to update global trace',
|
||||
text: 'An error occurred while updating the global trace status.',
|
||||
})
|
||||
} finally {
|
||||
updatingGlobalDetailKeys.delete(detail.key)
|
||||
}
|
||||
}
|
||||
|
||||
function splitJarSegments(jar: string | null, currentFileName: string | null): string[] {
|
||||
if (!jar) return []
|
||||
const segments = jar
|
||||
.split(/[/#]/)
|
||||
.map((s) => decodeURIComponent(s.trim()))
|
||||
.filter((s) => s.length > 0)
|
||||
if (segments.length > 0 && currentFileName && segments[0] === currentFileName) {
|
||||
return segments.slice(1)
|
||||
}
|
||||
return segments
|
||||
}
|
||||
|
||||
const groupedByClass = computed<ClassGroup[]>(() => {
|
||||
const classMap = new Map<string, ClassGroup>()
|
||||
|
||||
for (const issue of props.file.issues) {
|
||||
for (const detail of issue.details) {
|
||||
if (hideGloballyPassed.value && isDetailGloballyPassed(detail)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const classKey = `${detail.jar ?? ''}::${detail.file_path}`
|
||||
if (!classMap.has(classKey)) {
|
||||
classMap.set(classKey, {
|
||||
key: classKey,
|
||||
jar: detail.jar ?? null,
|
||||
filePath: detail.file_path,
|
||||
flags: [],
|
||||
})
|
||||
}
|
||||
classMap.get(classKey)!.flags.push({
|
||||
issueId: issue.id,
|
||||
issueType: issue.issue_type,
|
||||
detail,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const classGroup of classMap.values()) {
|
||||
classGroup.flags.sort((a, b) => {
|
||||
const aPreReviewed = isPreReviewed(a.detail.id, a.detail.status)
|
||||
const bPreReviewed = isPreReviewed(b.detail.id, b.detail.status)
|
||||
return aPreReviewed === bPreReviewed ? 0 : aPreReviewed ? 1 : -1
|
||||
})
|
||||
}
|
||||
|
||||
return Array.from(classMap.values())
|
||||
})
|
||||
|
||||
const groupedByJar = computed<JarGroup[]>(() => {
|
||||
const jarMap = new Map<string, JarGroup>()
|
||||
|
||||
for (const classItem of groupedByClass.value) {
|
||||
const jarKey = classItem.jar ?? ''
|
||||
if (!jarMap.has(jarKey)) {
|
||||
jarMap.set(jarKey, {
|
||||
key: jarKey,
|
||||
jar: classItem.jar,
|
||||
segments: splitJarSegments(classItem.jar, props.file.file_name),
|
||||
classes: [],
|
||||
})
|
||||
}
|
||||
jarMap.get(jarKey)!.classes.push(classItem)
|
||||
}
|
||||
|
||||
return Array.from(jarMap.values()).sort((a, b) => {
|
||||
const aRoot = a.segments.length === 0
|
||||
const bRoot = b.segments.length === 0
|
||||
return aRoot === bRoot ? 0 : aRoot ? -1 : 1
|
||||
})
|
||||
})
|
||||
|
||||
function getHighestSeverityInClass(flags: FlagItem[]): Labrinth.TechReview.Internal.DelphiSeverity {
|
||||
return flags.reduce(
|
||||
(highest, flag) =>
|
||||
severityOrder[flag.detail.severity] > severityOrder[highest] ? flag.detail.severity : highest,
|
||||
'low' as Labrinth.TechReview.Internal.DelphiSeverity,
|
||||
)
|
||||
}
|
||||
|
||||
function getClassDecompiledSource(classItem: ClassGroup): string | undefined {
|
||||
for (const flag of classItem.flags) {
|
||||
const source = props.decompiledSources.get(flag.detail.id)
|
||||
if (source) return source
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function getHighlightedClassSource(classItem: ClassGroup): string[] {
|
||||
const source = getClassDecompiledSource(classItem)
|
||||
if (!source) return []
|
||||
|
||||
const cached = highlightedSourceCache.get(classItem.key)
|
||||
if (cached?.source === source) return cached.lines
|
||||
|
||||
const lines = highlightCodeLines(source, 'java')
|
||||
highlightedSourceCache.set(classItem.key, { source, lines })
|
||||
return lines
|
||||
}
|
||||
|
||||
function isClassLoadingSource(classItem: ClassGroup): boolean {
|
||||
return classItem.flags.some((flag) => props.loadingIssues.has(flag.issueId))
|
||||
}
|
||||
|
||||
function loadClassSources(classItem: ClassGroup) {
|
||||
const issueIds = [...new Set(classItem.flags.map((flag) => flag.issueId))]
|
||||
if (issueIds.length > 0) {
|
||||
emit('loadIssueSources', issueIds)
|
||||
}
|
||||
}
|
||||
|
||||
function expandClass(classItem: ClassGroup) {
|
||||
if (expandedClasses.has(classItem.key)) return
|
||||
expandedClasses.add(classItem.key)
|
||||
loadClassSources(classItem)
|
||||
}
|
||||
|
||||
function toggleClass(classItem: ClassGroup) {
|
||||
if (expandedClasses.has(classItem.key)) {
|
||||
expandedClasses.delete(classItem.key)
|
||||
} else {
|
||||
expandClass(classItem)
|
||||
}
|
||||
}
|
||||
|
||||
async function copyToClipboard(code: string, detailId: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code)
|
||||
showCopyFeedback.set(detailId, true)
|
||||
setTimeout(() => {
|
||||
showCopyFeedback.delete(detailId)
|
||||
}, 2000)
|
||||
} catch (error) {
|
||||
console.error('Failed to copy code:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function focusDetail(detailId: string) {
|
||||
await nextTick()
|
||||
|
||||
const classItem = groupedByClass.value.find((group) =>
|
||||
group.flags.some((flag) => flag.detail.id === detailId),
|
||||
)
|
||||
|
||||
if (classItem) {
|
||||
expandClass(classItem)
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
|
||||
if (!import.meta.client) return
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
document.getElementById(`tech-review-detail-${detailId}`)?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
[() => props.focusedDetailId, () => props.file.id],
|
||||
([detailId]) => {
|
||||
if (detailId) {
|
||||
focusDetail(detailId)
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
[() => props.file.id, groupedByClass],
|
||||
([fileId, classes]) => {
|
||||
if (!fileId || classes.length === 0 || autoExpandedFileIds.has(fileId)) return
|
||||
|
||||
autoExpandedFileIds.add(fileId)
|
||||
|
||||
if (classes.length < LAZY_LOAD_CLASS_SOURCE_MINIMUM) {
|
||||
for (const classItem of classes) {
|
||||
expandClass(classItem)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="getFileDetailCount(file) > 0"
|
||||
class="flex flex-wrap items-center justify-between gap-3 p-4"
|
||||
>
|
||||
<TechRevVerdictButtons
|
||||
v-if="remainingUnmarkedCount > 0"
|
||||
variant="remaining"
|
||||
:remaining-count="remainingUnmarkedCount"
|
||||
:global-disabled="isBatchUpdating || getRemainingGlobalDetailCount(selectedFileFlags) === 0"
|
||||
:local-disabled="isBatchUpdating"
|
||||
@global-safe="batchMarkRemainingGlobally(selectedFileFlags, 'safe')"
|
||||
@local-safe="batchMarkRemaining(selectedFileFlags, 'safe')"
|
||||
@local-unsafe="batchMarkRemaining(selectedFileFlags, 'unsafe')"
|
||||
@global-unsafe="batchMarkRemainingGlobally(selectedFileFlags, 'unsafe')"
|
||||
/>
|
||||
<label class="ml-auto flex cursor-pointer items-center gap-3 text-sm">
|
||||
<span class="text-right text-secondary">
|
||||
Hide globally passed
|
||||
<span class="text-tertiary block text-xs">
|
||||
{{ globallyResolvedCount }}/{{ getFileDetailCount(file) }} traces globally resolved
|
||||
</span>
|
||||
</span>
|
||||
<Toggle v-model="hideGloballyPassed" :disabled="globallyPassedCount === 0" small />
|
||||
</label>
|
||||
</div>
|
||||
<div v-for="jarGroup in groupedByJar" :key="jarGroup.key" class="flex flex-col gap-1 px-4 pb-4">
|
||||
<div v-if="jarGroup.segments.length > 0" class="my-2">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
<template v-for="(segment, index) in jarGroup.segments" :key="`${jarGroup.key}-${index}`">
|
||||
<span
|
||||
class="font-mono text-sm"
|
||||
:class="
|
||||
index === jarGroup.segments.length - 1
|
||||
? 'font-semibold text-contrast'
|
||||
: 'text-secondary'
|
||||
"
|
||||
>
|
||||
{{ segment }}
|
||||
</span>
|
||||
<ChevronRightIcon
|
||||
v-if="index < jarGroup.segments.length - 1"
|
||||
class="size-4 text-secondary"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<TechRevVerdictButtons
|
||||
v-if="getJarRemainingUnmarkedCount(jarGroup) > 0"
|
||||
variant="remaining"
|
||||
jar
|
||||
:remaining-count="getJarRemainingUnmarkedCount(jarGroup)"
|
||||
:global-disabled="
|
||||
isBatchUpdating || getRemainingGlobalDetailCount(getJarFlags(jarGroup)) === 0
|
||||
"
|
||||
:local-disabled="isBatchUpdating"
|
||||
@global-safe="batchMarkRemainingGlobally(getJarFlags(jarGroup), 'safe')"
|
||||
@local-safe="batchMarkRemaining(getJarFlags(jarGroup), 'safe', true)"
|
||||
@local-unsafe="batchMarkRemaining(getJarFlags(jarGroup), 'unsafe', true)"
|
||||
@global-unsafe="batchMarkRemainingGlobally(getJarFlags(jarGroup), 'unsafe')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="classItem in jarGroup.classes"
|
||||
:key="classItem.key"
|
||||
class="overflow-clip rounded-xl border border-solid border-surface-4"
|
||||
>
|
||||
<div
|
||||
class="flex cursor-pointer items-center justify-between bg-surface-3 p-2 transition-colors duration-200 hover:bg-surface-4"
|
||||
@click="toggleClass(classItem)"
|
||||
>
|
||||
<div class="my-auto flex items-center gap-2">
|
||||
<IconButton
|
||||
type="quiet"
|
||||
label="Toggle details"
|
||||
class="transition-transform"
|
||||
:class="{ 'rotate-180': expandedClasses.has(classItem.key) }"
|
||||
>
|
||||
<ChevronDownIcon class="h-5 w-5 text-contrast" />
|
||||
</IconButton>
|
||||
|
||||
<span v-tooltip="classItem.filePath" class="font-mono text-sm font-semibold">{{
|
||||
truncateMiddle(classItem.filePath)
|
||||
}}</span>
|
||||
|
||||
<div
|
||||
class="rounded-full border-solid px-2.5 py-1"
|
||||
:class="getSeverityBadgeColor(getHighestSeverityInClass(classItem.flags))"
|
||||
>
|
||||
<span class="text-sm font-medium">{{
|
||||
capitalizeString(getHighestSeverityInClass(classItem.flags))
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-center gap-1 rounded-full border border-solid px-2.5 py-1 text-sm"
|
||||
:class="
|
||||
getMarkedFlagsCount(classItem.flags) === classItem.flags.length
|
||||
? 'border-green/60 bg-highlight-green text-green'
|
||||
: 'border-red/60 bg-highlight-red text-red'
|
||||
"
|
||||
>
|
||||
<CheckIcon
|
||||
v-if="getMarkedFlagsCount(classItem.flags) === classItem.flags.length"
|
||||
class="size-4"
|
||||
/>
|
||||
{{ getMarkedFlagsCount(classItem.flags) }}/{{ classItem.flags.length }} flags
|
||||
</div>
|
||||
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="isClassLoadingSource(classItem)"
|
||||
class="rounded-full border border-solid border-surface-5 bg-surface-3 px-2.5 py-1"
|
||||
>
|
||||
<span class="flex items-center gap-1.5 text-sm font-medium text-secondary">
|
||||
<LoaderCircleIcon class="size-4 animate-spin" />
|
||||
Loading source...
|
||||
</span>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Collapsible :collapsed="!expandedClasses.has(classItem.key)">
|
||||
<div class="flex flex-col gap-2 border-0 border-t border-solid border-surface-4 p-2">
|
||||
<div
|
||||
v-for="flag in classItem.flags"
|
||||
:id="`tech-review-detail-${flag.detail.id}`"
|
||||
:key="`${flag.issueId}-${flag.detail.id}`"
|
||||
class="flex flex-col gap-2 rounded-lg border border-solid border-surface-5 bg-surface-3 py-2 pl-4 last:border-b-0"
|
||||
:class="{
|
||||
'!border-brand bg-brand-highlight': focusedDetailId === flag.detail.id,
|
||||
}"
|
||||
>
|
||||
<div class="grid grid-cols-[1fr_auto] items-center">
|
||||
<div
|
||||
class="flex items-center gap-2"
|
||||
:class="{
|
||||
'opacity-50': isPreReviewed(flag.detail.id, flag.detail.status),
|
||||
}"
|
||||
>
|
||||
<span class="text-base font-semibold text-contrast">{{
|
||||
flag.issueType.replace(/_/g, ' ')
|
||||
}}</span>
|
||||
<div
|
||||
class="rounded-full border-solid px-2.5 py-1"
|
||||
:class="getSeverityBadgeColor(flag.detail.severity)"
|
||||
>
|
||||
<span class="text-sm font-medium">{{
|
||||
capitalizeString(flag.detail.severity)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="me-2 flex items-center justify-end gap-2">
|
||||
<TechRevVerdictButtons
|
||||
variant="trace"
|
||||
:detail="flag.detail"
|
||||
@global-safe="updateGlobalDetailAction(flag.detail, 'safe')"
|
||||
@local-safe="updateLocalDetailAction(flag.detail, 'safe')"
|
||||
@local-unsafe="updateLocalDetailAction(flag.detail, 'malware')"
|
||||
@global-unsafe="updateGlobalDetailAction(flag.detail, 'malware')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="flag.detail.data && Object.keys(flag.detail.data).length > 0"
|
||||
class="flex flex-wrap gap-x-4 gap-y-1 pr-4 text-sm"
|
||||
>
|
||||
<div
|
||||
v-for="[key, value] in Object.entries(flag.detail.data).sort(([a], [b]) =>
|
||||
a.localeCompare(b),
|
||||
)"
|
||||
:key="key"
|
||||
class="flex items-center gap-1.5"
|
||||
>
|
||||
<span class="text-secondary">{{ key }}:</span>
|
||||
<a
|
||||
v-if="typeof value === 'string' && value.startsWith('http')"
|
||||
:href="value"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-brand-blue hover:underline"
|
||||
>
|
||||
{{ value }}
|
||||
</a>
|
||||
<span v-else class="font-mono text-contrast">{{ value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="getHighlightedClassSource(classItem).length > 0"
|
||||
class="relative inset-0 overflow-hidden rounded-lg border border-solid border-surface-5 bg-surface-4"
|
||||
>
|
||||
<IconButton
|
||||
v-tooltip="`Copy code`"
|
||||
type="quiet"
|
||||
:label="`Copy code`"
|
||||
class="!absolute right-2 top-2 border-[1px]"
|
||||
@click="copyToClipboard(getClassDecompiledSource(classItem)!, classItem.key)"
|
||||
>
|
||||
<CopyIcon v-if="!showCopyFeedback.get(classItem.key)" />
|
||||
<CheckIcon v-else />
|
||||
</IconButton>
|
||||
|
||||
<div class="overflow-x-auto bg-surface-3 py-3">
|
||||
<div
|
||||
v-for="(line, n) in getHighlightedClassSource(classItem)"
|
||||
:key="n"
|
||||
class="flex font-mono text-[13px] leading-[1.6]"
|
||||
>
|
||||
<div
|
||||
class="select-none border-0 border-r border-solid border-surface-5 px-4 py-0 text-right text-primary"
|
||||
style="min-width: 3.5rem"
|
||||
>
|
||||
{{ n + 1 }}
|
||||
</div>
|
||||
<div class="flex-1 px-4 py-0 text-primary">
|
||||
<pre v-html="line || ' '"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="isClassLoadingSource(classItem)"
|
||||
class="rounded-lg border border-solid border-surface-5 bg-surface-3 p-4"
|
||||
>
|
||||
<p class="flex items-center gap-2 text-sm text-secondary">
|
||||
<LoaderCircleIcon class="size-4 animate-spin" />
|
||||
Loading source...
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="rounded-lg border border-solid border-surface-5 bg-surface-3 p-4">
|
||||
<p class="text-sm text-secondary">
|
||||
Source code not available or failed to decompile for this file.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
pre {
|
||||
all: unset;
|
||||
display: inline;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.fade-enter-active {
|
||||
transition: opacity 0.3s ease-in;
|
||||
transition-delay: 0.2s;
|
||||
}
|
||||
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.15s ease-out;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,133 @@
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, DownloadIcon, ExternalIcon, VersionIcon } from '@modrinth/assets'
|
||||
import { ButtonLink, useFormatBytes } from '@modrinth/ui'
|
||||
import { capitalizeString } from '@modrinth/utils'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import {
|
||||
getFileDetailCount,
|
||||
getFileHighestSeverity,
|
||||
getSeverityBadgeColor,
|
||||
getVersionLabel,
|
||||
getVersionPageHref,
|
||||
truncateMiddle,
|
||||
} from './helpers'
|
||||
import type { FlattenedFileReport } from './types'
|
||||
import { injectTechReviewDecisions } from './use-tech-review-decisions'
|
||||
|
||||
const props = defineProps<{
|
||||
reports: FlattenedFileReport[]
|
||||
project: {
|
||||
id: string
|
||||
slug?: string
|
||||
project_types: string[]
|
||||
}
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
viewFlags: [file: FlattenedFileReport]
|
||||
}>()
|
||||
|
||||
const formatBytes = useFormatBytes()
|
||||
const { getFileMarkedCount } = injectTechReviewDecisions()
|
||||
|
||||
const allFiles = computed(() => {
|
||||
return [...props.reports].sort((a, b) => {
|
||||
const aComplete = getFileMarkedCount(a) === getFileDetailCount(a)
|
||||
const bComplete = getFileMarkedCount(b) === getFileDetailCount(b)
|
||||
return aComplete === bComplete ? 0 : aComplete ? 1 : -1
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-for="(file, idx) in allFiles"
|
||||
:key="idx"
|
||||
class="flex items-center justify-between border-0 border-x border-b border-solid border-surface-3 bg-surface-2 px-4 py-3"
|
||||
:class="{
|
||||
'rounded-bl-2xl rounded-br-2xl': idx === allFiles.length - 1,
|
||||
'bg-[#E8E8E8] dark:bg-[#1A1C20]': idx % 2 === 1,
|
||||
}"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
v-tooltip="file.file_name"
|
||||
class="py-2 font-medium text-contrast"
|
||||
:aria-label="`View flags for ${file.file_name}`"
|
||||
tabindex="0"
|
||||
:class="{ 'cursor-pointer hover:underline': getFileDetailCount(file) > 0 }"
|
||||
@click="getFileDetailCount(file) > 0 && emit('viewFlags', file)"
|
||||
>
|
||||
{{ truncateMiddle(file.file_name, 50) }}
|
||||
</span>
|
||||
<div class="rounded-full border border-solid border-surface-5 bg-surface-3 px-2.5 py-1">
|
||||
<span class="text-sm font-medium text-secondary">{{ formatBytes(file.file_size) }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="getFileDetailCount(file) > 0"
|
||||
class="rounded-full border-solid px-2.5 py-1"
|
||||
:class="getSeverityBadgeColor(getFileHighestSeverity(file))"
|
||||
>
|
||||
<span class="text-sm font-medium">{{
|
||||
capitalizeString(getFileHighestSeverity(file))
|
||||
}}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="getFileDetailCount(file) > 0"
|
||||
class="flex items-center gap-1 rounded-full border border-solid px-2.5 py-1 text-sm"
|
||||
:class="
|
||||
getFileMarkedCount(file) === getFileDetailCount(file)
|
||||
? 'border-green/60 bg-highlight-green text-green'
|
||||
: 'border-red/60 bg-highlight-red text-red'
|
||||
"
|
||||
>
|
||||
<CheckIcon v-if="getFileMarkedCount(file) === getFileDetailCount(file)" class="size-4" />
|
||||
{{ getFileMarkedCount(file) }}/{{ getFileDetailCount(file) }} flags
|
||||
</div>
|
||||
<!-- TODO: remove toString when backend supports it properly -->
|
||||
<div
|
||||
v-else-if="file.flag_reason.toString() === 'manual'"
|
||||
class="border-blue/60 flex items-center gap-1 rounded-full border border-solid bg-highlight-blue px-2.5 py-1 text-sm text-blue"
|
||||
>
|
||||
Manual review
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="border-green/60 flex items-center gap-1 rounded-full border border-solid bg-highlight-green px-2.5 py-1 text-sm text-green"
|
||||
>
|
||||
No flags
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<ButtonLink
|
||||
type="outlined"
|
||||
target="_blank"
|
||||
:href="getVersionPageHref(project, file.version_id)"
|
||||
:aria-label="`Open version ${getVersionLabel(file)}`"
|
||||
>
|
||||
<VersionIcon aria-hidden="true" /> {{ getVersionLabel(file) }}
|
||||
</ButtonLink>
|
||||
<ButtonLink
|
||||
type="outlined"
|
||||
target="_blank"
|
||||
:href="`https://slicer.run/?url=${encodeURIComponent(file.download_url)}`"
|
||||
aria-label="Open in Slicer"
|
||||
>
|
||||
<ExternalIcon aria-hidden="true" /> Slicer
|
||||
</ButtonLink>
|
||||
<ButtonLink
|
||||
v-tooltip="`Download ${file.file_name} (${formatBytes(file.file_size)})`"
|
||||
type="outlined"
|
||||
:href="file.download_url"
|
||||
:download="file.file_name"
|
||||
tabindex="0"
|
||||
icon-only
|
||||
circular
|
||||
>
|
||||
<DownloadIcon />
|
||||
</ButtonLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,416 @@
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
BugIcon,
|
||||
CheckIcon,
|
||||
DropdownIcon,
|
||||
EyeOffIcon,
|
||||
ScaleIcon,
|
||||
ShieldCheckIcon,
|
||||
SpinnerIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { type TechReviewContext, techReviewQuickReplies } from '@modrinth/moderation'
|
||||
import {
|
||||
Button,
|
||||
type ButtonMenuOption,
|
||||
CollapsibleRegion,
|
||||
commonMessages,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
TeleportOverflowMenu,
|
||||
useFormatBytes,
|
||||
useFormatDateTime,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { capitalizeString, type ThreadMessage, type User } from '@modrinth/utils'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { UnsafeFile } from '~/components/ui/moderation/MaliciousSummaryModal.vue'
|
||||
import ThreadView from '~/components/ui/thread/ThreadView.vue'
|
||||
|
||||
import { severityOrder } from './helpers'
|
||||
import type { FlattenedFileReport } from './types'
|
||||
import { injectTechReviewDecisions } from './use-tech-review-decisions'
|
||||
|
||||
const props = defineProps<{
|
||||
project: Labrinth.Projects.v3.Project
|
||||
projectOwner: Labrinth.TechReview.Internal.Ownership
|
||||
thread: Labrinth.TechReview.Internal.Thread
|
||||
reports: FlattenedFileReport[]
|
||||
disableCollapsing?: boolean
|
||||
}>()
|
||||
|
||||
const isThreadCollapsed = defineModel<boolean>('collapsed', { required: true })
|
||||
|
||||
const emit = defineEmits<{
|
||||
refetch: []
|
||||
markComplete: [projectId: string]
|
||||
showMaliciousSummary: [unsafeFiles: UnsafeFile[]]
|
||||
statusChanged: [status: Labrinth.Projects.v2.ProjectStatus]
|
||||
}>()
|
||||
|
||||
const auth = useAuthState()
|
||||
const featureFlags = useFeatureFlags()
|
||||
const { formatMessage } = useVIntl()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const client = injectModrinthClient()
|
||||
const { getDetailDecision } = injectTechReviewDecisions()
|
||||
const formatBytes = useFormatBytes()
|
||||
|
||||
const formatDateTimeUtc = useFormatDateTime({
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
timeZoneName: 'short',
|
||||
timeZone: 'UTC',
|
||||
})
|
||||
|
||||
const remainingMessageCount = computed(() => {
|
||||
if (!props.thread?.messages) return 0
|
||||
return Math.max(0, props.thread.messages.length - 1)
|
||||
})
|
||||
|
||||
const threadExpandText = computed(() => {
|
||||
if (remainingMessageCount.value === 0) return 'Expand'
|
||||
if (remainingMessageCount.value === 1) return 'Show 1 more message'
|
||||
return `Show ${remainingMessageCount.value} more messages`
|
||||
})
|
||||
|
||||
const projectStatus = ref<Labrinth.Projects.v2.ProjectStatus>(props.project.status)
|
||||
const isLoadingStatusAction = ref(false)
|
||||
|
||||
function isStatusActionDisabled(status: Labrinth.Projects.v2.ProjectStatus): boolean {
|
||||
return projectStatus.value === status || isLoadingStatusAction.value
|
||||
}
|
||||
|
||||
async function setStatus(status: Labrinth.Projects.v2.ProjectStatus) {
|
||||
isLoadingStatusAction.value = true
|
||||
try {
|
||||
await client.labrinth.projects_v2.edit(props.project.id, { status })
|
||||
emit('refetch')
|
||||
projectStatus.value = status
|
||||
emit('statusChanged', status)
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.errorNotificationTitle),
|
||||
text: (err as any)?.data?.description ? (err as any).data.description : String(err),
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
isLoadingStatusAction.value = false
|
||||
}
|
||||
|
||||
const projectStatusActions = computed<ButtonMenuOption[]>(() => [
|
||||
{
|
||||
id: 'approve',
|
||||
label: 'Approve',
|
||||
icon: CheckIcon,
|
||||
tone: 'green',
|
||||
hoverFilled: true,
|
||||
action: () => setStatus('approved'),
|
||||
disabled: isStatusActionDisabled('approved'),
|
||||
},
|
||||
{
|
||||
id: 'withhold',
|
||||
label: 'Withhold',
|
||||
icon: EyeOffIcon,
|
||||
tone: 'orange',
|
||||
hoverFilled: true,
|
||||
action: () => setStatus('withheld'),
|
||||
disabled: isStatusActionDisabled('withheld'),
|
||||
},
|
||||
{
|
||||
id: 'send-to-review',
|
||||
label: 'Send to review',
|
||||
icon: ScaleIcon,
|
||||
action: () => setStatus('processing'),
|
||||
disabled: isStatusActionDisabled('processing'),
|
||||
},
|
||||
{
|
||||
id: 'reject',
|
||||
label: 'Reject',
|
||||
icon: XIcon,
|
||||
tone: 'red',
|
||||
hoverFilled: true,
|
||||
action: () => setStatus('rejected'),
|
||||
disabled: isStatusActionDisabled('rejected'),
|
||||
},
|
||||
])
|
||||
|
||||
const techReviewContext = computed<TechReviewContext>(() => ({
|
||||
project: props.project,
|
||||
project_owner: props.projectOwner,
|
||||
reports: props.reports,
|
||||
}))
|
||||
|
||||
const threadViewRef = ref<{
|
||||
setReplyContent: (content: string) => void
|
||||
getReplyContent: () => string
|
||||
} | null>(null)
|
||||
|
||||
const unsafeFiles = computed<UnsafeFile[]>(() => {
|
||||
return props.reports
|
||||
.filter((report) =>
|
||||
report.issues.some((issue) =>
|
||||
issue.details.some((detail) => getDetailDecision(detail.id, detail.status) === 'malware'),
|
||||
),
|
||||
)
|
||||
.map((report) => ({
|
||||
file: report,
|
||||
projectName: props.project.name,
|
||||
projectId: props.project.id,
|
||||
userId: props.projectOwner.id,
|
||||
username: props.projectOwner.name,
|
||||
}))
|
||||
})
|
||||
|
||||
const reviewSummaryPreview = computed(() => {
|
||||
const fileDecisions = new Map<
|
||||
string,
|
||||
{
|
||||
fileName: string
|
||||
fileSize: number
|
||||
decisions: {
|
||||
filePath: string
|
||||
issueType: string
|
||||
severity: string
|
||||
decision: 'safe' | 'malware'
|
||||
}[]
|
||||
maxSeverity: Labrinth.TechReview.Internal.DelphiSeverity
|
||||
}
|
||||
>()
|
||||
let totalSafe = 0
|
||||
let totalUnsafe = 0
|
||||
|
||||
for (const report of props.reports) {
|
||||
if (!fileDecisions.has(report.id)) {
|
||||
fileDecisions.set(report.id, {
|
||||
fileName: report.file_name,
|
||||
fileSize: report.file_size,
|
||||
decisions: [],
|
||||
maxSeverity: 'low',
|
||||
})
|
||||
}
|
||||
const fileData = fileDecisions.get(report.id)!
|
||||
|
||||
for (const issue of report.issues) {
|
||||
for (const detail of issue.details) {
|
||||
const decision = getDetailDecision(detail.id, detail.status)
|
||||
if (decision === 'pending') continue
|
||||
|
||||
fileData.decisions.push({
|
||||
filePath: detail.file_path,
|
||||
issueType: issue.issue_type.replace(/_/g, ' '),
|
||||
severity: detail.severity,
|
||||
decision,
|
||||
})
|
||||
|
||||
if (severityOrder[detail.severity] > severityOrder[fileData.maxSeverity]) {
|
||||
fileData.maxSeverity = detail.severity
|
||||
}
|
||||
|
||||
if (decision === 'safe') totalSafe++
|
||||
else totalUnsafe++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const totalDecisions = totalSafe + totalUnsafe
|
||||
if (totalDecisions === 0) return ''
|
||||
|
||||
const timestamp = formatDateTimeUtc(dayjs().toDate())
|
||||
let markdown = `## Tech Review Summary\n*${timestamp}*\n\n`
|
||||
markdown += `<details>\n<summary>File Details (${totalSafe} safe, ${totalUnsafe} unsafe)</summary>\n\n`
|
||||
|
||||
for (const [, fileData] of fileDecisions) {
|
||||
if (fileData.decisions.length === 0) continue
|
||||
|
||||
const fileSafe = fileData.decisions.filter((d) => d.decision === 'safe').length
|
||||
const fileUnsafe = fileData.decisions.filter((d) => d.decision === 'malware').length
|
||||
const fileVerdict = fileUnsafe > 0 ? 'Unsafe' : 'Safe'
|
||||
|
||||
markdown += `### ${fileData.fileName}\n`
|
||||
markdown += `> ${formatBytes(fileData.fileSize)} • ${fileData.decisions.length} issues • Max severity: ${fileData.maxSeverity} • **Verdict:** ${fileVerdict}\n\n`
|
||||
markdown += `<details>\n<summary>Issues (${fileSafe} safe, ${fileUnsafe} unsafe)</summary>\n\n`
|
||||
markdown += `| Class | Issue Type | Severity | Decision |\n`
|
||||
markdown += `|-------|------------|----------|----------|\n`
|
||||
|
||||
for (const d of fileData.decisions) {
|
||||
const decisionText = d.decision === 'safe' ? '✅ Safe' : '❌ Unsafe'
|
||||
markdown += `| \`${d.filePath}\` | ${d.issueType} | ${capitalizeString(d.severity)} | ${decisionText} |\n`
|
||||
}
|
||||
|
||||
markdown += `\n</details>\n\n`
|
||||
}
|
||||
|
||||
markdown += `</details>\n\n`
|
||||
markdown += `---\n\n**Total:** ${totalDecisions} issues reviewed (${totalSafe} safe, ${totalUnsafe} unsafe)\n\n`
|
||||
|
||||
return markdown
|
||||
})
|
||||
|
||||
const threadWithPreview = computed(() => {
|
||||
if (!reviewSummaryPreview.value) return props.thread
|
||||
|
||||
const user = auth.value?.user as User | null
|
||||
if (!user) return props.thread
|
||||
|
||||
const previewMessage: ThreadMessage & { preview: true } = {
|
||||
id: 'preview-message',
|
||||
author_id: user.id,
|
||||
body: {
|
||||
type: 'text',
|
||||
body: reviewSummaryPreview.value,
|
||||
private: true,
|
||||
replying_to: null,
|
||||
associated_images: [],
|
||||
},
|
||||
created: new Date().toISOString(),
|
||||
hide_identity: false,
|
||||
preview: true,
|
||||
}
|
||||
|
||||
return {
|
||||
...props.thread,
|
||||
messages: [...props.thread.messages, previewMessage],
|
||||
members: props.thread.members.some((m) => m.id === user.id)
|
||||
? props.thread.members
|
||||
: [...props.thread.members, user],
|
||||
}
|
||||
})
|
||||
|
||||
const allIssuesResolved = computed(() => {
|
||||
for (const report of props.reports) {
|
||||
for (const issue of report.issues) {
|
||||
for (const detail of issue.details) {
|
||||
if (getDetailDecision(detail.id, detail.status) === 'pending') return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const canSubmitReview = computed(() => {
|
||||
const totalIssues = props.reports.reduce((sum, r) => sum + r.issues.length, 0)
|
||||
if (totalIssues === 0) return true
|
||||
return allIssuesResolved.value
|
||||
})
|
||||
const hasSubmittedPassReview = ref(false)
|
||||
|
||||
async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
|
||||
hasSubmittedPassReview.value = verdict === 'safe'
|
||||
const editorContent = threadViewRef.value?.getReplyContent() || ''
|
||||
|
||||
let message: string | undefined
|
||||
if (reviewSummaryPreview.value && editorContent) {
|
||||
message = `${reviewSummaryPreview.value}${editorContent}`
|
||||
} else if (reviewSummaryPreview.value) {
|
||||
message = reviewSummaryPreview.value
|
||||
} else if (editorContent) {
|
||||
message = editorContent
|
||||
}
|
||||
|
||||
try {
|
||||
await client.labrinth.tech_review_internal.submitProject(props.project.id, {
|
||||
verdict,
|
||||
message,
|
||||
})
|
||||
emit('markComplete', props.project.id)
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Review submitted',
|
||||
text: 'Technical review completed successfully.',
|
||||
})
|
||||
|
||||
if (verdict === 'unsafe') {
|
||||
emit('showMaliciousSummary', unsafeFiles.value)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { data?: { issues?: string[] } } }
|
||||
if (err.response?.data?.issues) {
|
||||
const missedCount = err.response.data.issues.length
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Pending issues remain',
|
||||
text: `${missedCount} issue(s) still need a verdict before submitting.`,
|
||||
})
|
||||
} else {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Submit failed',
|
||||
text: 'Failed to submit review. Please try again.',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CollapsibleRegion
|
||||
v-model:collapsed="isThreadCollapsed"
|
||||
:expand-text="threadExpandText"
|
||||
:disabled="disableCollapsing"
|
||||
collapse-text="Collapse thread"
|
||||
>
|
||||
<div class="bg-surface-2 pt-0">
|
||||
<!-- DEV-531 -->
|
||||
<!-- @vue-expect-error TODO: will convert ThreadView to use api-client types at a later date -->
|
||||
<ThreadView
|
||||
ref="threadViewRef"
|
||||
:thread="threadWithPreview"
|
||||
:quick-replies="techReviewQuickReplies"
|
||||
:quick-reply-context="techReviewContext"
|
||||
primary-action="note"
|
||||
@update-thread="emit('refetch')"
|
||||
>
|
||||
<template #additionalActions>
|
||||
<Button
|
||||
v-tooltip="
|
||||
!canSubmitReview
|
||||
? 'There are still pending flags!'
|
||||
: hasSubmittedPassReview
|
||||
? 'Project already passed!'
|
||||
: undefined
|
||||
"
|
||||
type="colored"
|
||||
color="brand"
|
||||
:disabled="!canSubmitReview || hasSubmittedPassReview"
|
||||
@click="handleSubmitReview('safe')"
|
||||
>
|
||||
<ShieldCheckIcon /> Pass
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip="!canSubmitReview ? 'There are still pending flags!' : undefined"
|
||||
type="colored"
|
||||
color="red"
|
||||
:disabled="!canSubmitReview"
|
||||
@click="handleSubmitReview('unsafe')"
|
||||
>
|
||||
<BugIcon /> Fail
|
||||
</Button>
|
||||
<TeleportOverflowMenu
|
||||
label="More options"
|
||||
class="btn-dropdown-animation !w-auto !rounded-xl !px-2.5"
|
||||
:disabled="isLoadingStatusAction"
|
||||
:options="projectStatusActions"
|
||||
>
|
||||
<SpinnerIcon v-if="isLoadingStatusAction" class="animate-spin" aria-hidden="true" />
|
||||
<ScaleIcon v-else aria-hidden="true" />
|
||||
Set status
|
||||
<DropdownIcon aria-hidden="true" />
|
||||
</TeleportOverflowMenu>
|
||||
<Button
|
||||
v-if="featureFlags.developerMode"
|
||||
type="outlined"
|
||||
@click="emit('showMaliciousSummary', unsafeFiles)"
|
||||
>Debug</Button
|
||||
>
|
||||
</template>
|
||||
</ThreadView>
|
||||
</div>
|
||||
</CollapsibleRegion>
|
||||
</template>
|
||||
@@ -0,0 +1,155 @@
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { BanIcon, CheckCheckIcon, CheckIcon, ShieldAlertIcon } from '@modrinth/assets'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { canUpdateGlobalDetail } from './helpers'
|
||||
import { injectTechReviewDecisions } from './use-tech-review-decisions'
|
||||
|
||||
const REMAINING_LABELS = {
|
||||
globalSafe: 'All remaining globally safe',
|
||||
localSafe: 'All remaining safe',
|
||||
localUnsafe: 'All remaining malware',
|
||||
globalUnsafe: 'All remaining globally unsafe',
|
||||
} as const
|
||||
|
||||
const TRACE_ARIA_LABELS = {
|
||||
globalSafe: 'Global pass',
|
||||
localSafe: 'Local pass',
|
||||
localUnsafe: 'Local fail',
|
||||
globalUnsafe: 'Global fail',
|
||||
} as const
|
||||
|
||||
const props = defineProps<{
|
||||
variant: 'remaining' | 'trace'
|
||||
remainingCount?: number
|
||||
jar?: boolean
|
||||
detail?: Labrinth.TechReview.Internal.ReportIssueDetail
|
||||
globalDisabled?: boolean
|
||||
localDisabled?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
globalSafe: []
|
||||
localSafe: []
|
||||
localUnsafe: []
|
||||
globalUnsafe: []
|
||||
}>()
|
||||
|
||||
const {
|
||||
isDetailActionSelected,
|
||||
getDetailActionTooltip,
|
||||
updatingDetails,
|
||||
updatingGlobalDetailKeys,
|
||||
} = injectTechReviewDecisions()
|
||||
|
||||
const groupAriaLabel = computed(() => {
|
||||
if (props.variant === 'trace') return 'Trace verdict actions'
|
||||
return props.jar ? 'Remaining JAR issue actions' : 'Remaining issue actions'
|
||||
})
|
||||
|
||||
const remainingLabel = computed(() =>
|
||||
props.variant === 'remaining' && props.remainingCount != null
|
||||
? `${props.remainingCount} issue${props.remainingCount === 1 ? '' : 's'} remaining`
|
||||
: undefined,
|
||||
)
|
||||
|
||||
const ariaLabels = computed(() =>
|
||||
props.variant === 'remaining' ? REMAINING_LABELS : TRACE_ARIA_LABELS,
|
||||
)
|
||||
|
||||
function tooltip(decision: 'safe' | 'malware', scope: 'local' | 'global'): string {
|
||||
if (props.variant === 'remaining') {
|
||||
if (decision === 'safe' && scope === 'global') return REMAINING_LABELS.globalSafe
|
||||
if (decision === 'safe') return REMAINING_LABELS.localSafe
|
||||
if (scope === 'local') return REMAINING_LABELS.localUnsafe
|
||||
return REMAINING_LABELS.globalUnsafe
|
||||
}
|
||||
|
||||
return getDetailActionTooltip(props.detail!, decision, scope)
|
||||
}
|
||||
|
||||
function selected(decision: 'safe' | 'malware', scope: 'local' | 'global'): boolean {
|
||||
if (props.variant !== 'trace' || !props.detail) return false
|
||||
return isDetailActionSelected(props.detail, decision, scope)
|
||||
}
|
||||
|
||||
const isGlobalDisabled = computed(() => {
|
||||
if (props.variant === 'remaining') return props.globalDisabled
|
||||
if (!props.detail) return true
|
||||
return (
|
||||
!canUpdateGlobalDetail(props.detail) ||
|
||||
updatingGlobalDetailKeys.has(props.detail.key) ||
|
||||
updatingDetails.has(props.detail.id)
|
||||
)
|
||||
})
|
||||
|
||||
const isLocalDisabled = computed(() => {
|
||||
if (props.variant === 'remaining') return props.localDisabled
|
||||
if (!props.detail) return true
|
||||
return updatingDetails.has(props.detail.id) || updatingGlobalDetailKeys.has(props.detail.key)
|
||||
})
|
||||
|
||||
const BUTTON_BASE_CLASS =
|
||||
'custom-focus-indicator flex size-8 cursor-pointer items-center justify-center border-0 border-l border-solid border-l-surface-5 bg-transparent p-0 transition-[background-color,filter] duration-150 ease-in-out first:rounded-s-[calc(var(--radius-md)-1px)] first:border-l-0 last:rounded-e-[calc(var(--radius-md)-1px)] disabled:cursor-not-allowed disabled:opacity-50 [&>svg]:size-4'
|
||||
|
||||
function buttonClass(decision: 'safe' | 'malware', scope: 'local' | 'global') {
|
||||
return [
|
||||
BUTTON_BASE_CLASS,
|
||||
decision === 'safe' ? 'text-green' : 'text-red',
|
||||
selected(decision, scope)
|
||||
? 'bg-bg-green shadow-[inset_0_0_0_1px_var(--color-green)] hover:bg-bg-green focus-visible:bg-bg-green focus-visible:shadow-[inset_0_0_0_2px_var(--color-green)]'
|
||||
: 'hover:bg-surface-4 focus-visible:bg-surface-4 focus-visible:shadow-[inset_0_0_0_2px_var(--color-brand)]',
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center overflow-hidden rounded-xl border border-solid border-surface-5 bg-surface-3"
|
||||
role="group"
|
||||
:aria-label="groupAriaLabel"
|
||||
>
|
||||
<span
|
||||
v-if="remainingLabel"
|
||||
class="whitespace-nowrap px-3 text-sm font-semibold text-secondary"
|
||||
>{{ remainingLabel }}</span
|
||||
>
|
||||
<button
|
||||
v-tooltip="tooltip('safe', 'global')"
|
||||
:class="buttonClass('safe', 'global')"
|
||||
:aria-label="ariaLabels.globalSafe"
|
||||
:disabled="isGlobalDisabled"
|
||||
@click="emit('globalSafe')"
|
||||
>
|
||||
<CheckCheckIcon aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
v-tooltip="tooltip('safe', 'local')"
|
||||
:class="buttonClass('safe', 'local')"
|
||||
:aria-label="ariaLabels.localSafe"
|
||||
:disabled="isLocalDisabled"
|
||||
@click="emit('localSafe')"
|
||||
>
|
||||
<CheckIcon aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
v-tooltip="tooltip('malware', 'local')"
|
||||
:class="buttonClass('malware', 'local')"
|
||||
:aria-label="ariaLabels.localUnsafe"
|
||||
:disabled="isLocalDisabled"
|
||||
@click="emit('localUnsafe')"
|
||||
>
|
||||
<BanIcon aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
v-tooltip="tooltip('malware', 'global')"
|
||||
:class="buttonClass('malware', 'global')"
|
||||
:aria-label="ariaLabels.globalUnsafe"
|
||||
:disabled="isGlobalDisabled"
|
||||
@click="emit('globalUnsafe')"
|
||||
>
|
||||
<ShieldAlertIcon aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
|
||||
import type { DetailDecision, FlattenedFileReport } from './types'
|
||||
|
||||
export const severityOrder: Record<Labrinth.TechReview.Internal.DelphiSeverity, number> = {
|
||||
severe: 3,
|
||||
high: 2,
|
||||
medium: 1,
|
||||
low: 0,
|
||||
}
|
||||
|
||||
export function getSeverityBadgeColor(
|
||||
severity: Labrinth.TechReview.Internal.DelphiSeverity,
|
||||
): string {
|
||||
switch (severity) {
|
||||
case 'severe':
|
||||
return 'border-red/60 border bg-highlight-red text-red'
|
||||
case 'high':
|
||||
return 'border-orange/60 border bg-highlight-orange text-orange'
|
||||
case 'medium':
|
||||
return 'border-green/60 border bg-highlight-green text-green'
|
||||
case 'low':
|
||||
default:
|
||||
return 'border-blue/60 border bg-highlight-blue text-blue'
|
||||
}
|
||||
}
|
||||
|
||||
export function truncateMiddle(str: string, maxLength = 120): string {
|
||||
if (str.length <= maxLength) return str
|
||||
const keep = maxLength - 3
|
||||
const front = Math.ceil(keep / 3)
|
||||
return str.slice(0, front) + '...' + str.slice(front - keep)
|
||||
}
|
||||
|
||||
export function getFileHighestSeverity(
|
||||
file: FlattenedFileReport,
|
||||
): Labrinth.TechReview.Internal.DelphiSeverity {
|
||||
let highest: Labrinth.TechReview.Internal.DelphiSeverity = 'low'
|
||||
for (const issue of file.issues) {
|
||||
for (const detail of issue.details) {
|
||||
if (severityOrder[detail.severity] > severityOrder[highest]) {
|
||||
highest = detail.severity
|
||||
}
|
||||
}
|
||||
}
|
||||
return highest
|
||||
}
|
||||
|
||||
export function getFileDetailCount(file: FlattenedFileReport): number {
|
||||
return file.issues.reduce((sum, issue) => sum + issue.details.length, 0)
|
||||
}
|
||||
|
||||
export function flattenFileReports(
|
||||
versions: Labrinth.TechReview.Internal.VersionReport[],
|
||||
): FlattenedFileReport[] {
|
||||
return versions.flatMap((version) =>
|
||||
version.files.map((file) => ({
|
||||
...file,
|
||||
id: file.report_id,
|
||||
version_id: version.version_id,
|
||||
version_number: version.version_number,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
export function getVersionLabel(file: FlattenedFileReport): string {
|
||||
return file.version_number || file.version_id
|
||||
}
|
||||
|
||||
export function getVersionPageHref(
|
||||
project: { id: string; slug?: string; project_types: string[] },
|
||||
versionId: string,
|
||||
): string {
|
||||
return `/${project.project_types[0] ?? 'project'}/${project.slug ?? project.id}/version/${versionId}`
|
||||
}
|
||||
|
||||
export function verdictToDecision(
|
||||
verdict: Labrinth.TechReview.Internal.DelphiReportIssueStatus,
|
||||
): DetailDecision {
|
||||
if (verdict === 'safe') return 'safe'
|
||||
if (verdict === 'unsafe') return 'malware'
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
export function decisionToVerdict(
|
||||
decision: Exclude<DetailDecision, 'pending'>,
|
||||
): Labrinth.TechReview.Internal.DelphiReportIssueStatus {
|
||||
return decision === 'safe' ? 'safe' : 'unsafe'
|
||||
}
|
||||
|
||||
export function statusMatchesDecision(
|
||||
status: Labrinth.TechReview.Internal.DelphiReportIssueStatus | null,
|
||||
decision: DetailDecision,
|
||||
): boolean {
|
||||
if (status === 'safe') return decision === 'safe'
|
||||
if (status === 'unsafe') return decision === 'malware'
|
||||
return false
|
||||
}
|
||||
|
||||
export function canUpdateGlobalDetail(
|
||||
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
|
||||
): boolean {
|
||||
return detail.key.length > 0 && !detail.key.startsWith('<no-key-')
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
|
||||
export type FlattenedFileReport = Labrinth.TechReview.Internal.FileReport & {
|
||||
id: string
|
||||
version_id: string
|
||||
version_number?: string
|
||||
}
|
||||
|
||||
export type DetailDecision = 'safe' | 'malware' | 'pending'
|
||||
export type DetailDecisionScope = 'local' | 'global'
|
||||
|
||||
export type FlagItem = {
|
||||
issueId: string
|
||||
issueType: string
|
||||
detail: Labrinth.TechReview.Internal.ReportIssueDetail
|
||||
}
|
||||
|
||||
export type ClassGroup = {
|
||||
key: string
|
||||
jar: string | null
|
||||
filePath: string
|
||||
flags: FlagItem[]
|
||||
}
|
||||
|
||||
export type JarGroup = {
|
||||
key: string
|
||||
jar: string | null
|
||||
segments: string[]
|
||||
classes: ClassGroup[]
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { injectModrinthClient } from '@modrinth/ui'
|
||||
import { inject, type InjectionKey, type MaybeRefOrGetter, reactive, toValue } from 'vue'
|
||||
|
||||
import { canUpdateGlobalDetail, decisionToVerdict, statusMatchesDecision } from './helpers'
|
||||
import type { DetailDecision, DetailDecisionScope, FlagItem, FlattenedFileReport } from './types'
|
||||
|
||||
export function useTechReviewDecisions(reports: MaybeRefOrGetter<FlattenedFileReport[]>) {
|
||||
const client = injectModrinthClient()
|
||||
|
||||
const detailDecisions = reactive<Map<string, DetailDecision>>(new Map())
|
||||
const detailDecisionScopes = reactive<Map<string, DetailDecisionScope>>(new Map())
|
||||
const updatingDetails = reactive<Set<string>>(new Set())
|
||||
const updatingGlobalDetailKeys = reactive<Set<string>>(new Set())
|
||||
|
||||
function getDetailDecision(
|
||||
detailId: string,
|
||||
backendStatus: Labrinth.TechReview.Internal.DelphiReportIssueStatus,
|
||||
): DetailDecision {
|
||||
const localDecision = detailDecisions.get(detailId)
|
||||
if (localDecision) return localDecision
|
||||
if (backendStatus === 'safe') return 'safe'
|
||||
if (backendStatus === 'unsafe') return 'malware'
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
function isPreReviewed(
|
||||
detailId: string,
|
||||
backendStatus: Labrinth.TechReview.Internal.DelphiReportIssueStatus,
|
||||
): boolean {
|
||||
return (
|
||||
(backendStatus === 'safe' || backendStatus === 'unsafe') && !detailDecisions.has(detailId)
|
||||
)
|
||||
}
|
||||
|
||||
function getFileMarkedCount(file: FlattenedFileReport): number {
|
||||
let count = 0
|
||||
for (const issue of file.issues) {
|
||||
for (const detail of issue.details) {
|
||||
if (getDetailDecision(detail.id, detail.status) !== 'pending') count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
function getMarkedFlagsCount(flags: FlagItem[]): number {
|
||||
return flags.filter((f) => getDetailDecision(f.detail.id, f.detail.status) !== 'pending').length
|
||||
}
|
||||
|
||||
function isDetailGloballyPassed(detail: Labrinth.TechReview.Internal.ReportIssueDetail): boolean {
|
||||
if (detailDecisionScopes.get(detail.id) === 'global') {
|
||||
return detailDecisions.get(detail.id) === 'safe'
|
||||
}
|
||||
|
||||
return detail.global_status === 'safe'
|
||||
}
|
||||
|
||||
function isDetailGloballyResolved(
|
||||
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
|
||||
): boolean {
|
||||
if (detailDecisionScopes.get(detail.id) === 'global') {
|
||||
return detailDecisions.get(detail.id) !== 'pending'
|
||||
}
|
||||
|
||||
return detail.global_status === 'safe' || detail.global_status === 'unsafe'
|
||||
}
|
||||
|
||||
function applyDecisionToRelatedDetails(
|
||||
detailIds: string[],
|
||||
decision: DetailDecision,
|
||||
scope: DetailDecisionScope,
|
||||
): { otherMatchedCount: number } {
|
||||
const allDetails = toValue(reports).flatMap((report) =>
|
||||
report.issues.flatMap((issue) => issue.details),
|
||||
)
|
||||
const selectedDetailIds = new Set(detailIds)
|
||||
const updatedDetailIds = new Set<string>()
|
||||
|
||||
for (const detailId of detailIds) {
|
||||
const detail = allDetails.find((candidate) => candidate.id === detailId)
|
||||
const matchingDetails = detail?.key
|
||||
? allDetails.filter((candidate) => candidate.key === detail.key)
|
||||
: detail
|
||||
? [detail]
|
||||
: []
|
||||
|
||||
if (matchingDetails.length === 0) {
|
||||
detailDecisions.set(detailId, decision)
|
||||
detailDecisionScopes.set(detailId, scope)
|
||||
updatedDetailIds.add(detailId)
|
||||
continue
|
||||
}
|
||||
|
||||
for (const matchingDetail of matchingDetails) {
|
||||
detailDecisions.set(matchingDetail.id, decision)
|
||||
detailDecisionScopes.set(matchingDetail.id, scope)
|
||||
updatedDetailIds.add(matchingDetail.id)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
otherMatchedCount: [...updatedDetailIds].filter((id) => !selectedDetailIds.has(id)).length,
|
||||
}
|
||||
}
|
||||
|
||||
function isDetailActionSelected(
|
||||
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
|
||||
decision: DetailDecision,
|
||||
scope: DetailDecisionScope,
|
||||
): boolean {
|
||||
const localDecision = detailDecisions.get(detail.id)
|
||||
const localScope = detailDecisionScopes.get(detail.id)
|
||||
if (localDecision && localScope) {
|
||||
if (localDecision === 'pending') {
|
||||
if (localScope === 'local') {
|
||||
if (scope === 'local') return false
|
||||
return statusMatchesDecision(detail.global_status, decision)
|
||||
}
|
||||
|
||||
if (scope === 'global') return false
|
||||
return statusMatchesDecision(detail.local_status, decision)
|
||||
}
|
||||
|
||||
return localDecision === decision && localScope === scope
|
||||
}
|
||||
|
||||
if (scope === 'global') {
|
||||
return statusMatchesDecision(detail.global_status, decision)
|
||||
}
|
||||
|
||||
if (detail.global_status) return false
|
||||
|
||||
return statusMatchesDecision(detail.local_status, decision)
|
||||
}
|
||||
|
||||
function getToggledDetailVerdict(
|
||||
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
|
||||
decision: Exclude<DetailDecision, 'pending'>,
|
||||
scope: DetailDecisionScope,
|
||||
): Labrinth.TechReview.Internal.DelphiReportIssueStatus {
|
||||
return isDetailActionSelected(detail, decision, scope) ? 'pending' : decisionToVerdict(decision)
|
||||
}
|
||||
|
||||
function getDetailActionTooltip(
|
||||
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
|
||||
decision: Exclude<DetailDecision, 'pending'>,
|
||||
scope: DetailDecisionScope,
|
||||
): string {
|
||||
const action = decision === 'safe' ? 'pass' : 'fail'
|
||||
const scopeLabel = scope === 'global' ? 'Global' : 'Local'
|
||||
|
||||
if (scope === 'global' && !canUpdateGlobalDetail(detail)) {
|
||||
return 'Global verdict unavailable for generated trace keys'
|
||||
}
|
||||
|
||||
if (isDetailActionSelected(detail, decision, scope)) {
|
||||
return `Unset ${scopeLabel.toLowerCase()} ${action}`
|
||||
}
|
||||
|
||||
return `${scopeLabel} ${action}`
|
||||
}
|
||||
|
||||
async function updateIssueDetails(
|
||||
data: {
|
||||
detail_id: string
|
||||
verdict: Labrinth.TechReview.Internal.DelphiReportIssueStatus
|
||||
}[],
|
||||
) {
|
||||
await client.request('/moderation/tech-review/issue-detail', {
|
||||
api: 'labrinth',
|
||||
version: 'internal',
|
||||
method: 'PATCH',
|
||||
body: data,
|
||||
})
|
||||
}
|
||||
|
||||
async function updateGlobalIssueDetails(
|
||||
data: {
|
||||
detail_key: string
|
||||
verdict: Labrinth.TechReview.Internal.DelphiReportIssueStatus
|
||||
}[],
|
||||
) {
|
||||
await client.labrinth.tech_review_internal.updateGlobalIssueDetails(data)
|
||||
}
|
||||
|
||||
return {
|
||||
updatingDetails,
|
||||
updatingGlobalDetailKeys,
|
||||
getDetailDecision,
|
||||
isPreReviewed,
|
||||
getFileMarkedCount,
|
||||
getMarkedFlagsCount,
|
||||
isDetailGloballyPassed,
|
||||
isDetailGloballyResolved,
|
||||
applyDecisionToRelatedDetails,
|
||||
isDetailActionSelected,
|
||||
getToggledDetailVerdict,
|
||||
getDetailActionTooltip,
|
||||
updateIssueDetails,
|
||||
updateGlobalIssueDetails,
|
||||
}
|
||||
}
|
||||
|
||||
export type TechReviewDecisions = ReturnType<typeof useTechReviewDecisions>
|
||||
|
||||
export const TECH_REVIEW_DECISIONS_KEY: InjectionKey<TechReviewDecisions> =
|
||||
Symbol('techReviewDecisions')
|
||||
|
||||
export function injectTechReviewDecisions(): TechReviewDecisions {
|
||||
const decisions = inject(TECH_REVIEW_DECISIONS_KEY)
|
||||
if (!decisions) {
|
||||
throw new Error('Tech review decisions must be provided by ModerationTechRevCard')
|
||||
}
|
||||
return decisions
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { injectModrinthClient } from '@modrinth/ui'
|
||||
import { type MaybeRefOrGetter, reactive, toValue } from 'vue'
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
export function useTechReviewSources(
|
||||
issues: MaybeRefOrGetter<Labrinth.TechReview.Internal.FileIssue[]>,
|
||||
) {
|
||||
const client = injectModrinthClient()
|
||||
|
||||
if (import.meta.client) {
|
||||
clearExpiredCache()
|
||||
}
|
||||
|
||||
const loadingIssues = reactive<Set<string>>(new Set())
|
||||
const decompiledSources = reactive<Map<string, string>>(new Map())
|
||||
const loadedIssues = reactive<Set<string>>(new Set())
|
||||
|
||||
async function loadIssueSource(issueId: string): Promise<void> {
|
||||
if (loadingIssues.has(issueId) || loadedIssues.has(issueId)) return
|
||||
|
||||
loadingIssues.add(issueId)
|
||||
|
||||
try {
|
||||
const issueData = await client.labrinth.tech_review_internal.getIssue(issueId)
|
||||
|
||||
for (const detail of issueData.details) {
|
||||
if (detail.decompiled_source) {
|
||||
decompiledSources.set(detail.id, detail.decompiled_source)
|
||||
setCachedSource(detail.id, detail.decompiled_source)
|
||||
}
|
||||
}
|
||||
loadedIssues.add(issueId)
|
||||
} catch (error) {
|
||||
console.error('Failed to load issue source:', error)
|
||||
} finally {
|
||||
loadingIssues.delete(issueId)
|
||||
}
|
||||
}
|
||||
|
||||
function handleLoadIssueSources(issueIds: string[]): void {
|
||||
const uniqueIssueIds = new Set(issueIds)
|
||||
const matchedIssues = toValue(issues).filter((issue) => uniqueIssueIds.has(issue.id))
|
||||
|
||||
for (const issue of matchedIssues) {
|
||||
for (const detail of issue.details) {
|
||||
if (!decompiledSources.has(detail.id)) {
|
||||
const cached = getCachedSource(detail.id)
|
||||
if (cached) {
|
||||
decompiledSources.set(detail.id, cached)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hasUncached = issue.details.some((detail) => !decompiledSources.has(detail.id))
|
||||
if (hasUncached) {
|
||||
loadIssueSource(issue.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loadingIssues,
|
||||
decompiledSources,
|
||||
handleLoadIssueSources,
|
||||
}
|
||||
}
|
||||
@@ -95,7 +95,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
<div v-if="flags.developerMode" class="mx-4 mb-3 font-semibold">
|
||||
<div v-if="flags.showThreadIds" class="mx-4 mb-3 font-semibold">
|
||||
Thread ID:
|
||||
<CopyCode :text="thread.id" />
|
||||
</div>
|
||||
@@ -113,18 +113,19 @@
|
||||
@update-thread="() => updateThreadLocal()"
|
||||
/>
|
||||
</div>
|
||||
<template v-if="report && report.closed">
|
||||
<p>{{ formatMessage(messages.closedThreadDescription) }}</p>
|
||||
<div v-if="report && report.closed" class="m-4 mt-2 flex flex-col gap-4">
|
||||
<p class="m-0">{{ formatMessage(messages.closedThreadDescription) }}</p>
|
||||
<Button
|
||||
v-if="isStaff(auth.user)"
|
||||
:disabled="isLoading"
|
||||
class="w-fit"
|
||||
@click="runBlockingAction('reopen', () => reopenReport())"
|
||||
>
|
||||
<SpinnerIcon v-if="loadingAction === 'reopen'" class="animate-spin" aria-hidden="true" />
|
||||
<CheckCircleIcon v-else aria-hidden="true" />
|
||||
{{ formatMessage(messages.actionReopenThread) }}
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
<template v-else-if="!report || !report.closed">
|
||||
<div class="mx-4 mb-2 mt-2">
|
||||
<MarkdownEditor
|
||||
@@ -211,36 +212,34 @@
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<template v-if="report">
|
||||
<template v-if="isStaff(auth.user)">
|
||||
<Button
|
||||
v-if="replyBody"
|
||||
type="colored"
|
||||
color="red"
|
||||
:disabled="isLoading"
|
||||
@click="runBlockingAction('close-with-reply', () => closeReport(true))"
|
||||
>
|
||||
<SpinnerIcon
|
||||
v-if="loadingAction === 'close-with-reply'"
|
||||
class="animate-spin"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<CheckCircleIcon v-else aria-hidden="true" />
|
||||
{{ formatMessage(messages.actionCloseWithReply) }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
:disabled="isLoading"
|
||||
@click="runBlockingAction('close', () => closeReport())"
|
||||
>
|
||||
<SpinnerIcon
|
||||
v-if="loadingAction === 'close'"
|
||||
class="animate-spin"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<CheckCircleIcon v-else aria-hidden="true" />
|
||||
{{ formatMessage(messages.actionCloseThread) }}
|
||||
</Button>
|
||||
</template>
|
||||
<Button
|
||||
v-if="isStaff(auth.user) && replyBody"
|
||||
type="colored"
|
||||
color="red"
|
||||
:disabled="isLoading"
|
||||
@click="runBlockingAction('close-with-reply', () => closeReport(true))"
|
||||
>
|
||||
<SpinnerIcon
|
||||
v-if="loadingAction === 'close-with-reply'"
|
||||
class="animate-spin"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<CheckCircleIcon v-else aria-hidden="true" />
|
||||
{{ formatMessage(messages.actionCloseWithReply) }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
:disabled="isLoading"
|
||||
@click="runBlockingAction('close', () => closeReport())"
|
||||
>
|
||||
<SpinnerIcon
|
||||
v-if="loadingAction === 'close'"
|
||||
class="animate-spin"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<CheckCircleIcon v-else aria-hidden="true" />
|
||||
{{ formatMessage(messages.actionCloseThread) }}
|
||||
</Button>
|
||||
</template>
|
||||
<template v-if="project">
|
||||
<template v-if="isStaff(auth.user)">
|
||||
@@ -526,8 +525,8 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Close with reply',
|
||||
},
|
||||
actionCloseThread: {
|
||||
id: 'conversation-thread.action.close-thread',
|
||||
defaultMessage: 'Close thread',
|
||||
id: 'conversation-thread.action.close-report',
|
||||
defaultMessage: 'Close report',
|
||||
},
|
||||
actionApproveWithReply: {
|
||||
id: 'conversation-thread.action.approve-with-reply',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="flags.developerMode" class="m-4 font-bold text-heading">
|
||||
<div v-if="flags.showThreadIds" class="m-4 font-bold text-heading">
|
||||
Thread ID:
|
||||
<CopyCode :text="thread.id" />
|
||||
</div>
|
||||
@@ -23,10 +23,12 @@
|
||||
<p class="text-lg text-secondary">No messages yet</p>
|
||||
</div>
|
||||
|
||||
<template v-if="closed">
|
||||
<p class="text-secondary">This thread is closed and new messages cannot be sent to it.</p>
|
||||
<slot name="closedActions" />
|
||||
</template>
|
||||
<div v-if="closed" class="flex flex-col gap-4 p-4 pt-2">
|
||||
<p class="m-0 text-secondary">This thread is closed and new messages cannot be sent to it.</p>
|
||||
<div>
|
||||
<slot name="closedActions" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="px-4 py-2">
|
||||
@@ -41,43 +43,58 @@
|
||||
class="mt-4 flex flex-col items-stretch justify-between gap-3 px-4 pb-4 sm:flex-row sm:items-center sm:gap-2"
|
||||
>
|
||||
<div class="flex flex-col items-stretch gap-2 sm:flex-row sm:items-center">
|
||||
<Button
|
||||
v-if="sortedMessages.length > 0"
|
||||
<SplitButton
|
||||
v-if="primaryAction === 'note' && isStaff(auth.user)"
|
||||
type="colored"
|
||||
color="brand"
|
||||
menu-label="More send options"
|
||||
:disabled="!replyBody"
|
||||
class="w-full gap-2 sm:w-auto"
|
||||
@click="sendReply()"
|
||||
>
|
||||
<ReplyIcon class="size-4" />
|
||||
Reply
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
type="colored"
|
||||
color="brand"
|
||||
:disabled="!replyBody"
|
||||
class="w-full gap-2 sm:w-auto"
|
||||
@click="sendReply()"
|
||||
>
|
||||
<SendIcon class="size-4" />
|
||||
Send
|
||||
</Button>
|
||||
<Button
|
||||
v-if="isStaff(auth.user)"
|
||||
:disabled="!replyBody"
|
||||
:options="publicMessageOptions"
|
||||
class="w-full sm:w-auto"
|
||||
@click="sendReply(true)"
|
||||
>
|
||||
Add note
|
||||
</Button>
|
||||
</SplitButton>
|
||||
<template v-else>
|
||||
<Button
|
||||
v-if="sortedMessages.length > 0"
|
||||
type="colored"
|
||||
color="brand"
|
||||
:disabled="!replyBody"
|
||||
class="w-full gap-2 sm:w-auto"
|
||||
@click="sendReply()"
|
||||
>
|
||||
<ReplyIcon class="size-4" />
|
||||
Reply
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
type="colored"
|
||||
color="brand"
|
||||
:disabled="!replyBody"
|
||||
class="w-full gap-2 sm:w-auto"
|
||||
@click="sendReply()"
|
||||
>
|
||||
<SendIcon class="size-4" />
|
||||
Send
|
||||
</Button>
|
||||
<Button
|
||||
v-if="isStaff(auth.user)"
|
||||
:disabled="!replyBody"
|
||||
class="w-full sm:w-auto"
|
||||
@click="sendReply(true)"
|
||||
>
|
||||
Add note
|
||||
</Button>
|
||||
</template>
|
||||
<TeleportOverflowMenu
|
||||
v-if="visibleQuickReplies.length > 0"
|
||||
label="More options"
|
||||
:options="visibleQuickReplies"
|
||||
class="!w-auto !rounded-xl !px-2.5"
|
||||
>
|
||||
Quick reply
|
||||
<ArrowUpFromLineIcon />
|
||||
Load preset
|
||||
<ChevronDownIcon />
|
||||
</TeleportOverflowMenu>
|
||||
</div>
|
||||
@@ -91,9 +108,15 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="T">
|
||||
import { ChevronDownIcon, MessageIcon, ReplyIcon, SendIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ArrowUpFromLineIcon,
|
||||
ChevronDownIcon,
|
||||
MessageIcon,
|
||||
ReplyIcon,
|
||||
SendIcon,
|
||||
} from '@modrinth/assets'
|
||||
import type { QuickReply } from '@modrinth/moderation'
|
||||
import { Button, TeleportOverflowMenu } from '@modrinth/ui'
|
||||
import { Button, SplitButton, TeleportOverflowMenu } from '@modrinth/ui'
|
||||
import {
|
||||
type ButtonMenuOption,
|
||||
CopyCode,
|
||||
@@ -110,6 +133,19 @@ import ThreadMessage from './ThreadMessage.vue'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
thread: Thread
|
||||
quickReplies?: ReadonlyArray<QuickReply<T>>
|
||||
quickReplyContext?: T
|
||||
closed?: boolean
|
||||
primaryAction?: 'reply' | 'note'
|
||||
}>(),
|
||||
{
|
||||
primaryAction: 'reply',
|
||||
},
|
||||
)
|
||||
|
||||
const visibleQuickReplies = computed<ButtonMenuOption[]>(() => {
|
||||
const replies = props.quickReplies
|
||||
const context = props.quickReplyContext
|
||||
@@ -131,13 +167,6 @@ const visibleQuickReplies = computed<ButtonMenuOption[]>(() => {
|
||||
)
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
thread: Thread
|
||||
quickReplies?: ReadonlyArray<QuickReply<T>>
|
||||
quickReplyContext?: T
|
||||
closed?: boolean
|
||||
}>()
|
||||
|
||||
async function handleQuickReply(reply: QuickReply<T>, context: T) {
|
||||
const message = typeof reply.message === 'function' ? await reply.message(context) : reply.message
|
||||
|
||||
@@ -151,7 +180,7 @@ defineExpose({
|
||||
sendReply,
|
||||
})
|
||||
|
||||
const auth = await useAuth()
|
||||
const auth = useAuthState()
|
||||
|
||||
const emit = defineEmits<{
|
||||
updateThread: [thread: Thread]
|
||||
@@ -169,6 +198,16 @@ const members = computed(() => {
|
||||
|
||||
const replyBody = ref('')
|
||||
|
||||
const publicMessageOptions = computed<ButtonMenuOption[]>(() => [
|
||||
{
|
||||
id: 'send-public',
|
||||
label: 'Send publicly',
|
||||
icon: SendIcon,
|
||||
action: () => sendReply(false),
|
||||
disabled: !replyBody.value,
|
||||
},
|
||||
])
|
||||
|
||||
function setReplyContent(content: string) {
|
||||
replyBody.value = content
|
||||
}
|
||||
|
||||
@@ -52,14 +52,17 @@ const getQueryString = (value: QueryValue) => {
|
||||
return value ?? null
|
||||
}
|
||||
|
||||
export const useAuthState = () =>
|
||||
useState<AuthState>('auth', () => ({
|
||||
user: null,
|
||||
token: '',
|
||||
}))
|
||||
|
||||
export const useAuth = async (
|
||||
oldToken: string | null | undefined = null,
|
||||
route?: AuthInitRoute,
|
||||
) => {
|
||||
const auth = useState<AuthState>('auth', () => ({
|
||||
user: null,
|
||||
token: '',
|
||||
}))
|
||||
const auth = useAuthState()
|
||||
|
||||
if (!auth.value.user || oldToken) {
|
||||
auth.value = await initAuth(oldToken, route)
|
||||
|
||||
@@ -19,6 +19,7 @@ const validateValues = <K extends PropertyKey>(flags: Record<K, FlagValue>) => f
|
||||
export const DEFAULT_FEATURE_FLAGS = validateValues({
|
||||
// Developer flags
|
||||
developerMode: false,
|
||||
showThreadIds: false,
|
||||
demoMode: false,
|
||||
showVersionFilesInTable: false,
|
||||
showVersionEnvironmentColumn: false,
|
||||
|
||||
@@ -110,7 +110,6 @@ import {
|
||||
commonMessages,
|
||||
defineMessage,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
IntlFormatted,
|
||||
LoadingBar,
|
||||
normalizeChildren,
|
||||
@@ -131,10 +130,10 @@ import { getSignInRouteObj } from '~/composables/auth.js'
|
||||
import { setupProviders } from '~/providers/setup.ts'
|
||||
|
||||
const auth = await useAuth()
|
||||
setupProviders(auth)
|
||||
const { notificationManager } = setupProviders(auth)
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { addNotification } = notificationManager
|
||||
const isSwitchingAccount = useIsSwitchingAccount()
|
||||
|
||||
const props = defineProps({
|
||||
|
||||
@@ -970,13 +970,10 @@ const showTinMismatchBanner = computed(() => {
|
||||
|
||||
const PRIDE_COLLECTION_ID = 'M4c3ITvd'
|
||||
const PRIDE_ARTICLE_SLUGS = ['pride-campaign-2025', 'pride-campaign-2026', 'proud-of-you-2026']
|
||||
const PRIDE_CACHE_TIME = 1000 * 60 * 60 * 24
|
||||
|
||||
const { data: prideCollection } = useQuery({
|
||||
queryKey: computed(() => ['collection', PRIDE_COLLECTION_ID]),
|
||||
queryFn: () => client.labrinth.collections.get(PRIDE_COLLECTION_ID),
|
||||
staleTime: PRIDE_CACHE_TIME,
|
||||
gcTime: PRIDE_CACHE_TIME,
|
||||
})
|
||||
|
||||
const prideProjectIds = computed(() => new Set(prideCollection.value?.projects ?? []))
|
||||
|
||||
@@ -1025,9 +1025,6 @@
|
||||
"conversation-thread.action.approve-with-reply": {
|
||||
"message": "الموافقة مع الرد"
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "إغلاق سلسلة المحادثة"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "الإغلاق مع الرد"
|
||||
},
|
||||
|
||||
@@ -917,9 +917,6 @@
|
||||
"conversation-thread.action.approve-with-reply": {
|
||||
"message": "Schválit s odpovědí"
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "Uzavřít vlákno"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "Uzavřít s odpovědí"
|
||||
},
|
||||
|
||||
@@ -677,9 +677,6 @@
|
||||
"conversation-thread.action.approve-with-reply": {
|
||||
"message": "Godkend med svar"
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "Luk tråd"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "Luk med svar"
|
||||
},
|
||||
|
||||
@@ -1025,9 +1025,6 @@
|
||||
"conversation-thread.action.approve-with-reply": {
|
||||
"message": "Genehmigen mit Antwort"
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "Thread schliessen"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "Schliessen mit Antwort"
|
||||
},
|
||||
|
||||
@@ -1025,9 +1025,6 @@
|
||||
"conversation-thread.action.approve-with-reply": {
|
||||
"message": "Mit Antwort annehmen"
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "Thread schließen"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "Mit Antwort schließen"
|
||||
},
|
||||
|
||||
@@ -1070,8 +1070,8 @@
|
||||
"conversation-thread.action.approve-with-reply": {
|
||||
"message": "Approve with reply"
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "Close thread"
|
||||
"conversation-thread.action.close-report": {
|
||||
"message": "Close report"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "Close with reply"
|
||||
|
||||
@@ -1025,9 +1025,6 @@
|
||||
"conversation-thread.action.approve-with-reply": {
|
||||
"message": "Aprovar con respuesta"
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "Cerrar hilo"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "Cerrar con respuesta"
|
||||
},
|
||||
|
||||
@@ -1025,9 +1025,6 @@
|
||||
"conversation-thread.action.approve-with-reply": {
|
||||
"message": "Aprobar con respuesta"
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "Cerrar hilo"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "Cerrar con respuesta"
|
||||
},
|
||||
|
||||
@@ -383,9 +383,6 @@
|
||||
"conversation-thread.action.approve-with-reply": {
|
||||
"message": "Hyväksy vastauksella"
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "Sulje keskustelu"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "Sulje vastauksella"
|
||||
},
|
||||
|
||||
@@ -1025,9 +1025,6 @@
|
||||
"conversation-thread.action.approve-with-reply": {
|
||||
"message": "Approuver avec réponse"
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "Fermer le fil"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "Fermer avec réponse"
|
||||
},
|
||||
|
||||
@@ -1007,9 +1007,6 @@
|
||||
"conversation-thread.action.approve-with-reply": {
|
||||
"message": "Jóváhagyás és válasz"
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "Gondolatmenet lezárása"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "Bezárás és válasz"
|
||||
},
|
||||
|
||||
@@ -1019,9 +1019,6 @@
|
||||
"conversation-thread.action.approve-with-reply": {
|
||||
"message": "Approva con risposta"
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "Chiudi thread"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "Chiudi con risposta"
|
||||
},
|
||||
|
||||
@@ -1022,9 +1022,6 @@
|
||||
"conversation-thread.action.approve-with-reply": {
|
||||
"message": "送信して承認"
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "スレッドを閉じる"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "返信して閉じる"
|
||||
},
|
||||
|
||||
@@ -1025,9 +1025,6 @@
|
||||
"conversation-thread.action.approve-with-reply": {
|
||||
"message": "답글과 함께 승인"
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "스레드 닫기"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "답글과 함께 닫기"
|
||||
},
|
||||
|
||||
@@ -794,9 +794,6 @@
|
||||
"conversation-thread.action.approve-with-reply": {
|
||||
"message": "Luluskan dengan balasan"
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "Tutup bebenang"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "Tutup dengan balasan"
|
||||
},
|
||||
|
||||
@@ -1022,9 +1022,6 @@
|
||||
"conversation-thread.action.approve-with-reply": {
|
||||
"message": "Accepteren met antwoord"
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "Sluit thread"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "Sluit met antwoord"
|
||||
},
|
||||
|
||||
@@ -794,9 +794,6 @@
|
||||
"conversation-thread.action.approve-with-reply": {
|
||||
"message": "Godkjenn med svar"
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "Lukk tråden"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "Lukk med svar"
|
||||
},
|
||||
|
||||
@@ -1022,9 +1022,6 @@
|
||||
"conversation-thread.action.approve-with-reply": {
|
||||
"message": "Zatwierdź z odpowiedzią "
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "Zamknij wątek"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "Zamknij z odpowiedzią"
|
||||
},
|
||||
|
||||
@@ -1025,9 +1025,6 @@
|
||||
"conversation-thread.action.approve-with-reply": {
|
||||
"message": "Aprovar com resposta"
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "Encerrar tópico"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "Encerrar com resposta"
|
||||
},
|
||||
|
||||
@@ -1025,9 +1025,6 @@
|
||||
"conversation-thread.action.approve-with-reply": {
|
||||
"message": "Aprovar com comentário"
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "Fechar tópico"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "Fechar com comentário"
|
||||
},
|
||||
|
||||
@@ -1019,9 +1019,6 @@
|
||||
"conversation-thread.action.approve-with-reply": {
|
||||
"message": "Одобрить с ответом"
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "Закрыть ветку"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "Закрыть с ответом"
|
||||
},
|
||||
|
||||
@@ -959,9 +959,6 @@
|
||||
"conversation-thread.action.approve-with-reply": {
|
||||
"message": "Godkänn med svar"
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "Stäng tråd"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "Stäng med svar"
|
||||
},
|
||||
|
||||
@@ -1025,9 +1025,6 @@
|
||||
"conversation-thread.action.approve-with-reply": {
|
||||
"message": "Onaylandı, işlem tamamlandı"
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "Konuyu kapat"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "Cevapla ve kapat"
|
||||
},
|
||||
|
||||
@@ -1022,9 +1022,6 @@
|
||||
"conversation-thread.action.approve-with-reply": {
|
||||
"message": "Підтвердити з відповіддю"
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "Закрити тему"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "Закрити з відповіддю"
|
||||
},
|
||||
|
||||
@@ -908,9 +908,6 @@
|
||||
"conversation-thread.action.approve-with-reply": {
|
||||
"message": "Chấp thuận với phản hồi"
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "Đóng luồng"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "Đóng với phản hồi"
|
||||
},
|
||||
|
||||
@@ -1025,9 +1025,6 @@
|
||||
"conversation-thread.action.approve-with-reply": {
|
||||
"message": "批准并回复"
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "关闭对话消息"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "关闭并回复"
|
||||
},
|
||||
|
||||
@@ -1025,9 +1025,6 @@
|
||||
"conversation-thread.action.approve-with-reply": {
|
||||
"message": "回覆並核准"
|
||||
},
|
||||
"conversation-thread.action.close-thread": {
|
||||
"message": "關閉討論串"
|
||||
},
|
||||
"conversation-thread.action.close-with-reply": {
|
||||
"message": "回覆並關閉"
|
||||
},
|
||||
|
||||
@@ -89,12 +89,11 @@
|
||||
@download="emit('onDownload')"
|
||||
/>
|
||||
<div class="flex flex-col">
|
||||
<nuxt-link
|
||||
class="mb-4 flex w-fit items-center gap-2 rounded-lg px-2 py-0.5 pl-0 text-link"
|
||||
<BackToParentLink
|
||||
:to="`/${project.project_type}/${project.slug ? project.slug : project.id}/versions`"
|
||||
>
|
||||
<ChevronLeftIcon class="shrink-0" /> {{ formatMessage(messages.allVersions) }}
|
||||
</nuxt-link>
|
||||
{{ formatMessage(messages.allVersions) }}
|
||||
</BackToParentLink>
|
||||
<template v-if="version">
|
||||
<Admonition
|
||||
v-if="version.files_missing_attribution?.length"
|
||||
@@ -521,6 +520,7 @@ import {
|
||||
import { moderationSettings } from '@modrinth/moderation'
|
||||
import {
|
||||
Admonition,
|
||||
BackToParentLink,
|
||||
Button,
|
||||
ButtonLink,
|
||||
Collapsible,
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
</form>
|
||||
</NewModal>
|
||||
<div>
|
||||
<form class="flex gap-2" @submit.prevent="executeSearch">
|
||||
<form class="flex items-center gap-2" @submit.prevent="executeSearch">
|
||||
<Input
|
||||
v-model="query"
|
||||
:icon="SearchIcon"
|
||||
@@ -44,17 +44,18 @@
|
||||
autocomplete="off"
|
||||
placeholder="Search external projects..."
|
||||
clearable
|
||||
wrapper-class="flex-1 w-full"
|
||||
size="medium"
|
||||
wrapper-class="min-w-0 flex-1"
|
||||
/>
|
||||
<Button type="colored" color="brand" native-type="submit">
|
||||
<Button type="colored" color="brand" size="lg" native-type="submit">
|
||||
<SearchIcon aria-hidden="true" />
|
||||
Search by title
|
||||
</Button>
|
||||
<Button native-type="button" @click="executeFlameIdLookup">
|
||||
<Button size="lg" native-type="button" @click="executeFlameIdLookup">
|
||||
<BinaryIcon aria-hidden="true" />
|
||||
Lookup CurseForge ID
|
||||
</Button>
|
||||
<Button native-type="button" @click="executeSha1Lookup">
|
||||
<Button size="lg" native-type="button" @click="executeSha1Lookup">
|
||||
<HashIcon aria-hidden="true" />
|
||||
Lookup SHA-1
|
||||
</Button>
|
||||
|
||||
@@ -1,95 +1,88 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col justify-between gap-3 lg:flex-row">
|
||||
<Input
|
||||
v-model="query"
|
||||
:icon="SearchIcon"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
:placeholder="formatMessage(commonMessages.searchPlaceholder)"
|
||||
clearable
|
||||
wrapper-class="flex-1"
|
||||
input-class="h-[40px] w-full"
|
||||
@input="goToPage(1)"
|
||||
/>
|
||||
<ModerationQueueToolbar
|
||||
v-model="query"
|
||||
:page="currentPage"
|
||||
:total-pages="totalPages"
|
||||
@search="goToPage(1)"
|
||||
@switch-page="goToPage"
|
||||
>
|
||||
<template #actions>
|
||||
<Combobox
|
||||
v-model="currentFilterType"
|
||||
class="!w-full flex-grow sm:!w-[280px] sm:flex-grow-0 lg:!w-[280px]"
|
||||
trigger-type="base"
|
||||
trigger-size="lg"
|
||||
:options="filterTypes"
|
||||
:placeholder="formatMessage(commonMessages.filterByLabel)"
|
||||
@select="goToPage(1)"
|
||||
>
|
||||
<template #selected>
|
||||
<span class="flex flex-row gap-2 align-middle font-semibold">
|
||||
<ListFilterIcon class="size-5 flex-shrink-0 text-secondary" />
|
||||
<ModerationFilterCount
|
||||
:label="currentFilterType"
|
||||
:count="totalProjects"
|
||||
:loading="pending"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
</Combobox>
|
||||
|
||||
<div class="flex flex-col flex-wrap justify-end gap-2 sm:flex-row lg:flex-shrink-0">
|
||||
<div class="flex flex-col gap-2 sm:flex-row">
|
||||
<Combobox
|
||||
v-model="currentFilterType"
|
||||
class="!w-full flex-grow sm:!w-[280px] sm:flex-grow-0 lg:!w-[280px]"
|
||||
trigger-type="base"
|
||||
trigger-size="lg"
|
||||
:options="filterTypes"
|
||||
:placeholder="formatMessage(commonMessages.filterByLabel)"
|
||||
@select="goToPage(1)"
|
||||
>
|
||||
<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 }} ({{ totalProjects }})</span
|
||||
>
|
||||
</span>
|
||||
</template>
|
||||
</Combobox>
|
||||
<Combobox
|
||||
v-model="currentSortType"
|
||||
class="!w-full flex-grow sm:!w-[240px] sm:flex-grow-0"
|
||||
trigger-type="base"
|
||||
trigger-size="lg"
|
||||
:options="sortTypes"
|
||||
:placeholder="formatMessage(commonMessages.sortByLabel)"
|
||||
@select="goToPage(1)"
|
||||
>
|
||||
<template #selected>
|
||||
<span class="flex flex-row gap-2 align-middle font-semibold">
|
||||
<SortAscIcon
|
||||
v-if="currentSortType === 'Oldest' || currentSortType === 'Least external deps'"
|
||||
class="size-5 flex-shrink-0 text-secondary"
|
||||
/>
|
||||
<SortDescIcon v-else class="size-5 flex-shrink-0 text-secondary" />
|
||||
<span class="truncate text-contrast">{{ currentSortType }}</span>
|
||||
</span>
|
||||
</template>
|
||||
</Combobox>
|
||||
|
||||
<Combobox
|
||||
v-model="currentSortType"
|
||||
class="!w-full flex-grow sm:!w-[240px] sm:flex-grow-0"
|
||||
trigger-type="base"
|
||||
trigger-size="lg"
|
||||
:options="sortTypes"
|
||||
:placeholder="formatMessage(commonMessages.sortByLabel)"
|
||||
@select="goToPage(1)"
|
||||
>
|
||||
<template #selected>
|
||||
<span class="flex flex-row gap-2 align-middle font-semibold">
|
||||
<SortAscIcon
|
||||
v-if="currentSortType === 'Oldest' || currentSortType === 'Least external deps'"
|
||||
class="size-5 flex-shrink-0 text-secondary"
|
||||
/>
|
||||
<SortDescIcon v-else class="size-5 flex-shrink-0 text-secondary" />
|
||||
<span class="truncate text-contrast">{{ currentSortType }}</span>
|
||||
</span>
|
||||
</template>
|
||||
</Combobox>
|
||||
|
||||
<Combobox
|
||||
v-model="itemsPerPage"
|
||||
class="!w-full flex-grow sm:!w-[160px] sm:flex-grow-0 lg:!w-[140px]"
|
||||
trigger-type="base"
|
||||
trigger-size="lg"
|
||||
:options="itemsPerPageOptions"
|
||||
placeholder="Items per page"
|
||||
@select="goToPage(1)"
|
||||
>
|
||||
<template #selected>
|
||||
<span class="flex flex-row gap-2 align-middle font-semibold">
|
||||
<span class="truncate text-contrast">{{ itemsPerPage }} items</span>
|
||||
</span>
|
||||
</template>
|
||||
</Combobox>
|
||||
</div>
|
||||
<Combobox
|
||||
v-model="itemsPerPage"
|
||||
class="!w-full flex-grow sm:!w-[160px] sm:flex-grow-0 lg:!w-[140px]"
|
||||
trigger-type="base"
|
||||
trigger-size="lg"
|
||||
:options="itemsPerPageOptions"
|
||||
placeholder="Items per page"
|
||||
@select="goToPage(1)"
|
||||
>
|
||||
<template #selected>
|
||||
<span class="flex flex-row gap-2 align-middle font-semibold">
|
||||
<span class="truncate text-contrast">{{ itemsPerPage }} items</span>
|
||||
</span>
|
||||
</template>
|
||||
</Combobox>
|
||||
|
||||
<Button
|
||||
type="colored"
|
||||
color="orange"
|
||||
class="flex !h-[40px] w-full items-center justify-center gap-2 sm:w-auto"
|
||||
size="lg"
|
||||
class="w-full sm:w-auto"
|
||||
:disabled="pending || paginatedProjects?.length === 0"
|
||||
@click="moderateAllInFilter()"
|
||||
>
|
||||
<ScaleIcon class="flex-shrink-0" />
|
||||
<ScaleIcon />
|
||||
<span class="hidden sm:inline">{{ formatMessage(messages.moderate) }}</span>
|
||||
<span class="sm:hidden">Moderate</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
</template>
|
||||
<template #meta>
|
||||
<div v-if="totalProjects > 0">
|
||||
Showing {{ pageStart }}–{{ pageEnd }} of {{ totalProjects }}
|
||||
Showing {{ formatNumber(pageStart) }}–{{ formatNumber(pageEnd) }} of
|
||||
{{ formatNumber(totalProjects) }}
|
||||
{{
|
||||
currentFilterType === DEFAULT_FILTER_TYPE ? 'projects' : currentFilterType.toLowerCase()
|
||||
}}
|
||||
@@ -100,39 +93,27 @@
|
||||
{{ formatMessage(messages.excludeTechnicalReview) }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<Pagination
|
||||
v-if="totalPages > 1"
|
||||
:page="currentPage"
|
||||
:count="totalPages"
|
||||
@switch-page="goToPage"
|
||||
/>
|
||||
<ConfettiExplosion v-if="visible" />
|
||||
<QueueSummaryModal
|
||||
ref="queueSummaryModal"
|
||||
:completed-ids="moderationQueue.currentQueue.completed"
|
||||
:skipped-ids="moderationQueue.currentQueue.skipped"
|
||||
@review-skipped="reviewSkippedQueue"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<template v-if="pending">
|
||||
<div
|
||||
v-for="i in 3"
|
||||
:key="`loading-skeleton-${i}`"
|
||||
class="flex h-[98px] w-full animate-pulse rounded-2xl bg-surface-3"
|
||||
></div>
|
||||
</template>
|
||||
<EmptyState
|
||||
v-else-if="paginatedProjects.length === 0"
|
||||
:type="!!query ? 'no-search-result' : 'no-tasks'"
|
||||
:heading="emptyStateHeading"
|
||||
:description="emptyStateDescription"
|
||||
/>
|
||||
</ModerationQueueToolbar>
|
||||
|
||||
<ConfettiExplosion v-if="visible" />
|
||||
<QueueSummaryModal
|
||||
ref="queueSummaryModal"
|
||||
:completed-ids="moderationQueue.currentQueue.completed"
|
||||
:skipped-ids="moderationQueue.currentQueue.skipped"
|
||||
@review-skipped="reviewSkippedQueue"
|
||||
/>
|
||||
|
||||
<ModerationQueueSkeleton v-if="pending" />
|
||||
<EmptyState
|
||||
v-else-if="paginatedProjects.length === 0"
|
||||
:type="!!query ? 'no-search-result' : 'no-tasks'"
|
||||
:heading="emptyStateHeading"
|
||||
:description="emptyStateDescription"
|
||||
/>
|
||||
<div v-else class="flex flex-col gap-3">
|
||||
<ModerationQueueCard
|
||||
v-for="item in paginatedProjects"
|
||||
v-else
|
||||
:key="item.project.id"
|
||||
:queue-entry="item"
|
||||
:show-external-dependencies="currentFilterType === MODPACK_FILTER_TYPE"
|
||||
@@ -147,7 +128,7 @@
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { ListFilterIcon, ScaleIcon, SearchIcon, SortAscIcon, SortDescIcon } from '@modrinth/assets'
|
||||
import { ListFilterIcon, ScaleIcon, SortAscIcon, SortDescIcon } from '@modrinth/assets'
|
||||
import { Button } from '@modrinth/ui'
|
||||
import {
|
||||
Combobox,
|
||||
@@ -157,15 +138,18 @@ import {
|
||||
EmptyState,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
Input,
|
||||
Pagination,
|
||||
Toggle,
|
||||
useFormatNumber,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import ConfettiExplosion from 'vue-confetti-explosion'
|
||||
|
||||
import ModerationFilterCount from '~/components/ui/moderation/ModerationFilterCount.vue'
|
||||
import ModerationQueueCard from '~/components/ui/moderation/ModerationQueueCard.vue'
|
||||
import ModerationQueueSkeleton from '~/components/ui/moderation/ModerationQueueSkeleton.vue'
|
||||
import ModerationQueueToolbar from '~/components/ui/moderation/ModerationQueueToolbar.vue'
|
||||
import QueueSummaryModal from '~/components/ui/moderation/QueueSummaryModal.vue'
|
||||
import { type ModerationProject, toModerationProjects } from '~/helpers/moderation.ts'
|
||||
import { getProjectTypeForUrlShorthand } from '~/helpers/projects.js'
|
||||
@@ -175,6 +159,7 @@ import { findNextEligibleQueueProject } from '~/services/moderation/queue-eligib
|
||||
useHead({ title: 'Projects queue - Modrinth' })
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const formatNumber = useFormatNumber()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const moderationQueue = useModerationQueue()
|
||||
const route = useRoute()
|
||||
|
||||
@@ -28,6 +28,11 @@ const { data: report } = useQuery({
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<ModerationReportCard v-if="report" :report="report" :collapsed="false" />
|
||||
<ModerationReportCard
|
||||
v-if="report"
|
||||
:report="report"
|
||||
:collapsed="false"
|
||||
:disable-collapsing="true"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,21 +1,13 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col justify-between gap-3 lg:flex-row">
|
||||
<Input
|
||||
v-model="query"
|
||||
:icon="SearchIcon"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
:placeholder="formatMessage(commonMessages.searchPlaceholder)"
|
||||
clearable
|
||||
wrapper-class="flex-1"
|
||||
input-class="h-[40px] w-full"
|
||||
@input="goToPage(1)"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="flex flex-col items-stretch justify-end gap-2 sm:flex-row sm:items-center lg:flex-shrink-0"
|
||||
>
|
||||
<ModerationQueueToolbar
|
||||
v-model="query"
|
||||
:page="currentPage"
|
||||
:total-pages="totalPages"
|
||||
@search="goToPage(1)"
|
||||
@switch-page="goToPage"
|
||||
>
|
||||
<template #actions>
|
||||
<Combobox
|
||||
v-model="currentMessageFilter"
|
||||
class="!w-full flex-grow sm:!w-[200px] sm:flex-grow-0"
|
||||
@@ -25,12 +17,14 @@
|
||||
trigger-size="lg"
|
||||
@select="goToPage(1)"
|
||||
>
|
||||
<template #selected="{ label: messageLabel }">
|
||||
<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"
|
||||
>{{ messageLabel }} ({{ sortedReports.length }})</span
|
||||
>
|
||||
<ModerationFilterCount
|
||||
:label="currentMessageFilterName"
|
||||
:count="sortedReports.length"
|
||||
:loading="isLoading"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
</Combobox>
|
||||
@@ -78,7 +72,7 @@
|
||||
<span class="min-w-0 flex-1 truncate px-0.5 font-semibold text-inherit">
|
||||
{{
|
||||
currentReporterOrProject.length === 0
|
||||
? 'All Reports'
|
||||
? 'All reports'
|
||||
: `${currentReporterOrProject.length} selected`
|
||||
}}
|
||||
</span>
|
||||
@@ -111,7 +105,7 @@
|
||||
class="h-5 w-5 shrink-0 text-primary"
|
||||
:class="currentReporterOrProject.length === 0 ? 'text-contrast' : 'text-primary'"
|
||||
/>
|
||||
<span class="min-w-0 flex-1 font-semibold leading-tight">All Reports</span>
|
||||
<span class="min-w-0 flex-1 font-semibold leading-tight">All reports</span>
|
||||
<span class="flex shrink-0 items-center justify-center text-brand">
|
||||
<CheckIcon
|
||||
v-if="currentReporterOrProject.length === 0"
|
||||
@@ -169,35 +163,34 @@
|
||||
</div>
|
||||
</template>
|
||||
</TeleportPopoutMenu>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #meta>
|
||||
<div v-if="sortedReports.length > 0">
|
||||
Showing {{ formatNumber(pageStart) }}–{{ formatNumber(pageEnd) }} of
|
||||
{{ formatNumber(sortedReports.length) }} reports
|
||||
</div>
|
||||
</template>
|
||||
</ModerationQueueToolbar>
|
||||
|
||||
<div v-if="totalPages > 1" class="flex items-center justify-between">
|
||||
<div>
|
||||
Showing
|
||||
{{ itemsPerPage * (currentPage - 1) + 1 }}
|
||||
–
|
||||
{{ itemsPerPage * (currentPage - 1) + Math.min(itemsPerPage, paginatedReports.length) }}
|
||||
of {{ sortedReports.length }} reports
|
||||
</div>
|
||||
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
|
||||
<ModerationQueueSkeleton v-if="isLoading" />
|
||||
<div
|
||||
v-else-if="paginatedReports.length === 0"
|
||||
class="universal-card flex h-24 items-center justify-center text-secondary"
|
||||
>
|
||||
No reports in queue.
|
||||
</div>
|
||||
|
||||
<div v-if="totalPages > 1" class="flex justify-center lg:hidden">
|
||||
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div v-if="paginatedReports.length === 0" class="universal-card h-24 animate-pulse"></div>
|
||||
<div v-else class="flex flex-col gap-4 overflow-x-clip">
|
||||
<ReportCard
|
||||
v-for="report in paginatedReports"
|
||||
:key="report.id"
|
||||
:report="report"
|
||||
:collapsed="true"
|
||||
dismiss-after-close
|
||||
@dismiss="dismissReport(report.id)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="totalPages > 1" class="mt-4 flex justify-center">
|
||||
<div v-if="totalPages > 1" class="flex justify-end">
|
||||
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -211,7 +204,6 @@ import {
|
||||
ChevronLeftIcon,
|
||||
LayersIcon,
|
||||
ListFilterIcon,
|
||||
SearchIcon,
|
||||
SortAscIcon,
|
||||
SortDescIcon,
|
||||
} from '@modrinth/assets'
|
||||
@@ -222,163 +214,259 @@ import {
|
||||
commonMessages,
|
||||
formatReportType,
|
||||
injectModrinthClient,
|
||||
Input,
|
||||
MultiSelect,
|
||||
type MultiSelectItem,
|
||||
Pagination,
|
||||
TeleportPopoutMenu,
|
||||
useDebugLogger,
|
||||
useFormatNumber,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import Fuse from 'fuse.js'
|
||||
|
||||
import ModerationFilterCount from '~/components/ui/moderation/ModerationFilterCount.vue'
|
||||
import ModerationQueueSkeleton from '~/components/ui/moderation/ModerationQueueSkeleton.vue'
|
||||
import ModerationQueueToolbar from '~/components/ui/moderation/ModerationQueueToolbar.vue'
|
||||
import ReportCard from '~/components/ui/moderation/ModerationReportCard.vue'
|
||||
import { enrichReportBatch } from '~/helpers/moderation.ts'
|
||||
|
||||
useHead({ title: 'Reports queue - Modrinth' })
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const formatNumber = useFormatNumber()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = await useAuth()
|
||||
const client = injectModrinthClient()
|
||||
const debug = useDebugLogger('ModerationReports')
|
||||
|
||||
const { data: allReports } = await useLazyAsyncData('new-moderation-reports', async () => {
|
||||
const startTime = performance.now()
|
||||
let currentOffset = 0
|
||||
const REPORT_ENDPOINT_COUNT = 350
|
||||
const allReports: ExtendedReport[] = []
|
||||
const { data: allReports, pending: reportsPending } = await useLazyAsyncData(
|
||||
'new-moderation-reports',
|
||||
async () => {
|
||||
const startTime = performance.now()
|
||||
let currentOffset = 0
|
||||
const REPORT_ENDPOINT_COUNT = 350
|
||||
const allReports: ExtendedReport[] = []
|
||||
|
||||
const enrichmentPromises: Promise<ExtendedReport[]>[] = []
|
||||
const enrichmentPromises: Promise<ExtendedReport[]>[] = []
|
||||
|
||||
let reports: Labrinth.Reports.v3.Report[]
|
||||
let hasMoreReports = true
|
||||
while (hasMoreReports) {
|
||||
reports = (await useBaseFetch(
|
||||
`report?count=${REPORT_ENDPOINT_COUNT}&offset=${currentOffset}&all=true`,
|
||||
{
|
||||
apiVersion: 3,
|
||||
},
|
||||
)) as Labrinth.Reports.v3.Report[]
|
||||
let reports: Labrinth.Reports.v3.Report[]
|
||||
let hasMoreReports = true
|
||||
while (hasMoreReports) {
|
||||
reports = (await useBaseFetch(
|
||||
`report?count=${REPORT_ENDPOINT_COUNT}&offset=${currentOffset}&all=true`,
|
||||
{
|
||||
apiVersion: 3,
|
||||
},
|
||||
)) as Labrinth.Reports.v3.Report[]
|
||||
|
||||
hasMoreReports = reports.length > 0
|
||||
if (!hasMoreReports) {
|
||||
break
|
||||
hasMoreReports = reports.length > 0
|
||||
if (!hasMoreReports) {
|
||||
break
|
||||
}
|
||||
|
||||
const enrichmentPromise = enrichReportBatch(reports, client)
|
||||
enrichmentPromises.push(enrichmentPromise)
|
||||
|
||||
// this is explicitly not the length of the reports array, because the API may return fewer reports due to a report in the middle not being
|
||||
// serializable if the offset is set to the reports array you can get the same report from the end multiple times.
|
||||
currentOffset += REPORT_ENDPOINT_COUNT
|
||||
|
||||
if (enrichmentPromises.length >= 3) {
|
||||
const completed = await Promise.all(enrichmentPromises.splice(0, 2))
|
||||
allReports.push(...completed.flat())
|
||||
}
|
||||
}
|
||||
|
||||
const enrichmentPromise = enrichReportBatch(reports, client)
|
||||
enrichmentPromises.push(enrichmentPromise)
|
||||
const remainingBatches = await Promise.all(enrichmentPromises)
|
||||
allReports.push(...remainingBatches.flat())
|
||||
|
||||
// this is explicitly not the length of the reports array, because the API may return fewer reports due to a report in the middle not being
|
||||
// serializable if the offset is set to the reports array you can get the same report from the end multiple times.
|
||||
currentOffset += REPORT_ENDPOINT_COUNT
|
||||
const endTime = performance.now()
|
||||
const duration = endTime - startTime
|
||||
|
||||
if (enrichmentPromises.length >= 3) {
|
||||
const completed = await Promise.all(enrichmentPromises.splice(0, 2))
|
||||
allReports.push(...completed.flat())
|
||||
}
|
||||
}
|
||||
debug(
|
||||
`Reports fetched and processed in ${duration.toFixed(2)}ms (${(duration / 1000).toFixed(2)}s)`,
|
||||
)
|
||||
|
||||
const remainingBatches = await Promise.all(enrichmentPromises)
|
||||
allReports.push(...remainingBatches.flat())
|
||||
|
||||
const endTime = performance.now()
|
||||
const duration = endTime - startTime
|
||||
|
||||
debug(
|
||||
`Reports fetched and processed in ${duration.toFixed(2)}ms (${(duration / 1000).toFixed(2)}s)`,
|
||||
)
|
||||
|
||||
return allReports
|
||||
})
|
||||
|
||||
const query = ref(route.query.q?.toString() || '')
|
||||
|
||||
watch(
|
||||
query,
|
||||
(newQuery) => {
|
||||
const currentQuery = { ...route.query }
|
||||
if (newQuery) {
|
||||
currentQuery.q = newQuery
|
||||
} else {
|
||||
delete currentQuery.q
|
||||
}
|
||||
|
||||
router.replace({
|
||||
path: route.path,
|
||||
query: currentQuery,
|
||||
})
|
||||
},
|
||||
{ immediate: false },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => route.query.q,
|
||||
(newQueryParam) => {
|
||||
const newValue = newQueryParam?.toString() || ''
|
||||
if (query.value !== newValue) {
|
||||
query.value = newValue
|
||||
}
|
||||
return allReports
|
||||
},
|
||||
)
|
||||
|
||||
const currentSortTypeSorting = ref('oldest')
|
||||
const isLoading = computed(() => reportsPending.value || allReports.value == null)
|
||||
|
||||
const SORT_VALUES = ['oldest', 'newest'] as const
|
||||
const sortTypes: ComboboxOption<string>[] = [
|
||||
{ value: 'oldest', label: 'Oldest' },
|
||||
{ value: 'newest', label: 'Newest' },
|
||||
]
|
||||
|
||||
const currentMessageFilter = ref('all')
|
||||
const messageFilterTypes: ComboboxOption<string>[] = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'unread', label: 'Unread' },
|
||||
{ value: 'read', label: 'Read' },
|
||||
{ value: 'involved', label: 'Involved' },
|
||||
]
|
||||
const MESSAGE_FILTERS = [
|
||||
{ value: 'all', name: 'All' },
|
||||
{ value: 'unread', name: 'Unread' },
|
||||
{ value: 'read', name: 'Read' },
|
||||
{ value: 'involved', name: 'Involved' },
|
||||
] as const
|
||||
const MESSAGE_FILTER_VALUES = MESSAGE_FILTERS.map((filter) => filter.value)
|
||||
|
||||
const currentProjectTypeFilter = ref('all')
|
||||
const projectTypeFilterTypes: ComboboxOption<string>[] = [
|
||||
{ value: 'all', label: 'All project types' },
|
||||
{ value: 'modpack', label: 'Modpacks' },
|
||||
{ value: 'mod', label: 'Mods' },
|
||||
{ value: 'resourcepack', label: 'Resource Packs' },
|
||||
{ value: 'datapack', label: 'Data Packs' },
|
||||
{ value: 'plugin', label: 'Plugins' },
|
||||
{ value: 'shader', label: 'Shaders' },
|
||||
{ value: 'minecraft_java_server', label: 'Servers' },
|
||||
{ value: 'shared-instance', label: 'Shared instance' },
|
||||
]
|
||||
const PROJECT_TYPE_FILTERS = [
|
||||
{ value: 'all', name: 'All project types' },
|
||||
{ value: 'modpack', name: 'Modpacks' },
|
||||
{ value: 'mod', name: 'Mods' },
|
||||
{ value: 'resourcepack', name: 'Resource Packs' },
|
||||
{ value: 'datapack', name: 'Data Packs' },
|
||||
{ value: 'plugin', name: 'Plugins' },
|
||||
{ value: 'shader', name: 'Shaders' },
|
||||
{ value: 'minecraft_java_server', name: 'Servers' },
|
||||
{ value: 'shared-instance', name: 'Shared instance' },
|
||||
] as const
|
||||
const PROJECT_TYPE_VALUES = PROJECT_TYPE_FILTERS.map((filter) => filter.value)
|
||||
|
||||
const currentReportTargetFilter = ref('all')
|
||||
const reportTargetFilterTypes: ComboboxOption<string>[] = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'project', label: 'Projects' },
|
||||
{ value: 'user', label: 'Users' },
|
||||
{ value: 'version', label: 'Versions' },
|
||||
{ value: 'shared-instance', label: 'Shared instances' },
|
||||
]
|
||||
const REPORT_TARGET_FILTERS = [
|
||||
{ value: 'all', name: 'All' },
|
||||
{ value: 'project', name: 'Projects' },
|
||||
{ value: 'user', name: 'Users' },
|
||||
{ value: 'version', name: 'Versions' },
|
||||
{ value: 'shared-instance', name: 'Shared instances' },
|
||||
] as const
|
||||
const REPORT_TARGET_VALUES = REPORT_TARGET_FILTERS.map((filter) => filter.value)
|
||||
|
||||
const currentReportIssueFilter = ref('all')
|
||||
const reportIssueFilterTypes = computed<ComboboxOption<string>[]>(() => {
|
||||
const base: ComboboxOption<string>[] = [{ value: 'all', label: 'All' }]
|
||||
if (!allReports.value) return base
|
||||
function parseAllowed<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
|
||||
const parsed = queryAsStringOrEmpty((value as string | string[] | null | undefined) ?? '')
|
||||
return (allowed as readonly string[]).includes(parsed) ? (parsed as T) : fallback
|
||||
}
|
||||
|
||||
const issueTypes = new Set(allReports.value.map((report) => report.report_type))
|
||||
function parsePage(value: unknown): number {
|
||||
const page = Number.parseInt(
|
||||
queryAsStringOrEmpty((value as string | string[] | null | undefined) ?? ''),
|
||||
10,
|
||||
)
|
||||
return Number.isInteger(page) && page > 0 ? page : 1
|
||||
}
|
||||
|
||||
const sortedTypes = Array.from(issueTypes).sort()
|
||||
return [
|
||||
...base,
|
||||
...sortedTypes.map((type) => ({
|
||||
value: type,
|
||||
label: formatReportType(formatMessage, type),
|
||||
})),
|
||||
]
|
||||
})
|
||||
function selectedValuesEqual(left: string[], right: string[]): boolean {
|
||||
if (left.length !== right.length) return false
|
||||
return left.every((value, index) => value === right[index])
|
||||
}
|
||||
|
||||
function serializeRouteQuery(query: typeof route.query): string {
|
||||
const keys = Object.keys(query).sort()
|
||||
return JSON.stringify(
|
||||
Object.fromEntries(
|
||||
keys.flatMap((key) => {
|
||||
const value = query[key]
|
||||
if (value == null || value === '') return []
|
||||
return [[key, Array.isArray(value) ? value.map(String) : String(value)]]
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const query = ref(queryAsStringOrEmpty(route.query.q ?? ''))
|
||||
const currentSortTypeSorting = ref(parseAllowed(route.query.sort, SORT_VALUES, 'oldest'))
|
||||
const currentMessageFilter = ref(parseAllowed(route.query.messages, MESSAGE_FILTER_VALUES, 'all'))
|
||||
const currentMessageFilterName = computed(
|
||||
() =>
|
||||
MESSAGE_FILTERS.find((filter) => filter.value === currentMessageFilter.value)?.name ?? 'All',
|
||||
)
|
||||
const currentProjectTypeFilter = ref(
|
||||
parseAllowed(route.query.projectType, PROJECT_TYPE_VALUES, 'all'),
|
||||
)
|
||||
const currentReportTargetFilter = ref(parseAllowed(route.query.target, REPORT_TARGET_VALUES, 'all'))
|
||||
const currentReportIssueFilter = ref(queryAsStringOrEmpty(route.query.issue ?? '') || 'all')
|
||||
const currentReporterOrProject = ref(queryAsStringArray(route.query.selected))
|
||||
const currentPage = ref(parsePage(route.query.page))
|
||||
|
||||
function writeFiltersToRoute() {
|
||||
const nextQuery = { ...route.query }
|
||||
|
||||
if (query.value) nextQuery.q = query.value
|
||||
else delete nextQuery.q
|
||||
|
||||
if (currentSortTypeSorting.value !== 'oldest') nextQuery.sort = currentSortTypeSorting.value
|
||||
else delete nextQuery.sort
|
||||
|
||||
if (currentMessageFilter.value !== 'all') nextQuery.messages = currentMessageFilter.value
|
||||
else delete nextQuery.messages
|
||||
|
||||
if (currentReportTargetFilter.value !== 'all') nextQuery.target = currentReportTargetFilter.value
|
||||
else delete nextQuery.target
|
||||
|
||||
if (currentReportIssueFilter.value !== 'all') nextQuery.issue = currentReportIssueFilter.value
|
||||
else delete nextQuery.issue
|
||||
|
||||
if (currentProjectTypeFilter.value !== 'all') {
|
||||
nextQuery.projectType = currentProjectTypeFilter.value
|
||||
} else {
|
||||
delete nextQuery.projectType
|
||||
}
|
||||
|
||||
if (currentReporterOrProject.value.length === 1) {
|
||||
nextQuery.selected = currentReporterOrProject.value[0]
|
||||
} else if (currentReporterOrProject.value.length > 1) {
|
||||
nextQuery.selected = currentReporterOrProject.value
|
||||
} else {
|
||||
delete nextQuery.selected
|
||||
}
|
||||
|
||||
if (currentPage.value > 1) nextQuery.page = String(currentPage.value)
|
||||
else delete nextQuery.page
|
||||
|
||||
if (serializeRouteQuery(route.query) === serializeRouteQuery(nextQuery)) return
|
||||
|
||||
router.replace({
|
||||
path: route.path,
|
||||
query: nextQuery,
|
||||
})
|
||||
}
|
||||
|
||||
function readFiltersFromRoute() {
|
||||
const nextQuery = queryAsStringOrEmpty(route.query.q ?? '')
|
||||
if (query.value !== nextQuery) query.value = nextQuery
|
||||
|
||||
const nextSort = parseAllowed(route.query.sort, SORT_VALUES, 'oldest')
|
||||
if (currentSortTypeSorting.value !== nextSort) currentSortTypeSorting.value = nextSort
|
||||
|
||||
const nextMessages = parseAllowed(route.query.messages, MESSAGE_FILTER_VALUES, 'all')
|
||||
if (currentMessageFilter.value !== nextMessages) currentMessageFilter.value = nextMessages
|
||||
|
||||
const nextProjectType = parseAllowed(route.query.projectType, PROJECT_TYPE_VALUES, 'all')
|
||||
if (currentProjectTypeFilter.value !== nextProjectType) {
|
||||
currentProjectTypeFilter.value = nextProjectType
|
||||
}
|
||||
|
||||
const nextTarget = parseAllowed(route.query.target, REPORT_TARGET_VALUES, 'all')
|
||||
if (currentReportTargetFilter.value !== nextTarget) currentReportTargetFilter.value = nextTarget
|
||||
|
||||
const nextIssue = queryAsStringOrEmpty(route.query.issue ?? '') || 'all'
|
||||
if (currentReportIssueFilter.value !== nextIssue) currentReportIssueFilter.value = nextIssue
|
||||
|
||||
const nextSelected = queryAsStringArray(route.query.selected)
|
||||
if (!selectedValuesEqual(currentReporterOrProject.value, nextSelected)) {
|
||||
currentReporterOrProject.value = nextSelected
|
||||
}
|
||||
|
||||
const nextPage = parsePage(route.query.page)
|
||||
if (currentPage.value !== nextPage) currentPage.value = nextPage
|
||||
}
|
||||
|
||||
watch(
|
||||
[
|
||||
query,
|
||||
currentSortTypeSorting,
|
||||
currentMessageFilter,
|
||||
currentProjectTypeFilter,
|
||||
currentReportTargetFilter,
|
||||
currentReportIssueFilter,
|
||||
currentReporterOrProject,
|
||||
currentPage,
|
||||
],
|
||||
writeFiltersToRoute,
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
watch(() => route.query, readFiltersFromRoute, { deep: true })
|
||||
|
||||
type ReportedType<T> = T & { report_item_count: number }
|
||||
|
||||
const currentReporterOrProject = ref<string[]>([])
|
||||
const reporterOrProjectOptions = computed<MultiSelectItem<string>[]>(() => {
|
||||
if (!allReports.value) return []
|
||||
const options: MultiSelectItem<string>[] = []
|
||||
@@ -410,7 +498,7 @@ const reporterOrProjectOptions = computed<MultiSelectItem<string>[]>(() => {
|
||||
.forEach((project) => {
|
||||
options.push({
|
||||
value: `project/${project.id}`,
|
||||
label: `${project.title} (${project.report_item_count})`,
|
||||
label: `${project.title} (${formatNumber(project.report_item_count)})`,
|
||||
icon: project.icon_url ? h('img', { src: project.icon_url }) : undefined,
|
||||
})
|
||||
})
|
||||
@@ -426,7 +514,7 @@ const reporterOrProjectOptions = computed<MultiSelectItem<string>[]>(() => {
|
||||
.forEach((reporter) => {
|
||||
options.push({
|
||||
value: `reporter/${reporter.id}`,
|
||||
label: `${reporter.username} (${reporter.report_item_count})`,
|
||||
label: `${reporter.username} (${formatNumber(reporter.report_item_count)})`,
|
||||
icon: reporter.avatar_url ? h('img', { src: reporter.avatar_url }) : undefined,
|
||||
})
|
||||
})
|
||||
@@ -434,7 +522,6 @@ const reporterOrProjectOptions = computed<MultiSelectItem<string>[]>(() => {
|
||||
return options
|
||||
})
|
||||
|
||||
const currentPage = ref(1)
|
||||
const itemsPerPage = 15
|
||||
const totalPages = computed(() => Math.ceil((sortedReports.value?.length || 0) / itemsPerPage))
|
||||
|
||||
@@ -501,65 +588,164 @@ const baseFiltered = computed(() => {
|
||||
})
|
||||
|
||||
const filteredReports = computed(() => {
|
||||
const messageFilter = currentMessageFilter.value
|
||||
const projectTypeFilter = currentProjectTypeFilter.value
|
||||
const reportTargetFilter = currentReportTargetFilter.value
|
||||
const reportIssueFilter = currentReportIssueFilter.value
|
||||
|
||||
if (
|
||||
messageFilter === 'all' &&
|
||||
projectTypeFilter === 'all' &&
|
||||
reportTargetFilter === 'all' &&
|
||||
reportIssueFilter === 'all'
|
||||
) {
|
||||
return baseFiltered.value
|
||||
}
|
||||
|
||||
const messageFilterPredicate = (report: ExtendedReport) => {
|
||||
const messages = report.thread?.messages || []
|
||||
|
||||
if (messageFilter === 'all') return true
|
||||
if (messages.length === 0) return messageFilter === 'Unread'
|
||||
if (!messages[messages.length - 1].author_id) return false
|
||||
|
||||
if (messageFilter === 'involved') {
|
||||
const userId = (auth.value.user as any)?.id
|
||||
return userId && messages.some((message) => message.author_id === userId)
|
||||
}
|
||||
|
||||
const roleMap = memberRoleMap.value.get(report.id)
|
||||
if (!roleMap) return false
|
||||
|
||||
const authorRole = roleMap.get(messages[messages.length - 1].author_id)
|
||||
const isModeratorMessage = authorRole === 'moderator' || authorRole === 'admin'
|
||||
|
||||
return messageFilter === 'Read' ? isModeratorMessage : !isModeratorMessage
|
||||
}
|
||||
|
||||
const projectTypeFilterPredicate = (report: ExtendedReport) => {
|
||||
if (projectTypeFilter === 'all') return true
|
||||
if (projectTypeFilter === 'shared-instance') return report.item_type === 'shared-instance'
|
||||
return report.project?.project_type === projectTypeFilter
|
||||
}
|
||||
|
||||
const reportTargetFilterPredicate = (report: ExtendedReport) => {
|
||||
return reportTargetFilter === 'all' || report.item_type === reportTargetFilter
|
||||
}
|
||||
|
||||
const reportIssueFilterPredicate = (report: ExtendedReport) => {
|
||||
return reportIssueFilter === 'all' || report.report_type === reportIssueFilter
|
||||
}
|
||||
|
||||
return baseFiltered.value.filter((report) => {
|
||||
return (
|
||||
messageFilterPredicate(report) &&
|
||||
projectTypeFilterPredicate(report) &&
|
||||
reportTargetFilterPredicate(report) &&
|
||||
reportIssueFilterPredicate(report)
|
||||
matchesMessageFilter(report, currentMessageFilter.value) &&
|
||||
matchesProjectTypeFilter(report, currentProjectTypeFilter.value) &&
|
||||
matchesReportTargetFilter(report, currentReportTargetFilter.value) &&
|
||||
matchesReportIssueFilter(report, currentReportIssueFilter.value)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
function matchesMessageFilter(
|
||||
report: ExtendedReport,
|
||||
messageFilter: (typeof MESSAGE_FILTERS)[number]['value'] | string,
|
||||
): boolean {
|
||||
if (messageFilter === 'all') return true
|
||||
|
||||
const messages = report.thread?.messages || []
|
||||
if (messages.length === 0) return messageFilter === 'unread'
|
||||
if (!messages[messages.length - 1].author_id) return false
|
||||
|
||||
if (messageFilter === 'involved') {
|
||||
const userId = (auth.value.user as any)?.id
|
||||
return !!userId && messages.some((message) => message.author_id === userId)
|
||||
}
|
||||
|
||||
const roleMap = memberRoleMap.value.get(report.id)
|
||||
if (!roleMap) return false
|
||||
|
||||
const authorRole = roleMap.get(messages[messages.length - 1].author_id)
|
||||
const isModeratorMessage = authorRole === 'moderator' || authorRole === 'admin'
|
||||
|
||||
return messageFilter === 'read' ? isModeratorMessage : !isModeratorMessage
|
||||
}
|
||||
|
||||
function matchesProjectTypeFilter(
|
||||
report: ExtendedReport,
|
||||
projectTypeFilter: (typeof PROJECT_TYPE_FILTERS)[number]['value'] | string,
|
||||
): boolean {
|
||||
if (projectTypeFilter === 'all') return true
|
||||
if (projectTypeFilter === 'shared-instance') return report.item_type === 'shared-instance'
|
||||
return report.project?.project_type === projectTypeFilter
|
||||
}
|
||||
|
||||
function matchesReportTargetFilter(
|
||||
report: ExtendedReport,
|
||||
reportTargetFilter: (typeof REPORT_TARGET_FILTERS)[number]['value'] | string,
|
||||
): boolean {
|
||||
return reportTargetFilter === 'all' || report.item_type === reportTargetFilter
|
||||
}
|
||||
|
||||
function matchesReportIssueFilter(report: ExtendedReport, reportIssueFilter: string): boolean {
|
||||
return reportIssueFilter === 'all' || report.report_type === reportIssueFilter
|
||||
}
|
||||
|
||||
function labelWithCount(name: string, count: number): string {
|
||||
return `${name} (${formatNumber(count)})`
|
||||
}
|
||||
|
||||
const reportsForMessageCounts = computed(() =>
|
||||
baseFiltered.value.filter(
|
||||
(report) =>
|
||||
matchesProjectTypeFilter(report, currentProjectTypeFilter.value) &&
|
||||
matchesReportTargetFilter(report, currentReportTargetFilter.value) &&
|
||||
matchesReportIssueFilter(report, currentReportIssueFilter.value),
|
||||
),
|
||||
)
|
||||
const reportsForProjectTypeCounts = computed(() =>
|
||||
baseFiltered.value.filter(
|
||||
(report) =>
|
||||
matchesMessageFilter(report, currentMessageFilter.value) &&
|
||||
matchesReportTargetFilter(report, currentReportTargetFilter.value) &&
|
||||
matchesReportIssueFilter(report, currentReportIssueFilter.value),
|
||||
),
|
||||
)
|
||||
const reportsForTargetCounts = computed(() =>
|
||||
baseFiltered.value.filter(
|
||||
(report) =>
|
||||
matchesMessageFilter(report, currentMessageFilter.value) &&
|
||||
matchesProjectTypeFilter(report, currentProjectTypeFilter.value) &&
|
||||
matchesReportIssueFilter(report, currentReportIssueFilter.value),
|
||||
),
|
||||
)
|
||||
const reportsForIssueCounts = computed(() =>
|
||||
baseFiltered.value.filter(
|
||||
(report) =>
|
||||
matchesMessageFilter(report, currentMessageFilter.value) &&
|
||||
matchesProjectTypeFilter(report, currentProjectTypeFilter.value) &&
|
||||
matchesReportTargetFilter(report, currentReportTargetFilter.value),
|
||||
),
|
||||
)
|
||||
|
||||
const messageFilterTypes = computed<ComboboxOption<string>[]>(() =>
|
||||
MESSAGE_FILTERS.map((filter) => ({
|
||||
value: filter.value,
|
||||
label: isLoading.value
|
||||
? filter.name
|
||||
: labelWithCount(
|
||||
filter.name,
|
||||
reportsForMessageCounts.value.filter((report) =>
|
||||
matchesMessageFilter(report, filter.value),
|
||||
).length,
|
||||
),
|
||||
})),
|
||||
)
|
||||
|
||||
const projectTypeFilterTypes = computed<ComboboxOption<string>[]>(() =>
|
||||
PROJECT_TYPE_FILTERS.map((filter) => ({
|
||||
value: filter.value,
|
||||
label: isLoading.value
|
||||
? filter.name
|
||||
: labelWithCount(
|
||||
filter.name,
|
||||
reportsForProjectTypeCounts.value.filter((report) =>
|
||||
matchesProjectTypeFilter(report, filter.value),
|
||||
).length,
|
||||
),
|
||||
})),
|
||||
)
|
||||
|
||||
const reportTargetFilterTypes = computed<ComboboxOption<string>[]>(() =>
|
||||
REPORT_TARGET_FILTERS.map((filter) => ({
|
||||
value: filter.value,
|
||||
label: isLoading.value
|
||||
? filter.name
|
||||
: labelWithCount(
|
||||
filter.name,
|
||||
reportsForTargetCounts.value.filter((report) =>
|
||||
matchesReportTargetFilter(report, filter.value),
|
||||
).length,
|
||||
),
|
||||
})),
|
||||
)
|
||||
|
||||
const reportIssueFilterTypes = computed<ComboboxOption<string>[]>(() => {
|
||||
const issueTypes = new Set((allReports.value ?? []).map((report) => report.report_type))
|
||||
const options = [
|
||||
{ value: 'all', name: 'All' },
|
||||
...Array.from(issueTypes)
|
||||
.sort()
|
||||
.map((type) => ({
|
||||
value: type,
|
||||
name: formatReportType(formatMessage, type),
|
||||
})),
|
||||
]
|
||||
|
||||
return options.map((filter) => ({
|
||||
value: filter.value,
|
||||
label: isLoading.value
|
||||
? filter.name
|
||||
: labelWithCount(
|
||||
filter.name,
|
||||
reportsForIssueCounts.value.filter((report) =>
|
||||
matchesReportIssueFilter(report, filter.value),
|
||||
).length,
|
||||
),
|
||||
}))
|
||||
})
|
||||
|
||||
const sortedReports = computed(() => {
|
||||
const reporterOrProjectFilter = currentReporterOrProject.value
|
||||
const filtered =
|
||||
@@ -591,7 +777,37 @@ const paginatedReports = computed(() => {
|
||||
return sortedReports.value.slice(start, end)
|
||||
})
|
||||
|
||||
const pageStart = computed(() =>
|
||||
sortedReports.value.length === 0 ? 0 : itemsPerPage * (currentPage.value - 1) + 1,
|
||||
)
|
||||
const pageEnd = computed(
|
||||
() =>
|
||||
itemsPerPage * (currentPage.value - 1) + Math.min(itemsPerPage, paginatedReports.value.length),
|
||||
)
|
||||
|
||||
function goToPage(page: number) {
|
||||
currentPage.value = page
|
||||
}
|
||||
|
||||
watch(totalPages, (pages) => {
|
||||
if (isLoading.value) return
|
||||
|
||||
if (pages === 0) {
|
||||
if (currentPage.value !== 1) goToPage(1)
|
||||
return
|
||||
}
|
||||
|
||||
if (currentPage.value > pages) {
|
||||
goToPage(pages)
|
||||
}
|
||||
})
|
||||
|
||||
function dismissReport(reportId: string) {
|
||||
if (!allReports.value) return
|
||||
|
||||
allReports.value = allReports.value.filter((report) => report.id !== reportId)
|
||||
if (currentPage.value > totalPages.value) {
|
||||
currentPage.value = Math.max(1, totalPages.value)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { ArrowLeftIcon, LoaderCircleIcon } from '@modrinth/assets'
|
||||
import { ButtonLink, injectModrinthClient } from '@modrinth/ui'
|
||||
import { LoaderCircleIcon } from '@modrinth/assets'
|
||||
import { BackToParentLink, 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'
|
||||
import { flattenFileReports } from '~/components/ui/moderation/tech-review/helpers'
|
||||
import { useTechReviewSources } from '~/components/ui/moderation/tech-review/use-tech-review-sources'
|
||||
|
||||
const client = injectModrinthClient()
|
||||
const queryClient = useQueryClient()
|
||||
@@ -18,133 +20,6 @@ const projectId = String(useRouteId('project'))
|
||||
|
||||
useHead({ title: () => `Tech review - ${projectId} - 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 = reactive<Set<string>>(new Set())
|
||||
const decompiledSources = reactive<Map<string, string>>(new Map())
|
||||
const loadedIssues = reactive<Set<string>>(new Set())
|
||||
|
||||
async function loadIssueSource(issueId: string): Promise<void> {
|
||||
if (loadingIssues.has(issueId) || loadedIssues.has(issueId)) return
|
||||
|
||||
loadingIssues.add(issueId)
|
||||
|
||||
try {
|
||||
const issueData = await client.labrinth.tech_review_internal.getIssue(issueId)
|
||||
|
||||
for (const detail of issueData.details) {
|
||||
if (detail.decompiled_source) {
|
||||
decompiledSources.set(detail.id, detail.decompiled_source)
|
||||
setCachedSource(detail.id, detail.decompiled_source)
|
||||
}
|
||||
}
|
||||
loadedIssues.add(issueId)
|
||||
} catch (error) {
|
||||
console.error('Failed to load issue source:', error)
|
||||
} finally {
|
||||
loadingIssues.delete(issueId)
|
||||
}
|
||||
}
|
||||
|
||||
function findIssuesByIds(issueIds: Set<string>): Labrinth.TechReview.Internal.FileIssue[] {
|
||||
const issues: Labrinth.TechReview.Internal.FileIssue[] = []
|
||||
|
||||
if (!reviewItem.value) return []
|
||||
|
||||
for (const report of reviewItem.value.reports) {
|
||||
for (const issue of report.issues) {
|
||||
if (issueIds.has(issue.id)) {
|
||||
issues.push(issue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
function handleLoadIssueSources(issueIds: string[]): void {
|
||||
const uniqueIssueIds = new Set(issueIds)
|
||||
const issues = findIssuesByIds(uniqueIssueIds)
|
||||
|
||||
for (const issue of issues) {
|
||||
for (const detail of issue.details) {
|
||||
if (!decompiledSources.has(detail.id)) {
|
||||
const cached = getCachedSource(detail.id)
|
||||
if (cached) {
|
||||
decompiledSources.set(detail.id, cached)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hasUncached = issue.details.some((detail) => !decompiledSources.has(detail.id))
|
||||
if (hasUncached) {
|
||||
loadIssueSource(issue.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
data: projectReportData,
|
||||
isLoading: isLoadingReport,
|
||||
@@ -194,11 +69,6 @@ const isLoading = computed(
|
||||
|
||||
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 {
|
||||
@@ -229,15 +99,7 @@ const reviewItem = computed(() => {
|
||||
|
||||
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,
|
||||
})),
|
||||
)
|
||||
: []
|
||||
const reports = project_report ? flattenFileReports(project_report.versions) : []
|
||||
|
||||
return {
|
||||
project: projectData.value,
|
||||
@@ -247,6 +109,10 @@ const reviewItem = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const { loadingIssues, decompiledSources, handleLoadIssueSources } = useTechReviewSources(
|
||||
() => reviewItem.value?.reports.flatMap((report) => report.issues) ?? [],
|
||||
)
|
||||
|
||||
const focusedDetailId = computed(() => route.query.detail?.toString() ?? null)
|
||||
|
||||
async function handleMarkComplete(projectId: string) {
|
||||
@@ -301,13 +167,8 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<ButtonLink :to="'/moderation/technical-review'">
|
||||
<ArrowLeftIcon class="size-5" />
|
||||
Back to queue
|
||||
</ButtonLink>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<BackToParentLink :to="'/moderation/technical-review'"> Back to queue </BackToParentLink>
|
||||
|
||||
<div v-if="isLoading" class="flex flex-col gap-4">
|
||||
<div class="universal-card flex h-48 items-center justify-center">
|
||||
@@ -333,12 +194,12 @@ onUnmounted(() => {
|
||||
:loading-issues="loadingIssues"
|
||||
:decompiled-sources="decompiledSources"
|
||||
:collapsed="false"
|
||||
disable-collapsing
|
||||
@refetch="refetch"
|
||||
@load-issue-sources="handleLoadIssueSources"
|
||||
@mark-complete="handleMarkComplete"
|
||||
@show-malicious-summary="handleShowMaliciousSummary"
|
||||
/>
|
||||
|
||||
<MaliciousSummaryModal ref="maliciousSummaryModalRef" :unsafe-files="currentUnsafeFiles" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,22 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
BlendIcon,
|
||||
ListFilterIcon,
|
||||
LoaderCircleIcon,
|
||||
SearchIcon,
|
||||
SortAscIcon,
|
||||
SortDescIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { BlendIcon, ListFilterIcon, SortAscIcon, SortDescIcon, SpinnerIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Combobox,
|
||||
type ComboboxOption,
|
||||
commonMessages,
|
||||
injectModrinthClient,
|
||||
Input,
|
||||
Pagination,
|
||||
TeleportPopoutMenu,
|
||||
Toggle,
|
||||
useFormatNumber,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useInfiniteQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
@@ -26,7 +19,11 @@ import { nextTick, reactive } from 'vue'
|
||||
import MaliciousSummaryModal, {
|
||||
type UnsafeFile,
|
||||
} from '~/components/ui/moderation/MaliciousSummaryModal.vue'
|
||||
import ModerationQueueSkeleton from '~/components/ui/moderation/ModerationQueueSkeleton.vue'
|
||||
import ModerationQueueToolbar from '~/components/ui/moderation/ModerationQueueToolbar.vue'
|
||||
import ModerationTechRevCard from '~/components/ui/moderation/ModerationTechRevCard.vue'
|
||||
import { flattenFileReports } from '~/components/ui/moderation/tech-review/helpers'
|
||||
import { useTechReviewSources } from '~/components/ui/moderation/tech-review/use-tech-review-sources'
|
||||
|
||||
useHead({ title: 'Tech review queue - Modrinth' })
|
||||
|
||||
@@ -34,187 +31,14 @@ const client = injectModrinthClient()
|
||||
const queryClient = useQueryClient()
|
||||
const keybinds = useModerationKeybinds()
|
||||
|
||||
const currentPage = ref(1)
|
||||
const API_PAGE_SIZE = 50
|
||||
const UI_PAGE_SIZE = 4
|
||||
const { formatMessage } = useVIntl()
|
||||
const formatNumber = useFormatNumber()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
clearExpiredCache()
|
||||
|
||||
const loadingIssues = reactive<Set<string>>(new Set())
|
||||
const decompiledSources = reactive<Map<string, string>>(new Map())
|
||||
const loadedIssues = reactive<Set<string>>(new Set())
|
||||
|
||||
async function loadIssueSource(issueId: string): Promise<void> {
|
||||
if (loadingIssues.has(issueId) || loadedIssues.has(issueId)) return
|
||||
|
||||
loadingIssues.add(issueId)
|
||||
|
||||
try {
|
||||
const issueData = await client.labrinth.tech_review_internal.getIssue(issueId)
|
||||
|
||||
for (const detail of issueData.details) {
|
||||
if (detail.decompiled_source) {
|
||||
decompiledSources.set(detail.id, detail.decompiled_source)
|
||||
setCachedSource(detail.id, detail.decompiled_source)
|
||||
}
|
||||
}
|
||||
loadedIssues.add(issueId)
|
||||
} catch (error) {
|
||||
console.error('Failed to load issue source:', error)
|
||||
} finally {
|
||||
loadingIssues.delete(issueId)
|
||||
}
|
||||
}
|
||||
|
||||
function findIssuesByIds(issueIds: Set<string>): Labrinth.TechReview.Internal.FileIssue[] {
|
||||
const issues: Labrinth.TechReview.Internal.FileIssue[] = []
|
||||
|
||||
for (const review of reviewItems.value) {
|
||||
for (const report of review.reports) {
|
||||
for (const issue of report.issues) {
|
||||
if (issueIds.has(issue.id)) {
|
||||
issues.push(issue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
function handleLoadIssueSources(issueIds: string[]): void {
|
||||
const uniqueIssueIds = new Set(issueIds)
|
||||
const issues = findIssuesByIds(uniqueIssueIds)
|
||||
|
||||
for (const issue of issues) {
|
||||
for (const detail of issue.details) {
|
||||
if (!decompiledSources.has(detail.id)) {
|
||||
const cached = getCachedSource(detail.id)
|
||||
if (cached) {
|
||||
decompiledSources.set(detail.id, cached)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hasUncached = issue.details.some((detail) => !decompiledSources.has(detail.id))
|
||||
if (hasUncached) {
|
||||
loadIssueSource(issue.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const query = ref(route.query.q?.toString() || '')
|
||||
|
||||
watch(
|
||||
query,
|
||||
(newQuery) => {
|
||||
const currentQuery = { ...route.query }
|
||||
if (newQuery) {
|
||||
currentQuery.q = newQuery
|
||||
} else {
|
||||
delete currentQuery.q
|
||||
}
|
||||
|
||||
router.replace({
|
||||
path: route.path,
|
||||
query: currentQuery,
|
||||
})
|
||||
goToPage(1)
|
||||
},
|
||||
{ immediate: false },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => route.query.q,
|
||||
(newQueryParam) => {
|
||||
const newValue = newQueryParam?.toString() || ''
|
||||
if (query.value !== newValue) {
|
||||
query.value = newValue
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const currentFilterType = ref('All flags')
|
||||
|
||||
const filterTypes = computed<ComboboxOption<string>[]>(() => {
|
||||
const base: ComboboxOption<string>[] = [{ value: 'All flags', label: 'All flags' }]
|
||||
if (!reviewItems.value) return base
|
||||
|
||||
const issueTypes = new Set(
|
||||
reviewItems.value
|
||||
.flatMap((review) => review.reports)
|
||||
.flatMap((report) => report.issues)
|
||||
.map((issue) => issue.issue_type),
|
||||
)
|
||||
|
||||
const sortedTypes = Array.from(issueTypes).sort()
|
||||
return [...base, ...sortedTypes.map((type) => ({ value: type, label: type }))]
|
||||
})
|
||||
|
||||
const currentSortType = ref('Severity highest')
|
||||
const SORT_VALUES = ['Severity highest', 'Severity lowest', 'Oldest', 'Newest'] as const
|
||||
const sortTypes: ComboboxOption<string>[] = [
|
||||
{ value: 'Severity highest', label: 'Severity highest' },
|
||||
{ value: 'Severity lowest', label: 'Severity lowest' },
|
||||
@@ -222,26 +46,200 @@ const sortTypes: ComboboxOption<string>[] = [
|
||||
{ value: 'Newest', label: 'Newest' },
|
||||
]
|
||||
|
||||
const currentResponseFilter = ref('All')
|
||||
const RESPONSE_FILTER_VALUES = ['All', 'Unread', 'Read'] as const
|
||||
const responseFilterTypes: ComboboxOption<string>[] = [
|
||||
{ value: 'All', label: 'All' },
|
||||
{ value: 'Unread', label: 'Unread' },
|
||||
{ value: 'Read', label: 'Read' },
|
||||
]
|
||||
|
||||
const currentProjectTypeFilter = ref('All project types')
|
||||
const projectTypeFilterTypes: ComboboxOption<string>[] = [
|
||||
{ value: 'All project types', label: 'All project types' },
|
||||
{ value: 'Modpacks', label: 'Modpacks' },
|
||||
{ value: 'Mods', label: 'Mods' },
|
||||
{ value: 'Resource Packs', label: 'Resource Packs' },
|
||||
{ value: 'Data Packs', label: 'Data Packs' },
|
||||
{ value: 'Plugins', label: 'Plugins' },
|
||||
{ value: 'Shaders', label: 'Shaders' },
|
||||
{ value: 'Servers', label: 'Servers' },
|
||||
]
|
||||
const PROJECT_TYPE_FILTERS = [
|
||||
{ value: 'All project types', name: 'All project types' },
|
||||
{ value: 'Modpacks', name: 'Modpacks' },
|
||||
{ value: 'Mods', name: 'Mods' },
|
||||
{ value: 'Resource Packs', name: 'Resource Packs' },
|
||||
{ value: 'Data Packs', name: 'Data Packs' },
|
||||
{ value: 'Plugins', name: 'Plugins' },
|
||||
{ value: 'Shaders', name: 'Shaders' },
|
||||
{ value: 'Servers', name: 'Servers' },
|
||||
] as const
|
||||
const PROJECT_TYPE_VALUES = PROJECT_TYPE_FILTERS.map((filter) => filter.value)
|
||||
|
||||
const inOtherQueueFilter = ref(true)
|
||||
function parseAllowed<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
|
||||
const parsed = queryAsStringOrEmpty((value as string | string[] | null | undefined) ?? '')
|
||||
return (allowed as readonly string[]).includes(parsed) ? (parsed as T) : fallback
|
||||
}
|
||||
|
||||
function parsePage(value: unknown): number {
|
||||
const page = Number.parseInt(
|
||||
queryAsStringOrEmpty((value as string | string[] | null | undefined) ?? ''),
|
||||
10,
|
||||
)
|
||||
return Number.isInteger(page) && page > 0 ? page : 1
|
||||
}
|
||||
|
||||
function parseBoolean(value: unknown, fallback: boolean): boolean {
|
||||
const parsed = queryAsStringOrEmpty(
|
||||
(value as string | string[] | null | undefined) ?? '',
|
||||
).toLowerCase()
|
||||
if (parsed === 'true' || parsed === '1') return true
|
||||
if (parsed === 'false' || parsed === '0') return false
|
||||
return fallback
|
||||
}
|
||||
|
||||
function serializeRouteQuery(query: typeof route.query): string {
|
||||
const keys = Object.keys(query).sort()
|
||||
return JSON.stringify(
|
||||
Object.fromEntries(
|
||||
keys.flatMap((key) => {
|
||||
const value = query[key]
|
||||
if (value == null || value === '') return []
|
||||
return [[key, Array.isArray(value) ? value.map(String) : String(value)]]
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const query = ref(queryAsStringOrEmpty(route.query.q ?? ''))
|
||||
const currentFilterType = ref(queryAsStringOrEmpty(route.query.flags ?? '') || 'All flags')
|
||||
const currentSortType = ref(parseAllowed(route.query.sort, SORT_VALUES, 'Severity highest'))
|
||||
const currentResponseFilter = ref(parseAllowed(route.query.response, RESPONSE_FILTER_VALUES, 'All'))
|
||||
const currentProjectTypeFilter = ref(
|
||||
parseAllowed(route.query.projectType, PROJECT_TYPE_VALUES, 'All project types'),
|
||||
)
|
||||
const inOtherQueueFilter = ref(parseBoolean(route.query.underReview, true))
|
||||
const currentPage = ref(parsePage(route.query.page))
|
||||
|
||||
let syncingFromRoute = false
|
||||
|
||||
function writeFiltersToRoute() {
|
||||
if (syncingFromRoute) return
|
||||
|
||||
const nextQuery = { ...route.query }
|
||||
|
||||
if (query.value) nextQuery.q = query.value
|
||||
else delete nextQuery.q
|
||||
|
||||
if (currentSortType.value !== 'Severity highest') nextQuery.sort = currentSortType.value
|
||||
else delete nextQuery.sort
|
||||
|
||||
if (currentResponseFilter.value !== 'All') nextQuery.response = currentResponseFilter.value
|
||||
else delete nextQuery.response
|
||||
|
||||
if (currentFilterType.value !== 'All flags') nextQuery.flags = currentFilterType.value
|
||||
else delete nextQuery.flags
|
||||
|
||||
if (currentProjectTypeFilter.value !== 'All project types') {
|
||||
nextQuery.projectType = currentProjectTypeFilter.value
|
||||
} else {
|
||||
delete nextQuery.projectType
|
||||
}
|
||||
|
||||
if (!inOtherQueueFilter.value) nextQuery.underReview = 'false'
|
||||
else delete nextQuery.underReview
|
||||
|
||||
if (currentPage.value > 1) nextQuery.page = String(currentPage.value)
|
||||
else delete nextQuery.page
|
||||
|
||||
if (serializeRouteQuery(route.query) === serializeRouteQuery(nextQuery)) return
|
||||
|
||||
router.replace({
|
||||
path: route.path,
|
||||
query: nextQuery,
|
||||
})
|
||||
}
|
||||
|
||||
function readFiltersFromRoute() {
|
||||
syncingFromRoute = true
|
||||
|
||||
const nextQuery = queryAsStringOrEmpty(route.query.q ?? '')
|
||||
if (query.value !== nextQuery) query.value = nextQuery
|
||||
|
||||
const nextFlags = queryAsStringOrEmpty(route.query.flags ?? '') || 'All flags'
|
||||
if (currentFilterType.value !== nextFlags) currentFilterType.value = nextFlags
|
||||
|
||||
const nextSort = parseAllowed(route.query.sort, SORT_VALUES, 'Severity highest')
|
||||
if (currentSortType.value !== nextSort) currentSortType.value = nextSort
|
||||
|
||||
const nextResponse = parseAllowed(route.query.response, RESPONSE_FILTER_VALUES, 'All')
|
||||
if (currentResponseFilter.value !== nextResponse) currentResponseFilter.value = nextResponse
|
||||
|
||||
const nextProjectType = parseAllowed(
|
||||
route.query.projectType,
|
||||
PROJECT_TYPE_VALUES,
|
||||
'All project types',
|
||||
)
|
||||
if (currentProjectTypeFilter.value !== nextProjectType) {
|
||||
currentProjectTypeFilter.value = nextProjectType
|
||||
}
|
||||
|
||||
const nextUnderReview = parseBoolean(route.query.underReview, true)
|
||||
if (inOtherQueueFilter.value !== nextUnderReview) inOtherQueueFilter.value = nextUnderReview
|
||||
|
||||
const nextPage = parsePage(route.query.page)
|
||||
if (currentPage.value !== nextPage) currentPage.value = nextPage
|
||||
|
||||
nextTick(() => {
|
||||
syncingFromRoute = false
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
[
|
||||
query,
|
||||
currentFilterType,
|
||||
currentSortType,
|
||||
currentResponseFilter,
|
||||
currentProjectTypeFilter,
|
||||
inOtherQueueFilter,
|
||||
currentPage,
|
||||
],
|
||||
writeFiltersToRoute,
|
||||
)
|
||||
|
||||
watch(() => route.query, readFiltersFromRoute, { deep: true })
|
||||
|
||||
const filterTypes = computed<ComboboxOption<string>[]>(() => {
|
||||
const issues =
|
||||
reviewItems.value?.flatMap((review) => review.reports.flatMap((report) => report.issues)) ?? []
|
||||
const counts = new Map<string, number>()
|
||||
for (const issue of issues) {
|
||||
counts.set(issue.issue_type, (counts.get(issue.issue_type) ?? 0) + 1)
|
||||
}
|
||||
|
||||
const options: ComboboxOption<string>[] = [
|
||||
{
|
||||
value: 'All flags',
|
||||
label: isLoading.value ? 'All flags' : `All flags (${formatNumber(issues.length)})`,
|
||||
},
|
||||
]
|
||||
for (const type of Array.from(counts.keys()).sort()) {
|
||||
options.push({
|
||||
value: type,
|
||||
label: isLoading.value ? type : `${type} (${formatNumber(counts.get(type) ?? 0)})`,
|
||||
})
|
||||
}
|
||||
return options
|
||||
})
|
||||
|
||||
const projectTypeFilterTypes = computed<ComboboxOption<string>[]>(() => {
|
||||
const items = reviewItems.value ?? []
|
||||
const showCounts = !isLoading.value && currentProjectTypeFilter.value === 'All project types'
|
||||
|
||||
return PROJECT_TYPE_FILTERS.map((filter) => {
|
||||
if (!showCounts) {
|
||||
return { value: filter.value, label: filter.name }
|
||||
}
|
||||
|
||||
const apiType = toApiProjectType(filter.value)
|
||||
const count =
|
||||
filter.value === 'All project types'
|
||||
? items.length
|
||||
: items.filter((item) => apiType && item.project.project_types.includes(apiType)).length
|
||||
|
||||
return { value: filter.value, label: `${filter.name} (${formatNumber(count)})` }
|
||||
})
|
||||
})
|
||||
|
||||
const techReviewQueryKey = computed(
|
||||
() =>
|
||||
@@ -275,13 +273,11 @@ const searchResults = computed(() => {
|
||||
return fuse.value.search(query.value).map((result) => result.item)
|
||||
})
|
||||
|
||||
const baseFiltered = computed(() => {
|
||||
const filteredItems = computed(() => {
|
||||
if (!reviewItems.value) return []
|
||||
return query.value && searchResults.value ? searchResults.value : [...reviewItems.value]
|
||||
})
|
||||
|
||||
const filteredItems = computed(() => baseFiltered.value)
|
||||
|
||||
const filteredIssuesCount = computed(() => {
|
||||
return filteredItems.value.reduce((total, review) => {
|
||||
return total + review.reports.reduce((sum, report) => sum + report.issues.length, 0)
|
||||
@@ -295,6 +291,15 @@ const paginatedItems = computed(() => {
|
||||
const end = start + UI_PAGE_SIZE
|
||||
return filteredItems.value.slice(start, end)
|
||||
})
|
||||
const pageStart = computed(() =>
|
||||
filteredItems.value.length === 0 ? 0 : (currentPage.value - 1) * UI_PAGE_SIZE + 1,
|
||||
)
|
||||
const pageEnd = computed(() =>
|
||||
Math.min(
|
||||
(currentPage.value - 1) * UI_PAGE_SIZE + paginatedItems.value.length,
|
||||
filteredItems.value.length,
|
||||
),
|
||||
)
|
||||
function goToPage(page: number, top = false) {
|
||||
currentPage.value = page
|
||||
|
||||
@@ -388,7 +393,7 @@ const {
|
||||
})
|
||||
},
|
||||
getNextPageParam: (lastPage, allPages) => {
|
||||
// If we got a full page, there's probably more
|
||||
// full page = maybe more
|
||||
return lastPage.project_reports.length >= API_PAGE_SIZE ? allPages.length : undefined
|
||||
},
|
||||
initialPageParam: 0,
|
||||
@@ -423,11 +428,6 @@ const mergedSearchResponse = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
type FlattenedFileReport = Labrinth.TechReview.Internal.FileReport & {
|
||||
id: string
|
||||
version_id: string
|
||||
}
|
||||
|
||||
const reviewItems = computed(() => {
|
||||
if (!mergedSearchResponse.value?.project_reports?.length) {
|
||||
return []
|
||||
@@ -435,47 +435,29 @@ const reviewItems = computed(() => {
|
||||
|
||||
const response = mergedSearchResponse.value
|
||||
|
||||
return response.project_reports
|
||||
.map((projectReport) => {
|
||||
const project = response.projects[projectReport.project_id]
|
||||
const thread = project?.thread_id ? response.threads[project.thread_id] : undefined
|
||||
return response.project_reports.flatMap((projectReport) => {
|
||||
const project = response.projects[projectReport.project_id]
|
||||
const thread = project?.thread_id ? response.threads[project.thread_id] : undefined
|
||||
if (!thread) return []
|
||||
|
||||
if (!thread) return null
|
||||
|
||||
const reports: FlattenedFileReport[] = projectReport.versions.flatMap((version) =>
|
||||
version.files.map((file) => ({
|
||||
...file,
|
||||
id: file.report_id,
|
||||
version_id: version.version_id,
|
||||
})),
|
||||
)
|
||||
|
||||
return {
|
||||
return [
|
||||
{
|
||||
project,
|
||||
project_owner: response.ownership[projectReport.project_id],
|
||||
thread,
|
||||
reports,
|
||||
}
|
||||
})
|
||||
.filter(
|
||||
(
|
||||
item,
|
||||
): item is {
|
||||
project: Labrinth.TechReview.Internal.ProjectModerationInfo
|
||||
project_owner: Labrinth.TechReview.Internal.Ownership
|
||||
thread: Labrinth.TechReview.Internal.Thread
|
||||
reports: FlattenedFileReport[]
|
||||
} => item !== null,
|
||||
)
|
||||
reports: flattenFileReports(projectReport.versions),
|
||||
},
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
function handleMarkComplete(projectId: string) {
|
||||
// Find the index of the current card before removing it
|
||||
const currentIndex = paginatedItems.value.findIndex((item) => item.project.id === projectId)
|
||||
const { loadingIssues, decompiledSources, handleLoadIssueSources } = useTechReviewSources(() =>
|
||||
reviewItems.value.flatMap((review) => review.reports.flatMap((report) => report.issues)),
|
||||
)
|
||||
|
||||
// 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
|
||||
function handleMarkComplete(projectId: string) {
|
||||
const currentIndex = paginatedItems.value.findIndex((item) => item.project.id === projectId)
|
||||
const threadId = reviewItems.value.find((item) => item.project.id === projectId)?.thread?.id
|
||||
|
||||
queryClient.setQueryData(
|
||||
techReviewQueryKey.value,
|
||||
@@ -493,7 +475,7 @@ function handleMarkComplete(projectId: string) {
|
||||
...oldData,
|
||||
pages: oldData.pages.map((page) => ({
|
||||
...page,
|
||||
// Keep the raw page length stable; getNextPageParam uses it to know if more API pages exist.
|
||||
// leave this as-is so getNextPageParam still sees a full page
|
||||
project_reports: page.project_reports,
|
||||
projects: Object.fromEntries(
|
||||
Object.entries(page.projects).filter(([id]) => id !== projectId),
|
||||
@@ -509,25 +491,18 @@ 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
|
||||
refetchType: 'none',
|
||||
})
|
||||
|
||||
// Scroll to the next card after Vue updates the DOM
|
||||
nextTick(() => {
|
||||
// Get the project ID at the same position (next project after removal)
|
||||
const nextItem = paginatedItems.value[currentIndex]
|
||||
if (nextItem) {
|
||||
const nextCard = cardRefs.get(nextItem.project.id)
|
||||
if (nextCard) {
|
||||
nextCard.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
})
|
||||
}
|
||||
cardRefs.get(nextItem.project.id)?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -550,13 +525,16 @@ watch(
|
||||
currentProjectTypeFilter,
|
||||
],
|
||||
() => {
|
||||
if (syncingFromRoute) return
|
||||
goToPage(1)
|
||||
},
|
||||
)
|
||||
|
||||
watch(totalPages, (pages) => {
|
||||
if (isLoading.value) return
|
||||
|
||||
if (pages === 0) {
|
||||
goToPage(1)
|
||||
if (currentPage.value !== 1) goToPage(1)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -626,32 +604,14 @@ onUnmounted(() => {
|
||||
:progress="batchScanProgressInformation"
|
||||
/> -->
|
||||
|
||||
<div class="flex flex-col justify-between gap-2 lg:flex-row">
|
||||
<Input
|
||||
v-model="query"
|
||||
:icon="SearchIcon"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
:placeholder="formatMessage(commonMessages.searchPlaceholder)"
|
||||
clearable
|
||||
wrapper-class="flex-1 lg:max-w-52"
|
||||
input-class="!h-10"
|
||||
@input="goToPage(1)"
|
||||
/>
|
||||
|
||||
<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 items-stretch justify-end gap-2 sm:flex-row sm:items-center lg:flex-shrink-0"
|
||||
>
|
||||
<ModerationQueueToolbar
|
||||
v-model="query"
|
||||
:page="currentPage"
|
||||
:total-pages="totalPages"
|
||||
@search="goToPage(1)"
|
||||
@switch-page="goToPage"
|
||||
>
|
||||
<template #actions>
|
||||
<Combobox
|
||||
v-model="currentResponseFilter"
|
||||
class="!w-full flex-grow sm:!w-[120px] sm:flex-grow-0"
|
||||
@@ -695,16 +655,19 @@ onUnmounted(() => {
|
||||
<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 project queue</span>
|
||||
<span class="whitespace-nowrap font-semibold">Only under review</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
|
||||
>
|
||||
<span class="flex items-center gap-1.5 text-sm font-semibold text-secondary">
|
||||
Flag type
|
||||
<SpinnerIcon v-if="isLoading" class="size-3.5 animate-spin" aria-hidden="true" />
|
||||
<template v-else>({{ formatNumber(filteredIssuesCount) }})</template>
|
||||
</span>
|
||||
<Combobox
|
||||
v-model="currentFilterType"
|
||||
class="!w-full"
|
||||
dropdown-class="!z-[10000]"
|
||||
:options="filterTypes"
|
||||
:placeholder="formatMessage(commonMessages.filterByLabel)"
|
||||
searchable
|
||||
@@ -722,6 +685,7 @@ onUnmounted(() => {
|
||||
<Combobox
|
||||
v-model="currentProjectTypeFilter"
|
||||
class="!w-full"
|
||||
dropdown-class="!z-[10000]"
|
||||
:options="projectTypeFilterTypes"
|
||||
:placeholder="formatMessage(commonMessages.filterByLabel)"
|
||||
searchable
|
||||
@@ -737,23 +701,29 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</template>
|
||||
</TeleportPopoutMenu>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #meta>
|
||||
<div v-if="filteredItems.length > 0" class="flex items-center gap-2">
|
||||
<SpinnerIcon
|
||||
v-if="isFetchingNextPage"
|
||||
v-tooltip="`Pages are still being fetched...`"
|
||||
aria-hidden="true"
|
||||
class="size-4 animate-spin"
|
||||
/>
|
||||
Showing {{ formatNumber(pageStart) }}–{{ formatNumber(pageEnd) }} of
|
||||
{{ formatNumber(filteredItems.length) }} projects
|
||||
</div>
|
||||
</template>
|
||||
</ModerationQueueToolbar>
|
||||
|
||||
<div v-if="totalPages > 1" class="flex justify-center lg:hidden">
|
||||
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
|
||||
<ModerationQueueSkeleton v-if="isLoading" />
|
||||
<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 class="flex flex-col gap-4">
|
||||
<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-else class="flex flex-col gap-4">
|
||||
<div
|
||||
v-for="item in paginatedItems"
|
||||
:key="item.project.id"
|
||||
@@ -780,7 +750,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="totalPages > 1" class="mt-4 flex justify-center">
|
||||
<div v-if="totalPages > 1" class="flex justify-end">
|
||||
<Pagination
|
||||
:page="currentPage"
|
||||
:count="totalPages"
|
||||
|
||||
@@ -29,6 +29,9 @@ export default defineNuxtPlugin((nuxt) => {
|
||||
if (import.meta.server) {
|
||||
nuxt.hooks.hook('app:rendered', () => {
|
||||
vueQueryState.value = dehydrate(queryClient)
|
||||
|
||||
// Hack to prevent memory leak when gcTime is being set on the server side.
|
||||
queryClient.clear()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -28,5 +28,5 @@ export function setupProviders(auth: Awaited<ReturnType<typeof useAuth>>) {
|
||||
setupLoadingStateProvider()
|
||||
setupUserCountryProvider()
|
||||
|
||||
return userPreferences
|
||||
return { userPreferences, notificationManager }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
export default defineNitroPlugin((nitroApp) => {
|
||||
nitroApp.hooks.hook('error', async (error, { event }) => {
|
||||
const statusCode = (error as { statusCode?: number }).statusCode ?? 500
|
||||
if (statusCode < 500) return
|
||||
|
||||
console.error(`[Context Error] at ${event?.path}:`, error)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -396,6 +396,8 @@ pub struct ProjectReport {
|
||||
pub struct VersionReport {
|
||||
/// ID of the project version this report is for.
|
||||
pub version_id: VersionId,
|
||||
/// Version number of the project version this report is for.
|
||||
pub version_number: String,
|
||||
/// Reports for this version's files.
|
||||
#[serde(default)]
|
||||
pub files: Vec<FileReport>,
|
||||
@@ -663,6 +665,7 @@ async fn fetch_project_reports(
|
||||
|
||||
version_reports.push(VersionReport {
|
||||
version_id: VersionId::from(version_query.inner.id),
|
||||
version_number: version_query.inner.version_number.clone(),
|
||||
files: file_reports,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2522,6 +2522,7 @@ export namespace Labrinth {
|
||||
|
||||
export type VersionReport = {
|
||||
version_id: string
|
||||
version_number?: string
|
||||
files: FileReport[]
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ 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 _ArrowUpDownIcon from './icons/arrow-up-down.svg?component'
|
||||
import _ArrowUpFromLineIcon from './icons/arrow-up-from-line.svg?component'
|
||||
import _ArrowUpRightIcon from './icons/arrow-up-right.svg?component'
|
||||
import _ArrowUpZAIcon from './icons/arrow-up-z-a.svg?component'
|
||||
import _AsteriskIcon from './icons/asterisk.svg?component'
|
||||
@@ -477,6 +478,7 @@ export const ArrowLeftIcon = _ArrowLeftIcon
|
||||
export const ArrowLeftRightIcon = _ArrowLeftRightIcon
|
||||
export const ArrowUpIcon = _ArrowUpIcon
|
||||
export const ArrowUpDownIcon = _ArrowUpDownIcon
|
||||
export const ArrowUpFromLineIcon = _ArrowUpFromLineIcon
|
||||
export const ArrowUpRightIcon = _ArrowUpRightIcon
|
||||
export const ArrowUpZAIcon = _ArrowUpZAIcon
|
||||
export const AsteriskIcon = _AsteriskIcon
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<!-- @license lucide-static v0.562.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-arrow-up-from-line"
|
||||
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="m18 9-6-6-6 6" />
|
||||
<path d="M12 3v14" />
|
||||
<path d="M5 21h14" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 375 B |
@@ -850,7 +850,8 @@ a:not(.no-click-animation),
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
h3,
|
||||
h4 {
|
||||
color: var(--color-contrast);
|
||||
}
|
||||
|
||||
@@ -860,6 +861,10 @@ a:not(.no-click-animation),
|
||||
border-bottom: 1px solid var(--color-divider);
|
||||
}
|
||||
|
||||
h4 {
|
||||
@apply mt-5 mb-3;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
@@ -916,13 +921,12 @@ a:not(.no-click-animation),
|
||||
}
|
||||
|
||||
pre {
|
||||
@apply bg-surface-2 rounded-xl p-4 border border-solid border-surface-5;
|
||||
margin-top: 1rem;
|
||||
padding: 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
background-color: var(--color-button-bg);
|
||||
overflow-x: auto;
|
||||
|
||||
code {
|
||||
@apply bg-transparent border-0;
|
||||
font-size: 80%;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
@@ -930,6 +934,7 @@ a:not(.no-click-animation),
|
||||
}
|
||||
|
||||
code {
|
||||
@apply bg-surface-3 rounded-md p-4 border border-solid border-surface-5;
|
||||
padding: 0.2em 0.4em;
|
||||
font-size: 80%;
|
||||
border-radius: var(--radius-sm);
|
||||
@@ -983,38 +988,80 @@ a:not(.no-click-animation),
|
||||
}
|
||||
|
||||
details {
|
||||
border: 0.15rem solid var(--color-button-bg);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.5rem 0.5rem 0;
|
||||
@apply border border-solid border-surface-5 bg-surface-3 rounded-xl p-2;
|
||||
display: grid;
|
||||
grid-template-rows: min-content 0fr;
|
||||
transition: grid-template-rows 0.3s var(--ease-out-expo);
|
||||
overflow: clip;
|
||||
|
||||
summary {
|
||||
font-weight: bold;
|
||||
margin: -0.5rem -0.5rem 0;
|
||||
padding: 0.5rem 0.8rem;
|
||||
@apply bg-surface-4 hover:bg-surface-5 p-2 flex items-center gap-1 text-contrast -m-2 border border-solid border-transparent;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
background-color: var(--color-button-bg);
|
||||
border-radius: var(--radius-xs);
|
||||
list-style: none;
|
||||
|
||||
&:hover {
|
||||
filter: brightness(0.85);
|
||||
&::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&::marker {
|
||||
content: none;
|
||||
}
|
||||
|
||||
&::before {
|
||||
@apply text-primary;
|
||||
content: '';
|
||||
flex-shrink: 0;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
background-color: currentColor;
|
||||
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none'%3E%3Cpath d='M19 9L12 16L5 9' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E")
|
||||
center / contain no-repeat;
|
||||
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none'%3E%3Cpath d='M19 9L12 16L5 9' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E")
|
||||
center / contain no-repeat;
|
||||
transition: transform 0.3s ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
> :nth-child(2) {
|
||||
@apply mt-5;
|
||||
}
|
||||
|
||||
> :nth-child(n + 2) {
|
||||
@apply mx-1;
|
||||
}
|
||||
|
||||
> :last-child:not(summary) {
|
||||
@apply mb-1;
|
||||
}
|
||||
|
||||
&::details-content {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
transition: content-visibility 0.3s allow-discrete;
|
||||
}
|
||||
|
||||
&[open] {
|
||||
padding: 0.5rem;
|
||||
grid-template-rows: min-content 1fr;
|
||||
|
||||
summary {
|
||||
margin-bottom: 0.5rem;
|
||||
border-radius: var(--radius-xs) var(--radius-xs) 0 0;
|
||||
> summary {
|
||||
@apply border-b-surface-5;
|
||||
|
||||
&::before {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> :last-child:not(summary) {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
transition: none;
|
||||
|
||||
> details:not([open]) summary {
|
||||
margin: -0.5em -0.5em 0;
|
||||
border-radius: var(--radius-xs);
|
||||
summary,
|
||||
summary::before,
|
||||
&::details-content {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,26 @@ export type VersionEntry = {
|
||||
}
|
||||
|
||||
const VERSIONS: VersionEntry[] = [
|
||||
{
|
||||
date: `2026-08-27T18:54:17+00:00`,
|
||||
product: 'app',
|
||||
version: '0.19.1',
|
||||
body: `## Changed
|
||||
- Updated style of spoilers in descriptions.
|
||||
- Updated style of code formatting and snippets in descriptions.
|
||||
- Fourth-level headings in descriptions now have more contrast compared to regular body text.
|
||||
|
||||
## Fixed
|
||||
- Fixed lag on Screenshots page/tab when you have a lot of screenshots`,
|
||||
},
|
||||
{
|
||||
date: `2026-08-27T18:54:17+00:00`,
|
||||
product: 'web',
|
||||
body: `## Changed
|
||||
- Updated style of spoilers in descriptions.
|
||||
- Updated style of code formatting and snippets in descriptions.
|
||||
- Fourth-level headings in descriptions now have more contrast compared to regular body text.`,
|
||||
},
|
||||
{
|
||||
date: `2026-08-27T17:07:29+00:00`,
|
||||
product: 'app',
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<AutoLink
|
||||
:to="to"
|
||||
class="mb-4 flex w-fit items-center gap-2 rounded-lg px-2 py-0.5 pl-0 text-link"
|
||||
>
|
||||
<ChevronLeftIcon aria-hidden="true" class="shrink-0" />
|
||||
<slot />
|
||||
</AutoLink>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ChevronLeftIcon } from '@modrinth/assets'
|
||||
|
||||
import AutoLink from './AutoLink.vue'
|
||||
|
||||
defineProps<{
|
||||
to: unknown
|
||||
}>()
|
||||
</script>
|
||||
@@ -2,28 +2,29 @@
|
||||
<div class="relative overflow-hidden">
|
||||
<div
|
||||
class="collapsible-region-content"
|
||||
:class="{ open: !collapsed }"
|
||||
:class="{ open: !collapsed || disabled }"
|
||||
:style="{ '--collapsed-height': collapsedHeight }"
|
||||
>
|
||||
<div :class="{ 'pointer-events-none select-none pb-16': collapsed }">
|
||||
<div :class="{ 'pointer-events-none select-none': collapsed }">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="collapsed"
|
||||
class="pointer-events-none absolute inset-0 bg-gradient-to-b from-transparent"
|
||||
:class="gradientTo"
|
||||
v-if="!disabled"
|
||||
class="pointer-events-none absolute inset-0 bg-gradient-to-b from-transparent transition-opacity duration-250"
|
||||
:class="[gradientTo, { 'opacity-0': !collapsed }]"
|
||||
/>
|
||||
|
||||
<div class="absolute bottom-4 left-1/2 z-20 -translate-x-1/2">
|
||||
<Button
|
||||
type="quiet"
|
||||
class="flex items-center gap-1 text-xs !rounded-full"
|
||||
@click="collapsed = !collapsed"
|
||||
>
|
||||
<ExpandIcon v-if="collapsed" />
|
||||
<CollapseIcon v-else />
|
||||
<div v-if="!collapsed && !disabled" class="top-4 right-4 z-20 group absolute">
|
||||
<Button v-tooltip="collapseText" type="quiet" circular icon-only @click="collapsed = true">
|
||||
<DropdownIcon class="rotate-180" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="!disabled" class="absolute bottom-4 left-1/2 z-20 -translate-x-1/2">
|
||||
<Button type="quiet" class="text-xs" @click="collapsed = !collapsed">
|
||||
<DropdownIcon class="transition-transform" :class="{ 'rotate-180': !collapsed }" />
|
||||
{{ collapsed ? expandText : collapseText }}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -31,7 +32,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CollapseIcon, ExpandIcon } from '@modrinth/assets'
|
||||
import { DropdownIcon } from '@modrinth/assets'
|
||||
|
||||
import { Button } from '#ui/components/base/buttons'
|
||||
|
||||
@@ -41,12 +42,14 @@ withDefaults(
|
||||
collapseText?: string
|
||||
collapsedHeight?: string
|
||||
gradientTo?: string
|
||||
disabled?: boolean
|
||||
}>(),
|
||||
{
|
||||
expandText: 'Expand',
|
||||
collapseText: 'Collapse',
|
||||
collapsedHeight: '8rem',
|
||||
gradientTo: 'to-surface-2',
|
||||
disabled: false,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -56,8 +59,12 @@ const collapsed = defineModel<boolean>('collapsed', { default: true })
|
||||
<style scoped>
|
||||
.collapsible-region-content {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows 0.3s linear;
|
||||
grid-template-rows: minmax(var(--collapsed-height), 0fr);
|
||||
transition: grid-template-rows 500ms var(--ease-out-expo);
|
||||
|
||||
& > div {
|
||||
grid-row: 1 / span 2;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
@@ -67,16 +74,10 @@ const collapsed = defineModel<boolean>('collapsed', { default: true })
|
||||
}
|
||||
|
||||
.collapsible-region-content.open {
|
||||
grid-template-rows: 1fr;
|
||||
grid-template-rows: minmax(var(--collapsed-height), 1fr);
|
||||
}
|
||||
|
||||
.collapsible-region-content > div {
|
||||
overflow: hidden;
|
||||
min-height: var(--collapsed-height);
|
||||
transition: min-height 0.3s linear;
|
||||
}
|
||||
|
||||
.collapsible-region-content.open > div {
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -107,6 +107,7 @@
|
||||
]"
|
||||
:style="[dropdownStyle, { transformOrigin: dropdownTransformOrigin }]"
|
||||
:role="listbox ? 'listbox' : 'menu'"
|
||||
@pointerdown.stop
|
||||
@mousedown.stop
|
||||
@keydown="handleDropdownKeydown"
|
||||
>
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<template>
|
||||
<button
|
||||
class="!m-0 inline-flex w-fit select-text items-center gap-2 rounded-[10px] bg-[var(--color-button-bg)] px-2 py-1 font-mono text-sm text-primary transition-[opacity,filter,transform,outline] duration-200 ease-in-out hover:brightness-[1.25] active:scale-95 active:brightness-[0.8] motion-reduce:transition-none [&>svg]:h-[1em] [&>svg]:w-[1em]"
|
||||
class="rounded-lg border border-solid border-surface-5 bg-surface-2 text-xs !m-0 inline-flex w-fit select-text items-center gap-2 px-2 py-1 font-mono text-primary transition-[opacity,filter,transform,outline] duration-200 ease-in-out hover:brightness-[1.25] active:scale-95 motion-reduce:transition-none [&>svg]:h-[1em] [&>svg]:w-[1em]"
|
||||
:title="formatMessage(copiedMessage)"
|
||||
@click="copyText"
|
||||
>
|
||||
<span>{{ displayText ?? text }}</span>
|
||||
<CheckIcon v-if="copied" />
|
||||
<ClipboardCopyIcon v-else />
|
||||
<CopyIcon v-else />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, ClipboardCopyIcon } from '@modrinth/assets'
|
||||
import { CheckIcon, CopyIcon } from '@modrinth/assets'
|
||||
import { onBeforeUnmount, ref } from 'vue'
|
||||
|
||||
import { defineMessage, useVIntl } from '../../composables/i18n'
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<IconButton
|
||||
v-tooltip="label"
|
||||
:label="label"
|
||||
:disabled="copied"
|
||||
class="relative grid place-items-center overflow-hidden"
|
||||
@click="copyToClipboard"
|
||||
>
|
||||
<CheckIcon
|
||||
class="absolute transition-all ease-in-out"
|
||||
:class="copied ? 'translate-y-0' : 'translate-y-7'"
|
||||
/>
|
||||
<LinkIcon
|
||||
class="absolute transition-all ease-in-out"
|
||||
:class="copied ? '-translate-y-7' : 'translate-y-0'"
|
||||
/>
|
||||
</IconButton>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, LinkIcon } from '@modrinth/assets'
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
|
||||
import { defineMessage, useVIntl } from '../../composables/i18n'
|
||||
import { commonMessages } from '../../utils/common-messages'
|
||||
import IconButton from './buttons/IconButton.vue'
|
||||
|
||||
const copyLinkMessage = commonMessages.copyLinkButton
|
||||
const copiedToClipboardMessage = defineMessage({
|
||||
id: 'button.copied-to-clipboard',
|
||||
defaultMessage: 'Copied to clipboard',
|
||||
})
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const props = defineProps<{
|
||||
url: string
|
||||
copyLabel?: string
|
||||
copiedLabel?: string
|
||||
}>()
|
||||
|
||||
const copied = ref(false)
|
||||
let copiedResetTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const label = computed(() => {
|
||||
if (copied.value) {
|
||||
return props.copiedLabel ?? formatMessage(copiedToClipboardMessage)
|
||||
}
|
||||
|
||||
return props.copyLabel ?? formatMessage(copyLinkMessage)
|
||||
})
|
||||
|
||||
async function copyToClipboard() {
|
||||
await navigator.clipboard.writeText(props.url)
|
||||
copied.value = true
|
||||
clearTimeout(copiedResetTimeout)
|
||||
copiedResetTimeout = setTimeout(() => {
|
||||
copied.value = false
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => clearTimeout(copiedResetTimeout))
|
||||
</script>
|
||||
@@ -158,6 +158,7 @@
|
||||
class="fixed z-[9999] flex flex-col overflow-x-hidden overflow-y-auto rounded-[14px] border border-solid border-surface-5 bg-surface-4 shadow-2xl"
|
||||
:style="addMenuStyle"
|
||||
role="menu"
|
||||
@pointerdown.stop
|
||||
@mousedown.stop
|
||||
@keydown="handleAddMenuKeydown"
|
||||
@mousemove="(event) => handleMenuMouseMove(event, 'menu')"
|
||||
|
||||
@@ -119,6 +119,7 @@
|
||||
:style="dropdownStyle"
|
||||
role="listbox"
|
||||
aria-multiselectable="true"
|
||||
@pointerdown.stop
|
||||
@mousedown.stop
|
||||
@keydown="handleDropdownKeydown"
|
||||
>
|
||||
|
||||
@@ -67,7 +67,7 @@ defineExpose({ element })
|
||||
/>
|
||||
<div
|
||||
data-anchored-scroll-region
|
||||
class="flex min-w-48 flex-col p-2 overflow-y-auto"
|
||||
class="flex flex-col p-2 overflow-y-auto"
|
||||
:style="{ maxHeight: props.panelStyle.maxHeight }"
|
||||
>
|
||||
<slot />
|
||||
|
||||
@@ -5,6 +5,7 @@ export { default as AppearingProgressBar } from './AppearingProgressBar.vue'
|
||||
export { default as AutoBrandIcon } from './AutoBrandIcon.vue'
|
||||
export { default as AutoLink } from './AutoLink.vue'
|
||||
export { default as Avatar } from './Avatar.vue'
|
||||
export { default as BackToParentLink } from './BackToParentLink.vue'
|
||||
export { default as Badge } from './Badge.vue'
|
||||
export { default as BaseTerminal } from './BaseTerminal.vue'
|
||||
export { default as BasicMarkdownText } from './BasicMarkdownText.vue'
|
||||
@@ -46,6 +47,7 @@ export { default as CollapsibleRegion } from './CollapsibleRegion.vue'
|
||||
export type { ComboboxOption, ComboboxSearchInputVariant } from './Combobox.vue'
|
||||
export { default as Combobox } from './Combobox.vue'
|
||||
export { default as CopyCode } from './CopyCode.vue'
|
||||
export { default as CopyLinkButton } from './CopyLinkButton.vue'
|
||||
export { default as DoubleIcon } from './DoubleIcon.vue'
|
||||
export { default as DropArea } from './DropArea.vue'
|
||||
export type { DropdownFilterBarCategory, DropdownFilterBarOption } from './DropdownFilterBar.vue'
|
||||
|
||||
@@ -43,7 +43,9 @@ async function copyProjectLink() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-surface-3 p-4 rounded-2xl flex flex-col gap-3">
|
||||
<div
|
||||
class="bg-surface-3 p-4 rounded-2xl flex flex-col gap-3 border border-solid border-surface-4"
|
||||
>
|
||||
<div class="flex gap-4 justify-between">
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-contrast font-semibold">{{ title }}</span>
|
||||
@@ -88,7 +90,9 @@ async function copyProjectLink() {
|
||||
<div class="font-medium">Notes:</div>
|
||||
<div>{{ notes ?? 'N/A' }}</div>
|
||||
</div>
|
||||
<div class="bg-surface-2 p-4 rounded-2xl flex flex-col gap-3">
|
||||
<div
|
||||
class="bg-surface-2 p-4 rounded-2xl flex flex-col gap-3 border border-solid border-surface-4"
|
||||
>
|
||||
<span class="text-contrast font-semibold">Files</span>
|
||||
<span v-if="!(files?.length > 0)" class="text-secondary">
|
||||
No files available for external project.
|
||||
|
||||
@@ -176,6 +176,9 @@
|
||||
"button.continue": {
|
||||
"defaultMessage": "Continue"
|
||||
},
|
||||
"button.copied-to-clipboard": {
|
||||
"defaultMessage": "Copied to clipboard"
|
||||
},
|
||||
"button.copy-filename": {
|
||||
"defaultMessage": "Copy filename"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import BackToParentLink from '../../components/base/BackToParentLink.vue'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/BackToParentLink',
|
||||
component: BackToParentLink,
|
||||
render: (args) => ({
|
||||
components: { BackToParentLink },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: /*html*/ `
|
||||
<BackToParentLink v-bind="args">All versions</BackToParentLink>
|
||||
`,
|
||||
}),
|
||||
} satisfies Meta<typeof BackToParentLink>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
to: '/versions',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import CopyLinkButton from '../../components/base/CopyLinkButton.vue'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/CopyLinkButton',
|
||||
component: CopyLinkButton,
|
||||
} satisfies Meta<typeof CopyLinkButton>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
url: 'https://modrinth.com',
|
||||
},
|
||||
}
|
||||
|
||||
export const CustomLabels: Story = {
|
||||
args: {
|
||||
url: 'https://modrinth.com',
|
||||
copyLabel: 'Copy project link',
|
||||
copiedLabel: 'Project link copied',
|
||||
},
|
||||
}
|
||||
@@ -146,6 +146,7 @@ export function useAnchoredTeleport(
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
const target = event.target as Node | null
|
||||
if (!target || triggerElement()?.contains(target) || panel.value?.contains(target)) return
|
||||
if (document.getElementById('teleports')?.contains(target)) return
|
||||
close()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user