Compare commits

...
14 Commits
Author SHA1 Message Date
Michael H. 1bee2f83d8 fix: tanstack query memory leak 2026-08-29 02:31:35 +02:00
Michael H. 7823f83d04 fix: tailscale ssh user 2026-08-29 01:30:30 +02:00
Michael H. a6a9139019 chore: set tailscale hostname to magic container hostname 2026-08-29 01:05:40 +02:00
Michael H. b9c3b363e8 chore: frontend debug 2026-08-29 00:48:43 +02:00
Michael H. 0ab9100c46 Revert "fix: i18n nuxt context error"
This reverts commit a1b73d089e.
2026-08-28 16:30:32 +02:00
Michael H. a1b73d089e fix: i18n nuxt context error 2026-08-28 15:30:55 +02:00
Michael H. c2cbf16434 fix: use node cluster 2026-08-28 15:19:37 +02:00
Michael H. ff4c9be910 fix: 404 handling but actually 2026-08-28 15:14:47 +02:00
Truman Gao 98f8bbcad9 fix: 404 always logs error (#7341)
* fix: error page

* fix: nuxt always logs console error on 404

* Revert "fix: error page"

This reverts commit ec15bf3b40.
2026-08-28 13:27:54 +02:00
Prospector a9b774005b fix error pages (#7338) 2026-08-28 01:48:14 +00:00
Prospector 5d47594302 changelog 2026-08-27 11:54:34 -07:00
Prospector 8a0d683ae2 fix moderation advanced filters 2026-08-27 11:52:29 -07:00
Prospector ffd864a9e2 fix moderation collapsibles & do a consistency pass (#7330)
* fix moderation collapsibles & do a consistency pass

* extract tech review sources

* improve buttons

* buttn

* improve markdown styling

* fix nested details

* save filters + fix reports reloading

* prepr
2026-08-27 18:46:13 +00:00
Calum H. 263aaedc8f fix: screenshots page lagging with lots of images (#7336) 2026-08-27 18:27:33 +00:00
84 changed files with 4012 additions and 3201 deletions
+1 -1
View File
@@ -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,
+67 -6
View File
@@ -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"]
+1 -1
View File
@@ -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
}
+7 -4
View File
@@ -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,
+2 -3
View File
@@ -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({
-3
View File
@@ -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"
},
+2 -2
View File
@@ -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>
+94 -109
View File
@@ -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"
+3
View File
@@ -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()
})
}
+1 -1
View File
@@ -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[]
}
+2
View File
@@ -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

+69 -22
View File
@@ -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;
}
}
}
+20
View File
@@ -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"
>
+3 -3
View File
@@ -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 />
+2
View File
@@ -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.
+3
View File
@@ -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()
}