mirror of
https://github.com/modrinth/code.git
synced 2026-08-25 00:55:25 +00:00
fix: moderation/projects endpoint slow (#6639)
* fix: moderation/projects endpoint slow * fix: re-add tech rev toggle off by default * fix: i18n * prepare --------- Co-authored-by: aecsocket <43144841+aecsocket@users.noreply.github.com>
This commit is contained in:
@@ -2,12 +2,7 @@
|
|||||||
<div class="shadow-card rounded-2xl border border-solid border-surface-4 bg-surface-3 p-4">
|
<div class="shadow-card rounded-2xl border border-solid border-surface-4 bg-surface-3 p-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<NuxtLink
|
<NuxtLink :to="`/project/${projectRouteParam}`" target="_blank" tabindex="-1" class="flex">
|
||||||
:to="`/project/${queueEntry.project.slug}`"
|
|
||||||
target="_blank"
|
|
||||||
tabindex="-1"
|
|
||||||
class="flex"
|
|
||||||
>
|
|
||||||
<Avatar
|
<Avatar
|
||||||
:src="queueEntry.project.icon_url"
|
:src="queueEntry.project.icon_url"
|
||||||
size="4rem"
|
size="4rem"
|
||||||
@@ -17,7 +12,7 @@
|
|||||||
<div class="flex flex-col gap-1.5">
|
<div class="flex flex-col gap-1.5">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
:to="`/project/${queueEntry.project.slug}`"
|
:to="`/project/${projectRouteParam}`"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
class="text-lg font-semibold text-contrast hover:underline"
|
class="text-lg font-semibold text-contrast hover:underline"
|
||||||
>
|
>
|
||||||
@@ -177,7 +172,7 @@ function getDaysQueued(date: Date): number {
|
|||||||
const queuedDate = computed(() => {
|
const queuedDate = computed(() => {
|
||||||
return dayjs(
|
return dayjs(
|
||||||
props.queueEntry.project.queued ||
|
props.queueEntry.project.queued ||
|
||||||
props.queueEntry.project.created ||
|
props.queueEntry.project.published ||
|
||||||
props.queueEntry.project.updated,
|
props.queueEntry.project.updated,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -186,10 +181,14 @@ const daysInQueue = computed(() => {
|
|||||||
return getDaysQueued(queuedDate.value.toDate())
|
return getDaysQueued(queuedDate.value.toDate())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const projectRouteParam = computed(
|
||||||
|
() => props.queueEntry.project.slug || props.queueEntry.project.id,
|
||||||
|
)
|
||||||
|
|
||||||
const formattedDate = computed(() => {
|
const formattedDate = computed(() => {
|
||||||
const date =
|
const date =
|
||||||
props.queueEntry.project.queued ||
|
props.queueEntry.project.queued ||
|
||||||
props.queueEntry.project.created ||
|
props.queueEntry.project.published ||
|
||||||
props.queueEntry.project.updated
|
props.queueEntry.project.updated
|
||||||
if (!date) return 'Unknown'
|
if (!date) return 'Unknown'
|
||||||
|
|
||||||
@@ -202,7 +201,7 @@ const formattedDate = computed(() => {
|
|||||||
|
|
||||||
function copyLink() {
|
function copyLink() {
|
||||||
const base = window.location.origin
|
const base = window.location.origin
|
||||||
const projectUrl = `${base}/project/${props.queueEntry.project.slug}`
|
const projectUrl = `${base}/project/${projectRouteParam.value}`
|
||||||
navigator.clipboard.writeText(projectUrl).then(() => {
|
navigator.clipboard.writeText(projectUrl).then(() => {
|
||||||
addNotification({
|
addNotification({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
|
|||||||
@@ -1949,7 +1949,10 @@ function generateModpackMessage(allFiles: {
|
|||||||
|
|
||||||
const hasNextProject = ref(false)
|
const hasNextProject = ref(false)
|
||||||
async function refreshModerationCaches(threadId?: string) {
|
async function refreshModerationCaches(threadId?: string) {
|
||||||
const refreshes: Promise<unknown>[] = [invalidate(), refreshNuxtData('moderation-projects')]
|
const refreshes: Promise<unknown>[] = [
|
||||||
|
invalidate(),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['moderation-projects'] }),
|
||||||
|
]
|
||||||
|
|
||||||
if (threadId) {
|
if (threadId) {
|
||||||
refreshes.push(queryClient.invalidateQueries({ queryKey: ['thread', threadId] }))
|
refreshes.push(queryClient.invalidateQueries({ queryKey: ['thread', threadId] }))
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { Labrinth } from '@modrinth/api-client'
|
||||||
import type { ExtendedReport, OwnershipTarget } from '@modrinth/moderation'
|
import type { ExtendedReport, OwnershipTarget } from '@modrinth/moderation'
|
||||||
import type {
|
import type {
|
||||||
Organization,
|
Organization,
|
||||||
@@ -197,14 +198,10 @@ export interface ModerationOwnershipOrganization {
|
|||||||
|
|
||||||
export type ModerationOwnership = ModerationOwnershipUser | ModerationOwnershipOrganization
|
export type ModerationOwnership = ModerationOwnershipUser | ModerationOwnershipOrganization
|
||||||
|
|
||||||
export interface ProjectWithOwnership {
|
export type ProjectWithOwnership = Labrinth.Moderation.Internal.QueueProject
|
||||||
ownership: ModerationOwnership
|
|
||||||
external_dependencies_count: number
|
|
||||||
[key: string]: any
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ModerationProject {
|
export interface ModerationProject {
|
||||||
project: any
|
project: Omit<ProjectWithOwnership, 'ownership' | 'external_dependencies_count'>
|
||||||
ownership: ModerationOwnership | null
|
ownership: ModerationOwnership | null
|
||||||
external_dependencies_count: number
|
external_dependencies_count: number
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2672,6 +2672,9 @@
|
|||||||
"layout.nav.upgrade-to-modrinth-plus": {
|
"layout.nav.upgrade-to-modrinth-plus": {
|
||||||
"message": "Upgrade to Modrinth+"
|
"message": "Upgrade to Modrinth+"
|
||||||
},
|
},
|
||||||
|
"moderation.exclude-technical-review": {
|
||||||
|
"message": "Exclude TR"
|
||||||
|
},
|
||||||
"moderation.moderate": {
|
"moderation.moderate": {
|
||||||
"message": "Moderate"
|
"message": "Moderate"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
<Combobox
|
<Combobox
|
||||||
v-model="currentFilterType"
|
v-model="currentFilterType"
|
||||||
class="!w-full flex-grow sm:!w-[280px] sm:flex-grow-0 lg:!w-[280px]"
|
class="!w-full flex-grow sm:!w-[280px] sm:flex-grow-0 lg:!w-[280px]"
|
||||||
|
trigger-class="!h-10"
|
||||||
:options="filterTypes"
|
:options="filterTypes"
|
||||||
:placeholder="formatMessage(commonMessages.filterByLabel)"
|
:placeholder="formatMessage(commonMessages.filterByLabel)"
|
||||||
@select="goToPage(1)"
|
@select="goToPage(1)"
|
||||||
@@ -26,7 +27,7 @@
|
|||||||
<span class="flex flex-row gap-2 align-middle font-semibold">
|
<span class="flex flex-row gap-2 align-middle font-semibold">
|
||||||
<ListFilterIcon class="size-5 flex-shrink-0 text-secondary" />
|
<ListFilterIcon class="size-5 flex-shrink-0 text-secondary" />
|
||||||
<span class="truncate text-contrast"
|
<span class="truncate text-contrast"
|
||||||
>{{ currentFilterType }} ({{ filteredProjects.length }})</span
|
>{{ currentFilterType }} ({{ totalProjects }})</span
|
||||||
>
|
>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
@@ -35,6 +36,7 @@
|
|||||||
<Combobox
|
<Combobox
|
||||||
v-model="currentSortType"
|
v-model="currentSortType"
|
||||||
class="!w-full flex-grow sm:!w-[240px] sm:flex-grow-0"
|
class="!w-full flex-grow sm:!w-[240px] sm:flex-grow-0"
|
||||||
|
trigger-class="!h-10"
|
||||||
:options="sortTypes"
|
:options="sortTypes"
|
||||||
:placeholder="formatMessage(commonMessages.sortByLabel)"
|
:placeholder="formatMessage(commonMessages.sortByLabel)"
|
||||||
@select="goToPage(1)"
|
@select="goToPage(1)"
|
||||||
@@ -54,6 +56,7 @@
|
|||||||
<Combobox
|
<Combobox
|
||||||
v-model="itemsPerPage"
|
v-model="itemsPerPage"
|
||||||
class="!w-full flex-grow sm:!w-[160px] sm:flex-grow-0 lg:!w-[140px]"
|
class="!w-full flex-grow sm:!w-[160px] sm:flex-grow-0 lg:!w-[140px]"
|
||||||
|
trigger-class="!h-10"
|
||||||
:options="itemsPerPageOptions"
|
:options="itemsPerPageOptions"
|
||||||
placeholder="Items per page"
|
placeholder="Items per page"
|
||||||
@select="goToPage(1)"
|
@select="goToPage(1)"
|
||||||
@@ -69,7 +72,7 @@
|
|||||||
<ButtonStyled color="orange">
|
<ButtonStyled color="orange">
|
||||||
<button
|
<button
|
||||||
class="flex !h-[40px] w-full items-center justify-center gap-2 sm:w-auto"
|
class="flex !h-[40px] w-full items-center justify-center gap-2 sm:w-auto"
|
||||||
:disabled="paginatedProjects?.length === 0"
|
:disabled="pending || paginatedProjects?.length === 0"
|
||||||
@click="moderateAllInFilter()"
|
@click="moderateAllInFilter()"
|
||||||
>
|
>
|
||||||
<ScaleIcon class="flex-shrink-0" />
|
<ScaleIcon class="flex-shrink-0" />
|
||||||
@@ -80,17 +83,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="totalPages > 1" class="flex items-center justify-between">
|
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
Showing {{ itemsPerPage * (currentPage - 1) + 1 }}–{{
|
<div v-if="totalProjects > 0">
|
||||||
itemsPerPage * (currentPage - 1) + Math.min(itemsPerPage, paginatedProjects.length)
|
Showing {{ pageStart }}–{{ pageEnd }} of {{ totalProjects }}
|
||||||
}}
|
|
||||||
of {{ filteredProjects.length }}
|
|
||||||
{{
|
{{
|
||||||
currentFilterType === DEFAULT_FILTER_TYPE ? 'projects' : currentFilterType.toLowerCase()
|
currentFilterType === DEFAULT_FILTER_TYPE ? 'projects' : currentFilterType.toLowerCase()
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
|
<div class="flex items-center gap-2 text-sm font-semibold text-secondary">
|
||||||
|
<Toggle id="moderation-exclude-technical-review" v-model="excludeTechnicalReview" small />
|
||||||
|
<label class="cursor-pointer" for="moderation-exclude-technical-review">
|
||||||
|
{{ formatMessage(messages.excludeTechnicalReview) }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Pagination
|
||||||
|
v-if="totalPages > 1"
|
||||||
|
:page="currentPage"
|
||||||
|
:count="totalPages"
|
||||||
|
@switch-page="goToPage"
|
||||||
|
/>
|
||||||
<ConfettiExplosion v-if="visible" />
|
<ConfettiExplosion v-if="visible" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -124,6 +137,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { Labrinth } from '@modrinth/api-client'
|
||||||
import { ListFilterIcon, ScaleIcon, SearchIcon, SortAscIcon, SortDescIcon } from '@modrinth/assets'
|
import { ListFilterIcon, ScaleIcon, SearchIcon, SortAscIcon, SortDescIcon } from '@modrinth/assets'
|
||||||
import {
|
import {
|
||||||
ButtonStyled,
|
ButtonStyled,
|
||||||
@@ -132,20 +146,18 @@ import {
|
|||||||
commonMessages,
|
commonMessages,
|
||||||
defineMessages,
|
defineMessages,
|
||||||
EmptyState,
|
EmptyState,
|
||||||
|
injectModrinthClient,
|
||||||
injectNotificationManager,
|
injectNotificationManager,
|
||||||
Pagination,
|
Pagination,
|
||||||
StyledInput,
|
StyledInput,
|
||||||
|
Toggle,
|
||||||
useVIntl,
|
useVIntl,
|
||||||
} from '@modrinth/ui'
|
} from '@modrinth/ui'
|
||||||
import Fuse from 'fuse.js'
|
import { useQuery } from '@tanstack/vue-query'
|
||||||
import ConfettiExplosion from 'vue-confetti-explosion'
|
import ConfettiExplosion from 'vue-confetti-explosion'
|
||||||
|
|
||||||
import ModerationQueueCard from '~/components/ui/moderation/ModerationQueueCard.vue'
|
import ModerationQueueCard from '~/components/ui/moderation/ModerationQueueCard.vue'
|
||||||
import {
|
import { type ModerationProject, toModerationProjects } from '~/helpers/moderation.ts'
|
||||||
type ModerationProject,
|
|
||||||
type ProjectWithOwnership,
|
|
||||||
toModerationProjects,
|
|
||||||
} from '~/helpers/moderation.ts'
|
|
||||||
import { useModerationQueue } from '~/services/moderation-queue.ts'
|
import { useModerationQueue } from '~/services/moderation-queue.ts'
|
||||||
|
|
||||||
useHead({ title: 'Projects queue - Modrinth' })
|
useHead({ title: 'Projects queue - Modrinth' })
|
||||||
@@ -155,6 +167,7 @@ const { addNotification } = injectNotificationManager()
|
|||||||
const moderationQueue = useModerationQueue()
|
const moderationQueue = useModerationQueue()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const client = injectModrinthClient()
|
||||||
|
|
||||||
const visible = ref(false)
|
const visible = ref(false)
|
||||||
if (import.meta.client && history && history.state && history.state.confetti) {
|
if (import.meta.client && history && history.state && history.state.confetti) {
|
||||||
@@ -173,37 +186,14 @@ const messages = defineMessages({
|
|||||||
id: 'moderation.moderate',
|
id: 'moderation.moderate',
|
||||||
defaultMessage: 'Moderate',
|
defaultMessage: 'Moderate',
|
||||||
},
|
},
|
||||||
})
|
excludeTechnicalReview: {
|
||||||
|
id: 'moderation.exclude-technical-review',
|
||||||
const { data: allProjects, pending } = await useLazyAsyncData('moderation-projects', async () => {
|
defaultMessage: 'Exclude TR',
|
||||||
const startTime = performance.now()
|
},
|
||||||
let currentOffset = 0
|
|
||||||
const PROJECT_ENDPOINT_COUNT = 350
|
|
||||||
const allProjects: ModerationProject[] = []
|
|
||||||
|
|
||||||
let projects: ProjectWithOwnership[] = []
|
|
||||||
do {
|
|
||||||
projects = (await useBaseFetch(
|
|
||||||
`moderation/projects?count=${PROJECT_ENDPOINT_COUNT}&offset=${currentOffset}`,
|
|
||||||
{ internal: true },
|
|
||||||
)) as ProjectWithOwnership[]
|
|
||||||
|
|
||||||
if (projects.length === 0) break
|
|
||||||
|
|
||||||
allProjects.push(...toModerationProjects(projects))
|
|
||||||
currentOffset += projects.length
|
|
||||||
} while (projects.length === PROJECT_ENDPOINT_COUNT)
|
|
||||||
|
|
||||||
const duration = performance.now() - startTime
|
|
||||||
|
|
||||||
console.debug(
|
|
||||||
`Projects fetched and processed in ${duration.toFixed(2)}ms (${(duration / 1000).toFixed(2)}s)`,
|
|
||||||
)
|
|
||||||
|
|
||||||
return allProjects
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const query = ref(route.query.q?.toString() || '')
|
const query = ref(route.query.q?.toString() || '')
|
||||||
|
const excludeTechnicalReview = ref(false)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
query,
|
query,
|
||||||
@@ -379,116 +369,106 @@ const itemsPerPage = computed({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
const totalPages = computed(() =>
|
|
||||||
Math.ceil((filteredProjects.value?.length || 0) / itemsPerPage.value),
|
function toApiProjectType(label: string): string | undefined {
|
||||||
|
switch (label) {
|
||||||
|
case 'Modpacks':
|
||||||
|
return 'modpack'
|
||||||
|
case 'Mods':
|
||||||
|
return 'mod'
|
||||||
|
case 'Resource Packs':
|
||||||
|
return 'resourcepack'
|
||||||
|
case 'Data Packs':
|
||||||
|
return 'datapack'
|
||||||
|
case 'Plugins':
|
||||||
|
return 'plugin'
|
||||||
|
case 'Shaders':
|
||||||
|
return 'shader'
|
||||||
|
case 'Servers':
|
||||||
|
return 'minecraft_java_server'
|
||||||
|
case 'Fucked up':
|
||||||
|
return 'none'
|
||||||
|
default:
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toApiSort(label: string): Labrinth.Moderation.Internal.ProjectsSort {
|
||||||
|
switch (label) {
|
||||||
|
case 'Newest':
|
||||||
|
return 'newest'
|
||||||
|
case 'Most external deps':
|
||||||
|
return 'most_external_deps'
|
||||||
|
case 'Least external deps':
|
||||||
|
return 'least_external_deps'
|
||||||
|
default:
|
||||||
|
return 'oldest'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const moderationProjectsRequest = computed<Labrinth.Moderation.Internal.ProjectsRequest>(() => ({
|
||||||
|
count: itemsPerPage.value,
|
||||||
|
offset: (currentPage.value - 1) * itemsPerPage.value,
|
||||||
|
exclude_technical_review: excludeTechnicalReview.value,
|
||||||
|
query: query.value || undefined,
|
||||||
|
project_type: toApiProjectType(currentFilterType.value),
|
||||||
|
sort: toApiSort(currentSortType.value),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const moderationProjectsQueryKey = computed(
|
||||||
|
() => ['moderation-projects', moderationProjectsRequest.value] as const,
|
||||||
)
|
)
|
||||||
|
|
||||||
const fuse = computed(() => {
|
const {
|
||||||
if (!allProjects.value || allProjects.value.length === 0) return null
|
data: moderationProjectsResponse,
|
||||||
return new Fuse(allProjects.value, {
|
isPending: moderationProjectsPending,
|
||||||
keys: [
|
isPlaceholderData: moderationProjectsPlaceholder,
|
||||||
{
|
} = useQuery({
|
||||||
name: 'project.title',
|
queryKey: moderationProjectsQueryKey,
|
||||||
weight: 3,
|
queryFn: ({ queryKey }) => client.labrinth.moderation_internal.getProjects(queryKey[1]),
|
||||||
},
|
placeholderData: (previousData) => previousData,
|
||||||
{
|
|
||||||
name: 'project.slug',
|
|
||||||
weight: 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'project.description',
|
|
||||||
weight: 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'project.project_type',
|
|
||||||
weight: 1,
|
|
||||||
},
|
|
||||||
'ownership.name',
|
|
||||||
],
|
|
||||||
includeScore: true,
|
|
||||||
threshold: 0.4,
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const searchResults = computed(() => {
|
const pending = computed(
|
||||||
if (!query.value || !fuse.value) return null
|
() => moderationProjectsPending.value || moderationProjectsPlaceholder.value,
|
||||||
return fuse.value.search(query.value).map((result) => result.item)
|
|
||||||
})
|
|
||||||
|
|
||||||
const baseFiltered = computed(() => {
|
|
||||||
if (!allProjects.value) return []
|
|
||||||
return query.value && searchResults.value ? searchResults.value : [...allProjects.value]
|
|
||||||
})
|
|
||||||
|
|
||||||
const typeFiltered = computed(() => {
|
|
||||||
if (currentFilterType.value === 'All projects') {
|
|
||||||
return baseFiltered.value
|
|
||||||
} else if (currentFilterType.value === 'Fucked up') {
|
|
||||||
return baseFiltered.value.filter((queueItem) => queueItem.project.project_types.length === 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
const filterMap: Record<string, string> = {
|
|
||||||
Modpacks: 'modpack',
|
|
||||||
Mods: 'mod',
|
|
||||||
'Resource Packs': 'resourcepack',
|
|
||||||
'Data Packs': 'datapack',
|
|
||||||
Plugins: 'plugin',
|
|
||||||
Shaders: 'shader',
|
|
||||||
Servers: 'minecraft_java_server',
|
|
||||||
}
|
|
||||||
const projectType = filterMap[currentFilterType.value]
|
|
||||||
if (!projectType) return baseFiltered.value
|
|
||||||
|
|
||||||
return baseFiltered.value.filter(
|
|
||||||
(queueItem) =>
|
|
||||||
(queueItem.project.project_types.length > 0 &&
|
|
||||||
queueItem.project.project_types[0] === projectType) ||
|
|
||||||
(projectType === 'minecraft_java_server' &&
|
|
||||||
queueItem.project.project_types.includes('minecraft_java_server')),
|
|
||||||
)
|
)
|
||||||
})
|
const totalProjects = computed(() => moderationProjectsResponse.value?.total ?? 0)
|
||||||
|
const totalPages = computed(() => Math.ceil(totalProjects.value / itemsPerPage.value))
|
||||||
const filteredProjects = computed(() => {
|
const filteredProjects = computed(() =>
|
||||||
const filtered = [...typeFiltered.value]
|
toModerationProjects(moderationProjectsResponse.value?.projects ?? []),
|
||||||
|
)
|
||||||
if (currentSortType.value === 'Most external deps') {
|
const paginatedProjects = computed(() => filteredProjects.value)
|
||||||
filtered.sort((a, b) => {
|
const pageStart = computed(() =>
|
||||||
const depsDiff = b.external_dependencies_count - a.external_dependencies_count
|
totalProjects.value === 0 ? 0 : (currentPage.value - 1) * itemsPerPage.value + 1,
|
||||||
if (depsDiff !== 0) return depsDiff
|
)
|
||||||
const dateA = new Date(a.project.queued || a.project.published || 0).getTime()
|
const pageEnd = computed(() =>
|
||||||
const dateB = new Date(b.project.queued || b.project.published || 0).getTime()
|
Math.min(
|
||||||
return dateA - dateB
|
(currentPage.value - 1) * itemsPerPage.value + paginatedProjects.value.length,
|
||||||
})
|
totalProjects.value,
|
||||||
} else if (currentSortType.value === 'Least external deps') {
|
),
|
||||||
filtered.sort((a, b) => {
|
)
|
||||||
const depsDiff = a.external_dependencies_count - b.external_dependencies_count
|
const projectsById = computed(() => {
|
||||||
if (depsDiff !== 0) return depsDiff
|
const projects = new Map<string, ModerationProject>()
|
||||||
const dateA = new Date(a.project.queued || a.project.published || 0).getTime()
|
for (const project of filteredProjects.value) {
|
||||||
const dateB = new Date(b.project.queued || b.project.published || 0).getTime()
|
projects.set(project.project.id, project)
|
||||||
return dateA - dateB
|
|
||||||
})
|
|
||||||
} else if (currentSortType.value === 'Oldest') {
|
|
||||||
filtered.sort((a, b) => {
|
|
||||||
const dateA = new Date(a.project.queued || a.project.published || 0).getTime()
|
|
||||||
const dateB = new Date(b.project.queued || b.project.published || 0).getTime()
|
|
||||||
return dateA - dateB
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
filtered.sort((a, b) => {
|
|
||||||
const dateA = new Date(a.project.queued || a.project.published || 0).getTime()
|
|
||||||
const dateB = new Date(b.project.queued || b.project.published || 0).getTime()
|
|
||||||
return dateB - dateA
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return filtered
|
return projects
|
||||||
})
|
})
|
||||||
|
|
||||||
const paginatedProjects = computed(() => {
|
watch(totalPages, (pages) => {
|
||||||
if (!filteredProjects.value) return []
|
if (pages === 0 && currentPage.value !== 1) {
|
||||||
const start = (currentPage.value - 1) * itemsPerPage.value
|
currentPage.value = 1
|
||||||
const end = start + itemsPerPage.value
|
return
|
||||||
return filteredProjects.value.slice(start, end)
|
}
|
||||||
|
|
||||||
|
if (pages > 0 && currentPage.value > pages) {
|
||||||
|
currentPage.value = pages
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(excludeTechnicalReview, () => {
|
||||||
|
goToPage(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
const emptyStateHeading = computed(() => {
|
const emptyStateHeading = computed(() => {
|
||||||
@@ -525,21 +505,16 @@ function notifySkippedProjects(skippedCount: number) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findFirstEligibleProject(): Promise<ModerationProject | null> {
|
async function findFirstEligibleProject(): Promise<string | null> {
|
||||||
let skippedCount = 0
|
let skippedCount = 0
|
||||||
|
|
||||||
while (moderationQueue.hasItems) {
|
while (moderationQueue.hasItems) {
|
||||||
const currentId = moderationQueue.getCurrentProjectId()
|
const currentId = moderationQueue.getCurrentProjectId()
|
||||||
if (!currentId) return null
|
if (!currentId) return null
|
||||||
|
|
||||||
const project = filteredProjects.value.find((p) => p.project.id === currentId)
|
const project = projectsById.value.get(currentId)
|
||||||
if (!project) {
|
|
||||||
await moderationQueue.completeCurrentProject(currentId, 'skipped')
|
|
||||||
skippedCount++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (project.project.status !== 'processing') {
|
if (project && project.project.status !== 'processing') {
|
||||||
await moderationQueue.completeCurrentProject(currentId, 'skipped')
|
await moderationQueue.completeCurrentProject(currentId, 'skipped')
|
||||||
skippedCount++
|
skippedCount++
|
||||||
continue
|
continue
|
||||||
@@ -550,13 +525,13 @@ async function findFirstEligibleProject(): Promise<ModerationProject | null> {
|
|||||||
|
|
||||||
if (!lockStatus.locked || lockStatus.expired || lockStatus.is_own_lock) {
|
if (!lockStatus.locked || lockStatus.expired || lockStatus.is_own_lock) {
|
||||||
notifySkippedProjects(skippedCount)
|
notifySkippedProjects(skippedCount)
|
||||||
return project
|
return currentId
|
||||||
}
|
}
|
||||||
|
|
||||||
await moderationQueue.completeCurrentProject(currentId, 'skipped')
|
await moderationQueue.completeCurrentProject(currentId, 'skipped')
|
||||||
skippedCount++
|
skippedCount++
|
||||||
} catch {
|
} catch {
|
||||||
return project
|
return currentId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -565,17 +540,42 @@ async function findFirstEligibleProject(): Promise<ModerationProject | null> {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getProjectRouteParam(projectId: string): string {
|
||||||
|
return projectsById.value.get(projectId)?.project.slug || projectId
|
||||||
|
}
|
||||||
|
|
||||||
|
async function navigateToModerationProject(projectId: string) {
|
||||||
|
await navigateTo({
|
||||||
|
name: 'type-project',
|
||||||
|
params: {
|
||||||
|
type: 'project',
|
||||||
|
project: getProjectRouteParam(projectId),
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
showChecklist: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getFilteredProjectIds(): Promise<string[]> {
|
||||||
|
const response = await client.labrinth.moderation_internal.getProjectIds({
|
||||||
|
exclude_technical_review: excludeTechnicalReview.value,
|
||||||
|
query: query.value || undefined,
|
||||||
|
project_type: toApiProjectType(currentFilterType.value),
|
||||||
|
sort: toApiSort(currentSortType.value),
|
||||||
|
})
|
||||||
|
|
||||||
|
return response.ids
|
||||||
|
}
|
||||||
|
|
||||||
async function moderateAllInFilter() {
|
async function moderateAllInFilter() {
|
||||||
// Start from the current page - get projects from current page onwards
|
|
||||||
const startIndex = (currentPage.value - 1) * itemsPerPage.value
|
const startIndex = (currentPage.value - 1) * itemsPerPage.value
|
||||||
const projectsFromCurrentPage = filteredProjects.value.slice(startIndex)
|
const projectIds = (await getFilteredProjectIds()).slice(startIndex)
|
||||||
const projectIds = projectsFromCurrentPage.map((queueItem) => queueItem.project.id)
|
|
||||||
await moderationQueue.setQueue(projectIds)
|
await moderationQueue.setQueue(projectIds)
|
||||||
|
|
||||||
// Find first unlocked project
|
const targetProjectId = await findFirstEligibleProject()
|
||||||
const targetProject = await findFirstEligibleProject()
|
|
||||||
|
|
||||||
if (!targetProject) {
|
if (!targetProjectId) {
|
||||||
addNotification({
|
addNotification({
|
||||||
title: 'No projects available',
|
title: 'No projects available',
|
||||||
text: 'All projects in queue are already moderated or locked by others.',
|
text: 'All projects in queue are already moderated or locked by others.',
|
||||||
@@ -584,34 +584,22 @@ async function moderateAllInFilter() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
navigateTo({
|
await navigateToModerationProject(targetProjectId)
|
||||||
name: 'type-project',
|
|
||||||
params: {
|
|
||||||
type: 'project',
|
|
||||||
project: targetProject.project.slug,
|
|
||||||
},
|
|
||||||
state: {
|
|
||||||
showChecklist: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startFromProject(projectId: string) {
|
async function startFromProject(projectId: string) {
|
||||||
// Find the index of the clicked project in the filtered list
|
const allFilteredProjectIds = await getFilteredProjectIds()
|
||||||
const projectIndex = filteredProjects.value.findIndex((p) => p.project.id === projectId)
|
const projectIndex = allFilteredProjectIds.indexOf(projectId)
|
||||||
if (projectIndex === -1) {
|
if (projectIndex === -1) {
|
||||||
// Project not found in filtered list, just moderate it alone
|
|
||||||
await moderationQueue.setSingleProject(projectId)
|
await moderationQueue.setSingleProject(projectId)
|
||||||
} else {
|
} else {
|
||||||
// Start queue from this project onwards
|
const projectIds = allFilteredProjectIds.slice(projectIndex)
|
||||||
const projectsFromHere = filteredProjects.value.slice(projectIndex)
|
|
||||||
const projectIds = projectsFromHere.map((queueItem) => queueItem.project.id)
|
|
||||||
await moderationQueue.setQueue(projectIds)
|
await moderationQueue.setQueue(projectIds)
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetProject = await findFirstEligibleProject()
|
const targetProjectId = await findFirstEligibleProject()
|
||||||
|
|
||||||
if (!targetProject) {
|
if (!targetProjectId) {
|
||||||
addNotification({
|
addNotification({
|
||||||
title: 'No projects available',
|
title: 'No projects available',
|
||||||
text: 'All projects in queue are already moderated or locked by others.',
|
text: 'All projects in queue are already moderated or locked by others.',
|
||||||
@@ -620,15 +608,6 @@ async function startFromProject(projectId: string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
navigateTo({
|
await navigateToModerationProject(targetProjectId)
|
||||||
name: 'type-project',
|
|
||||||
params: {
|
|
||||||
type: 'project',
|
|
||||||
project: targetProject.project.slug,
|
|
||||||
},
|
|
||||||
state: {
|
|
||||||
showChecklist: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Generated
+22
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n select id\n from mods\n where id = any($1)\n and status not in ('rejected', 'draft', 'withheld', 'withdrawn')\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Int8"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Int8Array"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "079846a1e6a6b080e0e7f2e3efbb53b6526332433faf66de8716bc5cd2b12afd"
|
||||||
|
}
|
||||||
Generated
-31
@@ -1,31 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "\n SELECT\n id,\n external_dependencies_count as \"external_dependencies_count!\"\n FROM (\n SELECT DISTINCT ON (m.id)\n m.id,\n m.queued,\n (\n SELECT COUNT(*)\n FROM versions v\n INNER JOIN dependencies d ON d.dependent_id = v.id\n WHERE v.mod_id = m.id\n AND d.dependency_file_name IS NOT NULL\n ) external_dependencies_count\n FROM mods m\n\n /* -- Temporarily, don't exclude projects in tech rev q\n\n -- exclude projects in tech review queue\n LEFT JOIN delphi_issue_details_with_statuses didws\n ON didws.project_id = m.id AND didws.status = 'pending'\n */\n\n WHERE\n m.status = $1\n /* AND didws.status IS NULL */ -- Temporarily don't exclude\n\n GROUP BY m.id\n ) t\n WHERE\n ($4::boolean IS NULL OR (external_dependencies_count > 0) = $4)\n ORDER BY queued ASC\n OFFSET $3\n LIMIT $2\n ",
|
|
||||||
"describe": {
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"ordinal": 0,
|
|
||||||
"name": "id",
|
|
||||||
"type_info": "Int8"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 1,
|
|
||||||
"name": "external_dependencies_count!",
|
|
||||||
"type_info": "Int8"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Text",
|
|
||||||
"Int8",
|
|
||||||
"Int8",
|
|
||||||
"Bool"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": [
|
|
||||||
false,
|
|
||||||
null
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hash": "119a59fcf4bb2f19f89002c712a67c75d30056143c0bcabdbd74bb4c7b442082"
|
|
||||||
}
|
|
||||||
Generated
+24
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n SELECT id\n FROM mods\n WHERE\n status = $1\n AND (\n $3::boolean = false\n OR NOT EXISTS (\n SELECT 1\n FROM delphi_issue_details_with_statuses didws\n WHERE didws.project_id = mods.id\n AND didws.status = 'pending'\n )\n )\n ORDER BY\n CASE WHEN $2 = 'newest' THEN COALESCE(queued, published) END DESC NULLS LAST,\n CASE WHEN $2 = 'oldest' THEN COALESCE(queued, published) END ASC NULLS LAST,\n id ASC\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Int8"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text",
|
||||||
|
"Text",
|
||||||
|
"Bool"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "1bff1c5714dd039814d7a9d9f25f4ceca0e84a42b3a4c414210739fec6308e2d"
|
||||||
|
}
|
||||||
Generated
+28
File diff suppressed because one or more lines are too long
Generated
+135
File diff suppressed because one or more lines are too long
Generated
+138
File diff suppressed because one or more lines are too long
@@ -101,6 +101,7 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
|||||||
pats::edit_pat,
|
pats::edit_pat,
|
||||||
pats::delete_pat,
|
pats::delete_pat,
|
||||||
moderation::get_projects,
|
moderation::get_projects,
|
||||||
|
moderation::get_project_ids,
|
||||||
moderation::get_project_meta,
|
moderation::get_project_meta,
|
||||||
moderation::set_project_meta,
|
moderation::set_project_meta,
|
||||||
moderation::acquire_lock,
|
moderation::acquire_lock,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,3 @@
|
|||||||
pub(crate) mod moderation;
|
|
||||||
mod notifications;
|
mod notifications;
|
||||||
mod openapi;
|
mod openapi;
|
||||||
pub(crate) mod project_creation;
|
pub(crate) mod project_creation;
|
||||||
@@ -26,7 +25,6 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
|||||||
.configure(super::internal::flows::config)
|
.configure(super::internal::flows::config)
|
||||||
.configure(super::internal::pats::config)
|
.configure(super::internal::pats::config)
|
||||||
.configure(super::internal::admin::config)
|
.configure(super::internal::admin::config)
|
||||||
.configure(moderation::config)
|
|
||||||
.configure(notifications::config)
|
.configure(notifications::config)
|
||||||
.configure(project_creation::config)
|
.configure(project_creation::config)
|
||||||
.configure(projects::config)
|
.configure(projects::config)
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
use super::ApiError;
|
|
||||||
use crate::database::PgPool;
|
|
||||||
use crate::models::projects::Project;
|
|
||||||
use crate::models::v2::projects::LegacyProject;
|
|
||||||
use crate::queue::session::AuthQueue;
|
|
||||||
use crate::routes::internal;
|
|
||||||
use crate::{database::redis::RedisPool, routes::v2_reroute};
|
|
||||||
use actix_web::{HttpRequest, HttpResponse, get, web};
|
|
||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
|
||||||
cfg.service(web::scope("/moderation").service(get_projects));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct ResultCount {
|
|
||||||
#[serde(default = "default_count")]
|
|
||||||
pub count: u16,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_count() -> u16 {
|
|
||||||
100
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List projects in the moderation queue.
|
|
||||||
#[utoipa::path(
|
|
||||||
context_path = "/moderation",
|
|
||||||
tag = "v2 moderation",
|
|
||||||
get,
|
|
||||||
operation_id = "getModerationProjects",
|
|
||||||
params(
|
|
||||||
("count" = Option<u16>, Query, description = "Maximum number of projects to return")
|
|
||||||
),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Expected response to a valid request", body = Vec<LegacyProject>),
|
|
||||||
(
|
|
||||||
status = 401,
|
|
||||||
description = "Incorrect token scopes or no authorization to access the requested item(s)"
|
|
||||||
),
|
|
||||||
(
|
|
||||||
status = 404,
|
|
||||||
description = "The requested item(s) were not found or no authorization to access the requested item(s)"
|
|
||||||
)
|
|
||||||
),
|
|
||||||
security(("bearer_auth" = ["PROJECT_READ"]))
|
|
||||||
)]
|
|
||||||
#[get("/projects")]
|
|
||||||
pub async fn get_projects(
|
|
||||||
req: HttpRequest,
|
|
||||||
pool: web::Data<PgPool>,
|
|
||||||
redis: web::Data<RedisPool>,
|
|
||||||
count: web::Query<ResultCount>,
|
|
||||||
session_queue: web::Data<AuthQueue>,
|
|
||||||
) -> Result<HttpResponse, ApiError> {
|
|
||||||
let response = internal::moderation::get_projects_internal(
|
|
||||||
req,
|
|
||||||
pool.clone(),
|
|
||||||
redis.clone(),
|
|
||||||
web::Query(internal::moderation::ProjectsRequestOptions {
|
|
||||||
count: count.count,
|
|
||||||
offset: 0,
|
|
||||||
has_external_dependencies: None,
|
|
||||||
}),
|
|
||||||
session_queue,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map(|resp| HttpResponse::Ok().json(resp))
|
|
||||||
.or_else(v2_reroute::flatten_404_error)?;
|
|
||||||
|
|
||||||
// Convert to V2 projects
|
|
||||||
match v2_reroute::extract_ok_json::<Vec<Project>>(response).await {
|
|
||||||
Ok(project) => {
|
|
||||||
let legacy_projects =
|
|
||||||
LegacyProject::from_many(project, &**pool, &redis).await?;
|
|
||||||
Ok(HttpResponse::Ok().json(legacy_projects))
|
|
||||||
}
|
|
||||||
Err(response) => Ok(response),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,6 +6,34 @@ export class LabrinthModerationInternalModule extends AbstractModule {
|
|||||||
return 'labrinth_moderation_internal'
|
return 'labrinth_moderation_internal'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getProjects(
|
||||||
|
params: Labrinth.Moderation.Internal.ProjectsRequest = {},
|
||||||
|
): Promise<Labrinth.Moderation.Internal.ProjectsResponse> {
|
||||||
|
return this.client.request<Labrinth.Moderation.Internal.ProjectsResponse>(
|
||||||
|
'/moderation/projects',
|
||||||
|
{
|
||||||
|
api: 'labrinth',
|
||||||
|
version: 'internal',
|
||||||
|
method: 'GET',
|
||||||
|
params,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getProjectIds(
|
||||||
|
params: Omit<Labrinth.Moderation.Internal.ProjectsRequest, 'count' | 'offset'> = {},
|
||||||
|
): Promise<Labrinth.Moderation.Internal.ProjectIdsResponse> {
|
||||||
|
return this.client.request<Labrinth.Moderation.Internal.ProjectIdsResponse>(
|
||||||
|
'/moderation/projects/ids',
|
||||||
|
{
|
||||||
|
api: 'labrinth',
|
||||||
|
version: 'internal',
|
||||||
|
method: 'GET',
|
||||||
|
params,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
public async acquireLock(
|
public async acquireLock(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
): Promise<Labrinth.Moderation.Internal.LockAcquireResponse> {
|
): Promise<Labrinth.Moderation.Internal.LockAcquireResponse> {
|
||||||
|
|||||||
@@ -1914,6 +1914,57 @@ export namespace Labrinth {
|
|||||||
|
|
||||||
export namespace Moderation {
|
export namespace Moderation {
|
||||||
export namespace Internal {
|
export namespace Internal {
|
||||||
|
export type Ownership =
|
||||||
|
| {
|
||||||
|
kind: 'user'
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
icon_url: string | null
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'organization'
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
icon_url: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProjectsSort = 'oldest' | 'newest' | 'most_external_deps' | 'least_external_deps'
|
||||||
|
|
||||||
|
export type ProjectsRequest = {
|
||||||
|
count?: number
|
||||||
|
offset?: number
|
||||||
|
has_external_dependencies?: boolean
|
||||||
|
exclude_technical_review?: boolean
|
||||||
|
query?: string
|
||||||
|
project_type?: string
|
||||||
|
sort?: ProjectsSort
|
||||||
|
}
|
||||||
|
|
||||||
|
export type QueueProject = {
|
||||||
|
id: string
|
||||||
|
slug: string | null
|
||||||
|
name: string
|
||||||
|
summary: string
|
||||||
|
icon_url: string | null
|
||||||
|
status: Projects.v2.ProjectStatus
|
||||||
|
requested_status: Projects.v2.ProjectStatus | null
|
||||||
|
queued: string | null
|
||||||
|
published: string
|
||||||
|
updated: string
|
||||||
|
project_types: string[]
|
||||||
|
ownership: Ownership
|
||||||
|
external_dependencies_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProjectsResponse = {
|
||||||
|
total: number
|
||||||
|
projects: QueueProject[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProjectIdsResponse = {
|
||||||
|
ids: string[]
|
||||||
|
}
|
||||||
|
|
||||||
export type LockedByUser = {
|
export type LockedByUser = {
|
||||||
id: string
|
id: string
|
||||||
username: string
|
username: string
|
||||||
|
|||||||
Reference in New Issue
Block a user