mirror of
https://github.com/modrinth/code.git
synced 2026-08-31 03:55:59 +00:00
* 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
267 lines
7.8 KiB
Vue
267 lines
7.8 KiB
Vue
<template>
|
|
<div>
|
|
<form class="flex flex-col gap-2 sm:flex-row sm:items-center" @submit.prevent="executeSearch">
|
|
<Input
|
|
v-model="query"
|
|
:icon="SearchIcon"
|
|
type="text"
|
|
autocomplete="off"
|
|
placeholder="Search global trace keys..."
|
|
clearable
|
|
size="medium"
|
|
wrapper-class="min-w-0 flex-1"
|
|
/>
|
|
<Button type="colored" color="brand" size="lg" native-type="submit" :disabled="isLoading">
|
|
<SearchIcon aria-hidden="true" />
|
|
Search
|
|
</Button>
|
|
</form>
|
|
|
|
<div
|
|
v-if="!isLoading && !loadError && total > 0"
|
|
class="mt-4 flex flex-wrap items-center justify-between gap-3"
|
|
>
|
|
<p class="m-0">Showing {{ pageStart }}-{{ pageEnd }} of {{ total }}</p>
|
|
<Pagination :page="currentPage" :count="pageCount" @switch-page="switchPage" />
|
|
</div>
|
|
|
|
<EmptyState
|
|
v-if="isLoading"
|
|
type="no-search-result"
|
|
heading="Loading global detail traces..."
|
|
/>
|
|
<EmptyState
|
|
v-else-if="loadError"
|
|
type="no-search-result"
|
|
heading="Failed to load global detail traces"
|
|
/>
|
|
<div v-else-if="traces.length > 0" class="mt-4 flex flex-col gap-3">
|
|
<article
|
|
v-for="trace in traces"
|
|
:key="trace.detail_key"
|
|
class="universal-card flex flex-col gap-3"
|
|
>
|
|
<div class="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3">
|
|
<div class="min-w-0">
|
|
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
|
<HashIcon class="shrink-0 text-secondary" aria-hidden="true" />
|
|
<h2 class="m-0 min-w-0 text-lg font-semibold text-contrast">
|
|
Trace
|
|
<span class="break-all font-mono text-base">{{ trace.detail_key }}</span>
|
|
</h2>
|
|
<span
|
|
v-if="getLatestLocalTrace(trace)"
|
|
class="rounded-full border border-solid px-2.5 py-1 text-sm font-medium capitalize"
|
|
:class="getSeverityBadgeColor(getLatestLocalTrace(trace)?.severity)"
|
|
>
|
|
{{ getLatestLocalTrace(trace)?.severity }}
|
|
</span>
|
|
</div>
|
|
<div v-if="getLatestLocalTrace(trace)" class="mt-1 flex flex-wrap gap-x-3 text-sm">
|
|
<p class="m-0 break-all text-secondary">
|
|
<span class="font-semibold text-contrast">Issue</span>
|
|
{{ getLatestLocalTrace(trace)?.issue_type }}
|
|
</p>
|
|
<p class="m-0 break-all text-secondary">
|
|
<span class="font-semibold text-contrast">Path</span>
|
|
{{ decodeTracePath(getLatestLocalTrace(trace)?.file_path ?? '') }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div class="flex shrink-0 flex-nowrap items-center gap-2">
|
|
<Badge :type="trace.verdict" />
|
|
<Button
|
|
type="colored"
|
|
color="red"
|
|
:disabled="removingTraceKeys.has(trace.detail_key)"
|
|
@click="removeGlobalTrace(trace)"
|
|
>
|
|
<TrashIcon aria-hidden="true" />
|
|
Remove
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="getPreviewLocalTraces(trace).length > 0" class="flex flex-col gap-2">
|
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
|
<p class="m-0 text-sm text-secondary">
|
|
Showing {{ getPreviewLocalTraces(trace).length }} of
|
|
{{ formatTraceCount(getVisibleLocalTraceTotal(trace)) }}
|
|
</p>
|
|
<ButtonLink
|
|
v-if="getVisibleLocalTraceTotal(trace) > getPreviewLocalTraces(trace).length"
|
|
:to="getGlobalTraceLink(trace)"
|
|
>
|
|
<ListIcon aria-hidden="true" />
|
|
View all
|
|
</ButtonLink>
|
|
</div>
|
|
<GlobalDetailLocalTraceCard
|
|
v-for="localTrace in getPreviewLocalTraces(trace)"
|
|
:key="localTrace.detail_id"
|
|
:trace="localTrace"
|
|
/>
|
|
</div>
|
|
<EmptyState
|
|
v-else
|
|
type="no-search-result"
|
|
heading="No local traces currently match this key"
|
|
/>
|
|
</article>
|
|
</div>
|
|
<EmptyState v-else type="no-search-result" heading="No global detail traces found" />
|
|
|
|
<div v-if="!isLoading && !loadError && total > 0" class="mt-4 flex justify-end">
|
|
<Pagination :page="currentPage" :count="pageCount" @switch-page="switchPage" />
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import type { Labrinth } from '@modrinth/api-client'
|
|
import { HashIcon, ListIcon, SearchIcon, TrashIcon } from '@modrinth/assets'
|
|
import {
|
|
Badge,
|
|
Button,
|
|
ButtonLink,
|
|
EmptyState,
|
|
injectModrinthClient,
|
|
injectNotificationManager,
|
|
Input,
|
|
Pagination,
|
|
} from '@modrinth/ui'
|
|
|
|
import GlobalDetailLocalTraceCard from '~/components/ui/moderation/GlobalDetailLocalTraceCard.vue'
|
|
|
|
const client = injectModrinthClient()
|
|
const { addNotification } = injectNotificationManager()
|
|
const query = ref('')
|
|
const activeQuery = ref<string | null>(null)
|
|
const isLoading = ref(false)
|
|
const loadError = ref(false)
|
|
const currentPage = ref(1)
|
|
const itemsPerPage = 20
|
|
const localTracePreviewLimit = 3
|
|
const total = ref(0)
|
|
const traces = ref<Labrinth.TechReview.Internal.GlobalIssueDetail[]>([])
|
|
const removingTraceKeys = reactive<Set<string>>(new Set())
|
|
|
|
const pageCount = computed(() => Math.max(Math.ceil(total.value / itemsPerPage), 1))
|
|
const pageStart = computed(() =>
|
|
total.value === 0 ? 0 : (currentPage.value - 1) * itemsPerPage + 1,
|
|
)
|
|
const pageEnd = computed(() => Math.min(currentPage.value * itemsPerPage, total.value))
|
|
|
|
function formatTraceCount(count: number) {
|
|
return `${count} local ${count === 1 ? 'trace' : 'traces'}`
|
|
}
|
|
|
|
function getPreviewLocalTraces(trace: Labrinth.TechReview.Internal.GlobalIssueDetail) {
|
|
return trace.local_traces.slice(0, localTracePreviewLimit)
|
|
}
|
|
|
|
function getLatestLocalTrace(trace: Labrinth.TechReview.Internal.GlobalIssueDetail) {
|
|
return trace.local_traces.at(-1)
|
|
}
|
|
|
|
function decodeTracePath(path: string): string {
|
|
try {
|
|
return decodeURIComponent(path)
|
|
} catch {
|
|
return path
|
|
}
|
|
}
|
|
|
|
function getSeverityBadgeColor(
|
|
severity: Labrinth.TechReview.Internal.DelphiSeverity | undefined,
|
|
): string {
|
|
switch (severity) {
|
|
case 'severe':
|
|
return 'border-red/60 bg-highlight-red text-red'
|
|
case 'high':
|
|
return 'border-orange/60 bg-highlight-orange text-orange'
|
|
case 'medium':
|
|
return 'border-green/60 bg-highlight-green text-green'
|
|
case 'low':
|
|
default:
|
|
return 'border-blue/60 bg-highlight-blue text-blue'
|
|
}
|
|
}
|
|
|
|
function getVisibleLocalTraceTotal(trace: Labrinth.TechReview.Internal.GlobalIssueDetail) {
|
|
return Math.max(trace.local_trace_count, trace.local_traces.length)
|
|
}
|
|
|
|
function getGlobalTraceLink(trace: Labrinth.TechReview.Internal.GlobalIssueDetail) {
|
|
return `/moderation/global-traces/${encodeURIComponent(trace.detail_key)}`
|
|
}
|
|
|
|
async function loadTraces() {
|
|
isLoading.value = true
|
|
loadError.value = false
|
|
|
|
try {
|
|
const response = await client.labrinth.tech_review_internal.searchGlobalIssueDetails({
|
|
query: activeQuery.value,
|
|
limit: itemsPerPage,
|
|
page: currentPage.value - 1,
|
|
})
|
|
|
|
traces.value = response.traces
|
|
total.value = response.total
|
|
} catch (error) {
|
|
console.error('Failed to load global detail traces', error)
|
|
traces.value = []
|
|
total.value = 0
|
|
loadError.value = true
|
|
} finally {
|
|
isLoading.value = false
|
|
}
|
|
}
|
|
|
|
async function executeSearch() {
|
|
activeQuery.value = query.value.trim() || null
|
|
currentPage.value = 1
|
|
await loadTraces()
|
|
}
|
|
|
|
async function switchPage(page: number) {
|
|
currentPage.value = page
|
|
await loadTraces()
|
|
}
|
|
|
|
async function removeGlobalTrace(trace: Labrinth.TechReview.Internal.GlobalIssueDetail) {
|
|
if (removingTraceKeys.has(trace.detail_key)) return
|
|
|
|
removingTraceKeys.add(trace.detail_key)
|
|
try {
|
|
await client.labrinth.tech_review_internal.updateGlobalIssueDetails([
|
|
{ detail_key: trace.detail_key, verdict: 'pending' },
|
|
])
|
|
|
|
addNotification({
|
|
type: 'success',
|
|
title: 'Global trace removed',
|
|
text: 'The global verdict for this trace key has been removed.',
|
|
})
|
|
|
|
if (traces.value.length === 1 && currentPage.value > 1) {
|
|
currentPage.value--
|
|
}
|
|
|
|
await loadTraces()
|
|
} catch (error) {
|
|
console.error('Failed to remove global trace', error)
|
|
addNotification({
|
|
type: 'error',
|
|
title: 'Failed to remove global trace',
|
|
text: 'An error occurred while removing the global trace verdict.',
|
|
})
|
|
} finally {
|
|
removingTraceKeys.delete(trace.detail_key)
|
|
}
|
|
}
|
|
|
|
onMounted(loadTraces)
|
|
</script>
|