Merge remote-tracking branch 'origin/main' into prospector/disclosures-frontend

This commit is contained in:
Prospector
2026-08-07 18:12:40 -07:00
77 changed files with 3592 additions and 3498 deletions
@@ -106,7 +106,7 @@ interface Tags {
interface Props {
project: Labrinth.Projects.v2.Project
projectV3: Labrinth.Projects.v3.Project
versions?: Labrinth.Versions.v2.Version[]
versions?: Labrinth.Versions.v3.Version[]
currentMember?: Labrinth.Projects.v3.TeamMember | null
collapsed?: boolean
routeName?: string
@@ -0,0 +1,118 @@
<script setup lang="ts">
import { ScaleIcon, XIcon } from '@modrinth/assets'
import { AutoLink, Button, injectModrinthClient, NewModal } from '@modrinth/ui'
import { ref, useTemplateRef } from 'vue'
import { useGeneratedState } from '~/composables/generated'
import { getProjectTypeForUrlShorthand } from '~/helpers/projects.js'
const props = defineProps<{
completedIds: string[]
skippedIds: string[]
}>()
const emit = defineEmits<{
(e: 'review-skipped'): void
}>()
const client = injectModrinthClient()
const tags = useGeneratedState()
const modalRef = useTemplateRef<InstanceType<typeof NewModal>>('modalRef')
interface QueueSummaryEntry {
id: string
title: string
link: string
}
const completedEntries = ref<QueueSummaryEntry[]>([])
const skippedEntries = ref<QueueSummaryEntry[]>([])
function toEntries(
ids: string[],
projectsById: Map<string, { id: string; slug: string; title: string; project_types: string[] }>,
): QueueSummaryEntry[] {
return ids
.map((id) => projectsById.get(id))
.filter((project): project is NonNullable<typeof project> => !!project)
.map((project) => ({
id: project.id,
title: project.title,
link: `/${getProjectTypeForUrlShorthand(project.project_types[0], [], tags.value)}/${project.slug}`,
}))
}
async function show() {
const ids = [...new Set([...props.completedIds, ...props.skippedIds])]
const projects =
ids.length > 0 ? await client.labrinth.projects_v3.getMultiple(ids).catch(() => []) : []
const projectsById = new Map(projects.map((project) => [project.id, project]))
completedEntries.value = toEntries(props.completedIds, projectsById)
skippedEntries.value = toEntries(props.skippedIds, projectsById)
modalRef.value?.show()
}
function hide() {
modalRef.value?.hide()
}
function reviewSkipped() {
emit('review-skipped')
hide()
}
defineExpose({ show, hide })
</script>
<template>
<NewModal ref="modalRef" header="Queue completed">
<div class="flex flex-col gap-4">
<div v-if="completedEntries.length > 0" class="flex flex-col gap-2">
<span class="font-bold text-contrast">Completed ({{ completedEntries.length }})</span>
<ul class="m-0 flex list-none flex-col gap-1 p-0">
<li v-for="entry in completedEntries" :key="entry.id">
<AutoLink :to="entry.link" class="text-primary hover:underline">{{
entry.title
}}</AutoLink>
</li>
</ul>
</div>
<div v-if="skippedEntries.length > 0" class="flex flex-col gap-2">
<span class="font-bold text-contrast">Skipped ({{ skippedEntries.length }})</span>
<ul class="m-0 flex list-none flex-col gap-1 p-0">
<li v-for="entry in skippedEntries" :key="entry.id">
<AutoLink :to="entry.link" class="text-primary hover:underline">{{
entry.title
}}</AutoLink>
</li>
</ul>
</div>
<div
v-if="completedEntries.length === 0 && skippedEntries.length === 0"
class="text-secondary"
>
No projects were reviewed during this queue.
</div>
<div class="flex justify-end gap-2">
<Button
v-if="skippedEntries.length > 0"
type="colored"
color="orange"
@click="reviewSkipped"
>
<ScaleIcon />
Review skipped ({{ skippedEntries.length }})
</Button>
<Button @click="hide">
<XIcon />
Close
</Button>
</div>
</div>
</NewModal>
</template>
File diff suppressed because it is too large Load Diff
@@ -1,826 +0,0 @@
<script lang="ts" setup>
import type {
BooleanNodeBuilder,
ButtonNodeBuilder,
ChildNode,
DropdownNodeBuilder,
GroupNodeBuilder,
IdentifiedNodeBuilder,
InputNodeBuilder,
LabeledNodeBuilder,
NodeState,
NodeStateWithChildren,
OverrideValue,
ValueNodeBuilder,
} from '@modrinth/moderation'
import {
evalSegment,
expandVariables,
flattenProjectV3Variables,
flattenProjectVariables,
flattenStaticVariables,
getBooleanChildState,
NodeBuilder,
resolve,
resolveChildren,
setMessageProject,
} from '@modrinth/moderation'
import {
Button,
Checkbox,
Combobox,
injectProjectPageContext,
MarkdownEditor,
StyledInput,
} from '@modrinth/ui'
import { renderHighlightedString, renderString } from '@modrinth/utils'
import { inject, nextTick, onMounted, reactive, watchEffect } from 'vue'
import { NODE_META_KEY, STATE_KEY } from './checklist-context'
const nodeMetaMap = inject(NODE_META_KEY)
const injectedGlobalState = inject(STATE_KEY)
const { projectV3: project, projectV2 } = injectProjectPageContext()
setMessageProject(project, projectV2)
const props = defineProps<{
nodes: ChildNode[]
showContext: Record<string, NodeState>
onImageUpload?: (file: File) => Promise<string>
flex?: boolean
titleDepth?: number
parentStatePath?: string[]
}>()
function titleClass(depth: number): string {
if (depth === 0) return 'text-lg font-extrabold text-contrast'
if (depth === 1) return 'text-base font-semibold'
if (depth === 2) return 'text-sm font-semibold'
return ''
}
function isVisible(node: NodeBuilder): boolean {
if (node._shown !== undefined) return resolve(node._shown)
if (node.type === 'group') {
const children = getChildren(node as IdentifiedNodeBuilder)
return children.some((c) => !(c instanceof NodeBuilder) || isVisible(c))
}
return true
}
function isEnabled(node: IdentifiedNodeBuilder): boolean {
const e = node._enabled
if (e === undefined) return true
if (typeof e === 'function') return e(props.showContext)
return resolve(e)
}
function isButtonEnabled(node: ButtonNodeBuilder): boolean {
const enabled = node._enabled
if (enabled === undefined) return true
if (typeof enabled === 'function') return enabled(props.showContext)
return resolve(enabled)
}
function asBool(node: NodeBuilder): BooleanNodeBuilder {
return node as BooleanNodeBuilder
}
function asButton(node: NodeBuilder): ButtonNodeBuilder {
return node as ButtonNodeBuilder
}
function asIdentified(node: NodeBuilder): IdentifiedNodeBuilder {
return node as IdentifiedNodeBuilder
}
function asLabeled(node: NodeBuilder): LabeledNodeBuilder {
return node as LabeledNodeBuilder
}
function asGroup(node: NodeBuilder): GroupNodeBuilder {
return node as GroupNodeBuilder
}
function asDropdown(node: NodeBuilder): DropdownNodeBuilder {
return node as DropdownNodeBuilder
}
function asInput(node: NodeBuilder): InputNodeBuilder {
return node as InputNodeBuilder
}
function getAtPath(path: string[]): NodeState {
let current: unknown = injectedGlobalState!.value
for (const key of path) {
if (current == null || typeof current !== 'object' || current instanceof Set) return undefined
current = (current as Record<string, unknown>)[key]
}
return current as NodeState
}
function setAtPath(path: string[], value: NodeState): void {
if (path.length === 0) return
const global = injectedGlobalState!.value as unknown as Record<string, unknown>
let current = global
const stack: [Record<string, unknown>, string][] = []
for (let i = 0; i < path.length - 1; i++) {
const key = path[i]
const next = current[key]
if (!next || typeof next !== 'object' || next instanceof Set) {
if (value === undefined) return
current[key] = next !== null && next !== undefined ? { value: next } : {}
current = current[key] as Record<string, unknown>
} else {
stack.push([current, key])
current = next as Record<string, unknown>
}
}
const lastKey = path[path.length - 1]
if (value === undefined) {
Reflect.deleteProperty(current, lastKey)
for (let i = stack.length - 1; i >= 0; i--) {
const [parent, key] = stack[i]
const child = parent[key]
if (
child &&
typeof child === 'object' &&
!(child instanceof Set) &&
Object.keys(child as object).length === 0
) {
Reflect.deleteProperty(parent, key)
} else {
break
}
}
} else {
current[lastKey] = value as unknown
}
}
function getNodeState(node: IdentifiedNodeBuilder): NodeState {
return node._statePath ? getAtPath(node._statePath) : undefined
}
function setNodeState(node: IdentifiedNodeBuilder, value: NodeState): void {
if (node._statePath) setAtPath(node._statePath, value)
}
function getBooleanState(node: BooleanNodeBuilder): boolean {
const state = getNodeState(node)
if (typeof state === 'boolean') return state
if (state && typeof state === 'object' && !(state instanceof Set)) {
const v = (state as NodeStateWithChildren).value
if (typeof v === 'boolean') return v
}
const def = resolveDefault(node)
return (def as boolean | undefined) ?? false
}
function getMultiSelectState(node: IdentifiedNodeBuilder): Set<string> {
const state = getNodeState(node)
return state instanceof Set ? state : new Set<string>()
}
function getSelectState(node: IdentifiedNodeBuilder): string | undefined {
const state = getNodeState(node)
if (typeof state === 'string') return state
const def = resolveDefault(node as ValueNodeBuilder)
return typeof def === 'string' ? def : undefined
}
function getDropdownOptions(node: DropdownNodeBuilder) {
return [
...(node._none !== undefined ? [{ value: '', label: node._none }] : []),
...visibleChildren(node).map((c) => ({
value: asIdentified(c).id!,
label: asLabeled(c).label,
})),
]
}
function getDropdownModelValue(node: DropdownNodeBuilder) {
return getSelectState(node) ?? (node._none !== undefined ? '' : undefined)
}
function toggleSelect(parent: IdentifiedNodeBuilder, child: IdentifiedNodeBuilder) {
const current = getSelectState(parent)
setNodeState(parent, current === child.id ? undefined : child.id)
}
function resolveDefault(node: ValueNodeBuilder): NodeState {
const d = node._defaultValue
return typeof d === 'function' ? d(props.showContext) : d
}
function getTextState(node: IdentifiedNodeBuilder): string {
const state = getNodeState(node)
if (typeof state === 'string') return state
const def = resolveDefault(node as ValueNodeBuilder)
return typeof def === 'string' ? def : ''
}
function getNodeTitle(node: NodeBuilder): string | undefined {
if (node._title === undefined) return undefined
return resolve(node._title) || undefined
}
function getPlaceholder(node: InputNodeBuilder): string | undefined {
if (node._placeholder !== undefined) return resolve(node._placeholder)
const def = resolveDefault(node)
if (typeof def === 'string') return def
return undefined
}
function hasActionableFixes(node: IdentifiedNodeBuilder): boolean {
return nodeMetaMap?.value.get(node)?.isFixActionable ?? false
}
function hasRequiredMissingDescendants(node: IdentifiedNodeBuilder): boolean {
for (const child of getChildren(node)) {
if (!(child instanceof NodeBuilder)) continue
const identified = child as IdentifiedNodeBuilder
if (nodeMetaMap?.value.get(identified)?.hasRequiredMissing) return true
if (identified.id !== undefined && hasRequiredMissingDescendants(identified)) return true
}
return false
}
function nodeHasRequiredMissing(node: IdentifiedNodeBuilder): boolean {
return !!nodeMetaMap?.value.get(node)?.hasRequiredMissing || hasRequiredMissingDescendants(node)
}
function getBooleanColor(node: BooleanNodeBuilder): 'standard' | 'orange' | 'blue' | 'brand' {
if (!getBooleanState(node)) return 'standard'
if (hasRequiredMissingDescendants(node)) return 'orange'
return hasActionableFixes(node) ? 'blue' : 'brand'
}
function setTextState(node: IdentifiedNodeBuilder, v: string): void {
const inputNode = node as InputNodeBuilder
const result = inputNode._onChange?.(v, overrideHelpers)
if (isOverrideValue(result)) {
const ov = result.__override
const def = resolveDefault(node as ValueNodeBuilder)
const defStr = typeof def === 'string' ? def : ''
setNodeState(node, ov === defStr ? undefined : ov || undefined)
nextTick(() => textInputRefs.get(node)?.setValue(ov))
return
}
const def = resolveDefault(node as ValueNodeBuilder)
const defStr = typeof def === 'string' ? def : ''
setNodeState(node, v === defStr ? undefined : defStr ? v : v || undefined)
}
function handleButtonClick(node: ButtonNodeBuilder): void {
const before = new Map<NodeBuilder, string>()
for (const inputNode of textInputRefs.keys()) {
before.set(inputNode, getTextState(asIdentified(inputNode)))
}
node._onClick?.(props.showContext)
for (const [inputNode, beforeVal] of before) {
const after = getTextState(asIdentified(inputNode))
if (after !== beforeVal) setTextState(asIdentified(inputNode), after)
}
}
onMounted(() => {
for (const inputNode of textInputRefs.keys()) {
setTextState(asIdentified(inputNode), getTextState(asIdentified(inputNode)))
}
})
function toggleBoolean(node: BooleanNodeBuilder) {
const raw = getNodeState(node)
const next = !getBooleanState(node)
const defaultVal = (resolveDefault(node) as boolean | undefined) ?? false
const isDefault = next === defaultVal
if (raw && typeof raw === 'object' && !(raw instanceof Set)) {
const { value: _v, ...children } = raw as NodeStateWithChildren & Record<string, NodeState>
const hasChildren = Object.keys(children).length > 0
setNodeState(
node,
isDefault && !hasChildren
? undefined
: ({ ...children, ...(isDefault ? {} : { value: next }) } as NodeState),
)
} else {
setNodeState(node, isDefault ? undefined : next)
}
}
function toggleChip(parent: IdentifiedNodeBuilder, child: IdentifiedNodeBuilder) {
const selected = new Set(getMultiSelectState(parent))
if (selected.has(child.id!)) {
selected.delete(child.id!)
if (child._statePath) {
setAtPath(child._statePath, undefined)
}
} else {
selected.add(child.id!)
}
setNodeState(parent, selected.size > 0 ? selected : undefined)
}
const textInputRefs = new Map<NodeBuilder, { setValue: (v: string) => void }>()
const overrideHelpers = { override: (v: string): OverrideValue => ({ __override: v }) }
function isOverrideValue(v: unknown): v is OverrideValue {
return v !== null && typeof v === 'object' && '__override' in v
}
const scopedContextFallbacks = new WeakMap<IdentifiedNodeBuilder, Record<string, NodeState>>()
function childScopedContext(child: IdentifiedNodeBuilder): Record<string, NodeState> {
if (!child._statePath) return props.showContext
const basePath = child._statePath
const state = getAtPath(basePath)
if (state && typeof state === 'object' && !(state instanceof Set)) {
return state as Record<string, NodeState>
}
const existing = scopedContextFallbacks.get(child)
if (existing) return existing
const fallback = new Proxy({} as Record<string, NodeState>, {
set(_target, key, value) {
setAtPath([...basePath, key as string], value as NodeState)
return true
},
})
scopedContextFallbacks.set(child, fallback)
return fallback
}
function getChildrenContext(node: IdentifiedNodeBuilder): Record<string, NodeState> {
if (node.type === 'dropdown') return props.showContext
if (node.type === 'group' && asGroup(node)._selectMode) return props.showContext
return childScopedContext(node)
}
function getChildren(node: IdentifiedNodeBuilder): ChildNode[] {
return resolveChildren(node, getChildrenContext(node))
}
function visibleChildren(node: IdentifiedNodeBuilder): NodeBuilder[] {
return getChildren(node).filter((c): c is NodeBuilder => c instanceof NodeBuilder && isVisible(c))
}
const tooltipHtml = reactive(new Map<NodeBuilder, string>())
function getTooltipConfig(node: NodeBuilder, state?: Record<string, NodeState>) {
const t = node._tooltip
const manual = t === undefined ? undefined : typeof t === 'function' ? t(state ?? {}) : resolve(t)
if (manual)
return {
content: manual,
delay: { show: 500, hide: 0 },
triggers: ['hover', 'focus'],
placement: 'top',
}
const html = tooltipHtml.get(node)
if (!html) return undefined
return {
content: html,
html: true,
delay: { show: 500, hide: 0 },
triggers: ['hover', 'focus'],
placement: 'top',
}
}
watchEffect(async () => {
// Read all reactive state synchronously before any await so Vue tracks dependencies
const buttonTasks: Array<{ node: BooleanNodeBuilder; state: Record<string, NodeState> }> = []
for (const node of props.nodes) {
if (!(node instanceof NodeBuilder)) continue
if (node.type === 'toggle' && isVisible(node)) {
const boolNode = asBool(node)
if (boolNode._segments.some((s) => s.type !== 'collect')) {
const nodeState = getNodeState(boolNode)
const childState =
nodeState && typeof nodeState === 'object' && !(nodeState instanceof Set)
? (() => {
const { value: _v, ...rest } = nodeState as NodeStateWithChildren &
Record<string, NodeState>
return rest
})()
: {}
buttonTasks.push({ node: boolNode, state: childState })
}
}
if (node.type === 'group' && asGroup(node)._selectMode === 'multi' && isVisible(node)) {
for (const child of visibleChildren(asIdentified(node))) {
const opt = child as IdentifiedNodeBuilder
if (opt._segments.some((s) => s.type !== 'collect')) {
const childState = getBooleanChildState(getNodeState(opt)) as Record<string, NodeState>
buttonTasks.push({ node: opt as BooleanNodeBuilder, state: childState })
}
}
}
}
async function evalCollectedChildren(node: IdentifiedNodeBuilder): Promise<string> {
let result = ''
for (const child of getChildren(node)) {
if (!(child instanceof NodeBuilder)) continue
if (!isVisible(child)) continue
const childNode = asIdentified(child)
if (child.type === 'group') {
const grp = asGroup(childNode)
if (grp._selectMode === 'multi') {
const selected = getMultiSelectState(childNode)
for (const opt of getChildren(childNode)) {
if (!(opt instanceof NodeBuilder) || !isVisible(opt)) continue
const optNode = asIdentified(opt)
if (!optNode.id || !selected.has(optNode.id)) continue
result += await evalNodeTooltip(
optNode,
getBooleanChildState(getNodeState(optNode)) as Record<string, NodeState>,
)
}
} else if (grp._selectMode === 'single') {
const selected = getSelectState(childNode)
for (const opt of getChildren(childNode)) {
if (!(opt instanceof NodeBuilder) || !isVisible(opt)) continue
const optNode = asIdentified(opt)
if (optNode.id !== selected) continue
result += await evalNodeTooltip(
optNode,
getBooleanChildState(getNodeState(optNode)) as Record<string, NodeState>,
)
}
} else {
result += await evalCollectedChildren(childNode)
}
} else if (child.type === 'dropdown') {
const selected = getSelectState(childNode)
for (const opt of getChildren(childNode)) {
if (!(opt instanceof NodeBuilder) || !isVisible(opt)) continue
const optNode = asIdentified(opt)
if (optNode.id !== selected) continue
result += await evalNodeTooltip(
optNode,
getBooleanChildState(getNodeState(optNode)) as Record<string, NodeState>,
)
}
} else if (child.type === 'toggle' || child.type === 'check') {
if (!getBooleanState(asBool(childNode))) continue
result += await evalNodeTooltip(
childNode,
getBooleanChildState(getNodeState(childNode)) as Record<string, NodeState>,
)
}
}
return result
}
async function evalNodeTooltip(
node: IdentifiedNodeBuilder,
state: Record<string, NodeState>,
): Promise<string> {
let result = ''
for (const seg of node._segments) {
if (seg.type === 'collect') {
let collected = await evalCollectedChildren(node)
if (!collected.trim() && seg.fallback) {
collected = await evalSegment(seg.fallback, state, node._statePath ?? [])
}
result += collected
} else {
result += await evalSegment(seg, state, node._statePath ?? [])
}
}
return result
}
for (const { node, state } of buttonTasks) {
try {
const raw = await evalNodeTooltip(node as unknown as IdentifiedNodeBuilder, state)
const expanded = expandVariables(raw, projectV2.value, project.value, {
...flattenStaticVariables(),
...flattenProjectVariables(projectV2.value),
...flattenProjectV3Variables(project.value),
})
const trimmed = expanded.trim()
tooltipHtml.set(
node,
trimmed
? `<div class="markdown-body moderation-tooltip-markdown">${renderHighlightedString(trimmed)}</div>`
: '',
)
} catch {
tooltipHtml.set(node, '')
}
}
})
</script>
<template>
<div :class="flex ? 'flex flex-wrap gap-2' : 'space-y-4'">
<template
v-for="(item, idx) in nodes"
:key="
item instanceof NodeBuilder
? item.type === 'button'
? `button-${asButton(item).label}`
: (asIdentified(item)._statePath?.join('/') ?? asIdentified(item).id ?? item.type)
: typeof item === 'string'
? `s-${item}`
: `display-${idx}`
"
>
<!-- Display items: plain strings or zero-arg render functions -->
<template v-if="!(item instanceof NodeBuilder)">
<template v-if="typeof item === 'string'">{{ item }}</template>
<component :is="item" v-else />
</template>
<template v-else-if="isVisible(item)">
<div :class="item.type !== 'group' && !getNodeTitle(item) ? 'contents' : undefined">
<div v-if="getNodeTitle(item)" class="mb-2" :class="titleClass(titleDepth ?? 0)">
<span
v-html="renderString(getNodeTitle(item)!).replace(/^<p>([\s\S]*)<\/p>\n?$/, '$1')"
/><span v-if="nodeHasRequiredMissing(asIdentified(item))" class="text-red">*</span>
</div>
<!-- group -->
<template v-if="item.type === 'group'">
<!-- multi-select (chips) mode -->
<template v-if="asGroup(item)._selectMode === 'multi'">
<div class="flex flex-wrap gap-2">
<template
v-for="child in visibleChildren(asIdentified(item))"
:key="asIdentified(child).id"
>
<Button
v-tooltip="getTooltipConfig(asIdentified(child))"
:type="
(getMultiSelectState(asIdentified(item)).has(asIdentified(child).id!)
? hasActionableFixes(asIdentified(child))
? 'blue'
: 'brand'
: 'standard') &&
(getMultiSelectState(asIdentified(item)).has(asIdentified(child).id!)
? hasActionableFixes(asIdentified(child))
? 'blue'
: 'brand'
: 'standard') !== 'standard'
? 'colored'
: 'base'
"
:color="
(getMultiSelectState(asIdentified(item)).has(asIdentified(child).id!)
? hasActionableFixes(asIdentified(child))
? 'blue'
: 'brand'
: 'standard') &&
(getMultiSelectState(asIdentified(item)).has(asIdentified(child).id!)
? hasActionableFixes(asIdentified(child))
? 'blue'
: 'brand'
: 'standard') !== 'standard'
? (getMultiSelectState(asIdentified(item)).has(asIdentified(child).id!)
? hasActionableFixes(asIdentified(child))
? 'blue'
: 'brand'
: 'standard') === 'medal-promo'
? 'medal_promotion'
: getMultiSelectState(asIdentified(item)).has(asIdentified(child).id!)
? hasActionableFixes(asIdentified(child))
? 'blue'
: 'brand'
: 'standard'
: undefined
"
:aria-label="asIdentified(child)._icon ? asLabeled(child).label : undefined"
@click="toggleChip(asIdentified(item), asIdentified(child))"
>
<component :is="asIdentified(child)._icon" v-if="asIdentified(child)._icon" />
<template v-else>{{ asLabeled(child).label }}</template>
</Button>
</template>
</div>
<template
v-for="child in visibleChildren(asIdentified(item))"
:key="`sub-${asIdentified(child).id}`"
>
<NodeRenderer
v-if="
getMultiSelectState(asIdentified(item)).has(asIdentified(child).id!) &&
getChildren(asIdentified(child)).length
"
:nodes="getChildren(asIdentified(child))"
:show-context="getChildrenContext(asIdentified(child))"
:on-image-upload="onImageUpload"
:title-depth="titleDepth"
:parent-state-path="asIdentified(child)._statePath ?? props.parentStatePath ?? []"
class="mt-2"
/>
</template>
</template>
<!-- single-select (button-style) mode -->
<template v-else-if="asGroup(item)._selectMode === 'single'">
<div class="flex flex-wrap gap-2">
<template
v-for="child in visibleChildren(asIdentified(item))"
:key="asIdentified(child).id"
>
<Button
:type="
(getSelectState(asIdentified(item)) === asIdentified(child).id
? 'brand'
: 'standard') &&
(getSelectState(asIdentified(item)) === asIdentified(child).id
? 'brand'
: 'standard') !== 'standard'
? 'colored'
: 'base'
"
:color="
(getSelectState(asIdentified(item)) === asIdentified(child).id
? 'brand'
: 'standard') &&
(getSelectState(asIdentified(item)) === asIdentified(child).id
? 'brand'
: 'standard') !== 'standard'
? (getSelectState(asIdentified(item)) === asIdentified(child).id
? 'brand'
: 'standard') === 'medal-promo'
? 'medal_promotion'
: getSelectState(asIdentified(item)) === asIdentified(child).id
? 'brand'
: 'standard'
: undefined
"
:aria-label="asIdentified(child)._icon ? asLabeled(child).label : undefined"
@click="toggleSelect(asIdentified(item), asIdentified(child))"
>
<component :is="asIdentified(child)._icon" v-if="asIdentified(child)._icon" />
<template v-else>{{ asLabeled(child).label }}</template>
</Button>
</template>
</div>
<template
v-for="child in visibleChildren(asIdentified(item))"
:key="`sub-${asIdentified(child).id}`"
>
<NodeRenderer
v-if="
getSelectState(asIdentified(item)) === asIdentified(child).id &&
getChildren(asIdentified(child)).length
"
:nodes="getChildren(asIdentified(child))"
:show-context="getChildrenContext(asIdentified(child))"
:on-image-upload="onImageUpload"
:title-depth="titleDepth"
:parent-state-path="asIdentified(child)._statePath ?? props.parentStatePath ?? []"
class="mt-2"
/>
</template>
</template>
<!-- plain container mode -->
<NodeRenderer
v-else
:nodes="getChildren(asIdentified(item))"
:show-context="getChildrenContext(asIdentified(item))"
:on-image-upload="onImageUpload"
:flex="asGroup(item)._layout !== 'column'"
:title-depth="item._title !== undefined ? (titleDepth ?? 0) + 1 : titleDepth"
:parent-state-path="asIdentified(item)._statePath ?? props.parentStatePath ?? []"
/>
</template>
<!-- dropdown -->
<template v-else-if="item.type === 'dropdown'">
<Combobox
class="!w-80"
:options="getDropdownOptions(asDropdown(item))"
:model-value="getDropdownModelValue(asDropdown(item))"
trigger-class="!bg-[var(--color-button-bg)] !rounded-[var(--radius-md)] !shadow-[var(--shadow-inset-sm),0_0_0_0_transparent]"
dropdown-class="!rounded-[var(--radius-md)] !bg-[var(--color-button-bg)] !border-0"
@update:model-value="(v) => setNodeState(asIdentified(item), v || undefined)"
/>
<template
v-for="child in visibleChildren(asIdentified(item))"
:key="`sub-${asIdentified(child).id}`"
>
<NodeRenderer
v-if="
getSelectState(asIdentified(item)) === asIdentified(child).id &&
getChildren(asIdentified(child)).length
"
:nodes="getChildren(asIdentified(child))"
:show-context="getChildrenContext(asIdentified(child))"
:on-image-upload="onImageUpload"
:title-depth="titleDepth"
:parent-state-path="asIdentified(child)._statePath ?? props.parentStatePath ?? []"
class="mt-2"
/>
</template>
</template>
<!-- button -->
<template v-else-if="item.type === 'button'">
<Button
v-tooltip="getTooltipConfig(item, showContext)"
:disabled="!isButtonEnabled(asButton(item))"
:aria-label="item._icon && !asButton(item).label ? asButton(item).label : undefined"
@click="handleButtonClick(asButton(item))"
>
<component :is="item._icon" v-if="item._icon" />
{{ asButton(item).label }}
</Button>
</template>
<!-- toggle -->
<template v-else-if="item.type === 'toggle'">
<Button
v-tooltip="getTooltipConfig(asBool(item))"
:type="getBooleanColor(asBool(item)) === 'standard' ? 'base' : 'colored'"
:color="
getBooleanColor(asBool(item)) === 'standard'
? undefined
: getBooleanColor(asBool(item))
"
:disabled="!isEnabled(asIdentified(item))"
:aria-label="item._icon ? asLabeled(item).label : undefined"
@click="toggleBoolean(asBool(item))"
>
<component :is="item._icon" v-if="item._icon" />
<template v-else>{{ asLabeled(item).label }}</template>
</Button>
</template>
<!-- check -->
<template v-else-if="item.type === 'check'">
<Checkbox
:model-value="getBooleanState(asBool(item))"
:label="asLabeled(item).label"
:disabled="!isEnabled(asIdentified(item))"
@update:model-value="isEnabled(asIdentified(item)) && toggleBoolean(asBool(item))"
/>
</template>
<!-- text -->
<template v-else-if="item.type === 'text'">
<StyledInput
:id="`node-${asIdentified(item).id}`"
:ref="(el: any) => (el ? textInputRefs.set(item, el) : textInputRefs.delete(item))"
v-tooltip="getTooltipConfig(item, showContext)"
:model-value="getTextState(asIdentified(item))"
:placeholder="getPlaceholder(asInput(item))"
autocomplete="off"
@update:model-value="(v: string) => setTextState(asIdentified(item), v)"
/>
</template>
<!-- markdown -->
<template v-else-if="item.type === 'markdown'">
<MarkdownEditor
:id="`node-${asIdentified(item).id}`"
:aria-label="asLabeled(item).label || undefined"
:model-value="getTextState(asIdentified(item))"
:placeholder="getPlaceholder(asInput(item))"
:max-height="300"
:disabled="false"
:heading-buttons="false"
:on-image-upload="onImageUpload"
@update:model-value="(v: string) => setTextState(asIdentified(item), v)"
/>
</template>
</div>
</template>
</template>
<!-- children of active boolean nodes, rendered after all siblings -->
<template
v-for="(item, idx) in nodes"
:key="
item instanceof NodeBuilder
? item.type === 'button'
? `children-button-${asButton(item).label}`
: `children-${asIdentified(item)._statePath?.join('/') ?? asIdentified(item).id ?? item.type}`
: `children-display-${idx}`
"
>
<NodeRenderer
v-if="
item instanceof NodeBuilder &&
isVisible(item) &&
(item.type === 'toggle' || item.type === 'check') &&
getBooleanState(asBool(item)) &&
getChildren(asIdentified(item)).length
"
:nodes="getChildren(asIdentified(item))"
:show-context="getChildrenContext(asIdentified(item))"
:on-image-upload="onImageUpload"
:title-depth="item._title !== undefined ? (titleDepth ?? 0) + 1 : titleDepth"
:parent-state-path="asIdentified(item)._statePath ?? props.parentStatePath ?? []"
class="w-full"
/>
</template>
</div>
</template>
@@ -1,11 +1,5 @@
import type { IdentifiedNodeBuilder, NodeState } from '@modrinth/moderation'
import type { ComputedRef, InjectionKey, Ref } from 'vue'
export interface ActiveAction {
node: IdentifiedNodeBuilder
state: Record<string, NodeState>
statePath: string[]
}
import type { ActiveAction, NodeState } from '@modrinth/moderation/src/types/node'
import type { InjectionKey, Ref } from 'vue'
export interface LiveNode {
isActive: boolean
@@ -17,7 +11,5 @@ export interface LiveNode {
activeActions: ActiveAction[]
}
export const NODE_META_KEY: InjectionKey<ComputedRef<Map<IdentifiedNodeBuilder, LiveNode>>> =
Symbol('nodeMeta')
export const STATE_KEY: InjectionKey<Ref<Record<string, Record<string, NodeState>>>> =
Symbol('checklistState')
+3 -15
View File
@@ -602,8 +602,8 @@ import { versionQueryOptions } from '~/composables/queries/version'
import { useServerInstallContent } from '~/composables/use-server-install-content'
import { userCollectProject, userFollowProject } from '~/composables/user.js'
import { injectCurrentProjectId } from '~/providers/current-project.ts'
import { loadChecklistState } from '~/services/moderation-checklist-storage.ts'
import { useModerationQueue } from '~/services/moderation-queue.ts'
import { loadChecklistState } from '~/services/moderation/checklist-storage.ts'
import { useModerationQueue } from '~/services/moderation/queue.ts'
import { getReportPath, reportProject } from '~/utils/report-helpers.ts'
definePageMeta({
@@ -1192,7 +1192,7 @@ const { data: thread } = useQuery({
const isSettings = computed(() => route.name.startsWith('type-project-settings'))
// Transform versionsV3 to be same shape as versionsV2 for compatibility in project pages
// Jank modpack loaders fix
const versionsRaw = computed(() => {
return (versionsV3.value ?? []).map((version) => {
const files = Array.isArray(version.files) ? version.files : []
@@ -2025,23 +2025,11 @@ function setModerationChecklistOpen(open) {
showModerationChecklist.value = open
}
function isProjectInActiveModerationQueue(projectId = project.value?.id) {
return (
!!projectId &&
moderationQueue.isQueueMode &&
moderationQueue.currentQueue.items.includes(projectId)
)
}
async function openModerationChecklistFromMenu() {
const projectId = project.value?.id
if (!projectId) return
await moderationQueue.ready
if (!isProjectInActiveModerationQueue(projectId)) {
await moderationQueue.setSingleProject(projectId)
}
setModerationChecklistOpen(true)
}
+54 -46
View File
@@ -108,6 +108,12 @@
@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">
@@ -160,8 +166,11 @@ import { useQuery } from '@tanstack/vue-query'
import ConfettiExplosion from 'vue-confetti-explosion'
import ModerationQueueCard from '~/components/ui/moderation/ModerationQueueCard.vue'
import QueueSummaryModal from '~/components/ui/moderation/QueueSummaryModal.vue'
import { type ModerationProject, toModerationProjects } from '~/helpers/moderation.ts'
import { useModerationQueue } from '~/services/moderation-queue.ts'
import { getProjectTypeForUrlShorthand } from '~/helpers/projects.js'
import { useModerationQueue } from '~/services/moderation/queue.ts'
import { findNextEligibleQueueProject } from '~/services/moderation/queue-eligibility.ts'
useHead({ title: 'Projects queue - Modrinth' })
@@ -172,6 +181,8 @@ const route = useRoute()
const router = useRouter()
const client = injectModrinthClient()
const queueSummaryModal = ref()
const visible = ref(false)
if (import.meta.client && history && history.state && history.state.confetti) {
setTimeout(async () => {
@@ -184,6 +195,14 @@ if (import.meta.client && history && history.state && history.state.confetti) {
}, 1000)
}
if (import.meta.client && history && history.state && history.state.queueSummary) {
setTimeout(async () => {
history.state.queueSummary = false
await nextTick()
queueSummaryModal.value?.show()
}, 1000)
}
const messages = defineMessages({
moderate: {
id: 'moderation.moderate',
@@ -498,60 +517,36 @@ function goToPage(page: number) {
currentPage.value = page
}
function notifySkippedProjects(skippedCount: number) {
if (skippedCount <= 0) return
addNotification({
title: 'Skipped projects',
text: `Skipped ${skippedCount} project(s) already moderated or locked by others.`,
type: 'info',
autoCloseMs: 2000,
})
}
async function findFirstEligibleProject(): Promise<string | null> {
let skippedCount = 0
const candidateIds = [...moderationQueue.currentQueue.items]
if (candidateIds.length === 0) return null
while (moderationQueue.hasItems) {
const currentId = moderationQueue.getCurrentProjectId()
if (!currentId) return null
const next = await findNextEligibleQueueProject(client, moderationQueue, candidateIds)
const project = projectsById.value.get(currentId)
if (project && project.project.status !== 'processing') {
await moderationQueue.completeCurrentProject(currentId, 'skipped')
skippedCount++
continue
}
try {
const lockStatus = await moderationQueue.checkLock(currentId)
if (!lockStatus.locked || lockStatus.expired || lockStatus.is_own_lock) {
notifySkippedProjects(skippedCount)
return currentId
}
await moderationQueue.completeCurrentProject(currentId, 'skipped')
skippedCount++
} catch {
return currentId
}
if (!next) {
await Promise.all(candidateIds.map((id) => moderationQueue.excludeProject(id)))
return null
}
notifySkippedProjects(skippedCount)
return null
await Promise.all(next.excluded.map((id) => moderationQueue.excludeProject(id)))
return next.project
}
function getProjectRouteParam(projectId: string): string {
return projectsById.value.get(projectId)?.project.slug || projectId
}
function getProjectRouteType(projectId: string): string {
const projectType = projectsById.value.get(projectId)?.project.project_types[0]
if (!projectType) return 'project'
return getProjectTypeForUrlShorthand(projectType, [])
}
async function navigateToModerationProject(projectId: string) {
await navigateTo({
name: 'type-project',
params: {
type: 'project',
type: getProjectRouteType(projectId),
project: getProjectRouteParam(projectId),
},
state: {
@@ -593,12 +588,8 @@ async function moderateAllInFilter() {
async function startFromProject(projectId: string) {
const allFilteredProjectIds = await getFilteredProjectIds()
const projectIndex = allFilteredProjectIds.indexOf(projectId)
if (projectIndex === -1) {
await moderationQueue.setSingleProject(projectId)
} else {
const projectIds = allFilteredProjectIds.slice(projectIndex)
await moderationQueue.setQueue(projectIds)
}
const projectIds = projectIndex === -1 ? [projectId] : allFilteredProjectIds.slice(projectIndex)
await moderationQueue.setQueue(projectIds)
const targetProjectId = await findFirstEligibleProject()
@@ -613,4 +604,21 @@ async function startFromProject(projectId: string) {
await navigateToModerationProject(targetProjectId)
}
async function reviewSkippedQueue() {
await moderationQueue.startSkippedReview()
const targetProjectId = await findFirstEligibleProject()
if (!targetProjectId) {
addNotification({
title: 'No projects available',
text: 'All previously skipped projects are already moderated or locked by others.',
type: 'warning',
})
return
}
await navigateToModerationProject(targetProjectId)
}
</script>
@@ -0,0 +1,38 @@
export interface SessionChecklistState {
visitedStages?: string[]
}
function sessionStorageKey(projectId: string): string {
return `moderation-checklist-session:${projectId}`
}
export function getSessionChecklistState(projectId: string): SessionChecklistState {
try {
const raw = sessionStorage.getItem(sessionStorageKey(projectId))
return raw ? JSON.parse(raw) : {}
} catch {
return {}
}
}
export function patchSessionChecklistState(
projectId: string,
patch: Partial<SessionChecklistState>,
): void {
try {
sessionStorage.setItem(
sessionStorageKey(projectId),
JSON.stringify({ ...getSessionChecklistState(projectId), ...patch }),
)
} catch {
// Shush
}
}
export function clearSessionChecklistState(projectId: string): void {
try {
sessionStorage.removeItem(sessionStorageKey(projectId))
} catch {
// Shush
}
}
@@ -1,6 +1,6 @@
import type { NodeState } from '@modrinth/moderation'
import type { NodeState } from '@modrinth/moderation/src/types/node'
import { dbDelete, dbGet, dbPut, dbScan } from './moderation-db.ts'
import { dbDelete, dbGet, dbPut, dbScan } from './db.ts'
export interface PersistedChecklistState {
savedAt: string
@@ -9,6 +9,7 @@ export interface PersistedChecklistState {
stage?: string
message?: string
state?: Record<string, Record<string, NodeState>>
activatedStages?: string[]
}
const STORE = 'checklist'
@@ -0,0 +1,91 @@
import type { AbstractModrinthClient } from '@modrinth/api-client'
import type { ModerationQueueService } from './queue.ts'
export interface QueueCandidateCheck {
locked: boolean
expired?: boolean
isOwnLock?: boolean
slug?: string
projectType?: string
status?: string
isProcessing: boolean
}
export interface EligibleQueueProject {
project: string
result: QueueCandidateCheck
excluded: string[]
}
const BATCH_SIZE = 5
export function isEligibleQueueCandidate(result: QueueCandidateCheck | undefined): boolean {
if (!result?.isProcessing) return false
return !result.locked || !!result.expired || !!result.isOwnLock
}
export async function batchCheckQueueCandidates(
client: AbstractModrinthClient,
moderationQueue: ModerationQueueService,
projectIds: string[],
): Promise<Map<string, QueueCandidateCheck>> {
const results = new Map<string, QueueCandidateCheck>()
const projects = await client.labrinth.projects_v3.getMultiple(projectIds).catch(() => [])
const projectsById = new Map(projects.map((project) => [project.id, project]))
const checks = await Promise.allSettled(
projectIds.map(async (id) => {
const lockResponse = await moderationQueue.checkLock(id)
const project = projectsById.get(id) ?? null
return {
id,
locked: lockResponse.locked,
expired: lockResponse.expired,
isOwnLock: lockResponse.is_own_lock,
slug: project?.slug,
projectType: project?.project_types[0],
status: project?.status,
isProcessing: project === null ? true : project.status === 'processing',
}
}),
)
checks.forEach((result, index) => {
if (result.status === 'fulfilled') {
results.set(result.value.id, result.value)
} else {
results.set(projectIds[index], { locked: false, isProcessing: true })
}
})
return results
}
export async function findNextEligibleQueueProject(
client: AbstractModrinthClient,
moderationQueue: ModerationQueueService,
candidateIds: string[],
): Promise<EligibleQueueProject | null> {
const excluded: string[] = []
let checkedCount = 0
while (checkedCount < candidateIds.length) {
const batch = candidateIds.slice(checkedCount, checkedCount + BATCH_SIZE)
checkedCount += batch.length
const results = await batchCheckQueueCandidates(client, moderationQueue, batch)
for (const id of batch) {
const result = results.get(id)
if (isEligibleQueueCandidate(result)) {
return { project: id, result: result!, excluded: [...excluded] }
}
excluded.push(id)
}
}
return null
}
@@ -1,13 +1,13 @@
import { dbDelete, dbGet, dbPut } from './moderation-db.ts'
import { dbDelete, dbGet, dbPut } from './db.ts'
export interface PersistedModerationQueueState {
version: 1
savedAt: string
currentQueue: {
items: string[]
skipped?: string[]
total: number
completed: number
skipped: number
completed: string[]
lastUpdated: string
}
isQueueMode: boolean
@@ -31,9 +31,9 @@ function isPersistedStateCandidate(value: unknown): value is PersistedModeration
const queue = candidate.currentQueue
if (!queue || typeof queue !== 'object') return false
if (!isStringArray(queue.items)) return false
if (queue.skipped !== undefined && !isStringArray(queue.skipped)) return false
if (typeof queue.total !== 'number' || Number.isNaN(queue.total)) return false
if (typeof queue.completed !== 'number' || Number.isNaN(queue.completed)) return false
if (typeof queue.skipped !== 'number' || Number.isNaN(queue.skipped)) return false
if (!isStringArray(queue.completed)) return false
if (typeof queue.lastUpdated !== 'string') return false
return true
@@ -6,13 +6,13 @@ import {
loadQueueState,
type PersistedModerationQueueState,
saveQueueState,
} from './moderation-queue-storage.ts'
} from './queue-storage.ts'
export interface ModerationQueue {
items: string[]
skipped: string[]
total: number
completed: number
skipped: number
completed: string[]
lastUpdated: Date
}
@@ -29,11 +29,14 @@ export interface ModerationQueueService {
queueLength: number
hasItems: boolean
hasSkipped: boolean
progress: number
setQueue(projectIds: string[]): Promise<void>
setSingleProject(projectId: string): Promise<void>
completeCurrentProject(projectId: string, status?: 'completed' | 'skipped'): Promise<boolean>
completeProject(projectId: string): Promise<boolean>
deferProject(projectId: string): Promise<boolean>
excludeProject(projectId: string): Promise<boolean>
startSkippedReview(): Promise<void>
getCurrentProjectId(): string | null
resetQueue(): Promise<void>
@@ -46,31 +49,31 @@ export interface ModerationQueueService {
const EMPTY_QUEUE: ModerationQueue = {
items: [],
skipped: [],
total: 0,
completed: 0,
skipped: 0,
completed: [],
lastUpdated: new Date(),
}
function createEmptyQueue(): ModerationQueue {
return { ...EMPTY_QUEUE, lastUpdated: new Date(), items: [] }
return { ...EMPTY_QUEUE, lastUpdated: new Date(), items: [], skipped: [], completed: [] }
}
function sanitizeQueue(raw: PersistedModerationQueueState['currentQueue']): ModerationQueue {
const lastUpdated = new Date(raw.lastUpdated)
const items = raw.items.filter((id): id is string => typeof id === 'string')
const completed = Number.isFinite(raw.completed) ? Math.max(Math.trunc(raw.completed), 0) : 0
const skipped = Number.isFinite(raw.skipped) ? Math.max(Math.trunc(raw.skipped), 0) : 0
const minimumTotal = items.length + completed + skipped
const skipped = (raw.skipped ?? []).filter((id): id is string => typeof id === 'string')
const completed = (raw.completed ?? []).filter((id): id is string => typeof id === 'string')
const minimumTotal = items.length + completed.length
const total = Number.isFinite(raw.total)
? Math.max(Math.trunc(raw.total), minimumTotal)
: minimumTotal
return {
items,
skipped,
total,
completed,
skipped,
lastUpdated: Number.isNaN(lastUpdated.getTime()) ? new Date() : lastUpdated,
}
}
@@ -84,9 +87,9 @@ function persistedPayload(
savedAt: new Date().toISOString(),
currentQueue: {
items: [...queue.items],
skipped: [...queue.skipped],
total: queue.total,
completed: queue.completed,
skipped: queue.skipped,
completed: [...queue.completed],
lastUpdated: queue.lastUpdated.toISOString(),
},
isQueueMode,
@@ -101,9 +104,10 @@ function createModerationQueueState(client: AbstractModrinthClient = injectModri
const queueLength = computed(() => currentQueue.value.items.length)
const hasItems = computed(() => currentQueue.value.items.length > 0)
const hasSkipped = computed(() => currentQueue.value.skipped.length > 0)
const progress = computed(() => {
if (currentQueue.value.total === 0) return 0
return (currentQueue.value.completed + currentQueue.value.skipped) / currentQueue.value.total
return (currentQueue.value.total - currentQueue.value.items.length) / currentQueue.value.total
})
let mutationChain = Promise.resolve()
@@ -148,42 +152,41 @@ function createModerationQueueState(client: AbstractModrinthClient = injectModri
return result
}
function setQueueState(items: string[], mode: boolean) {
isQueueMode.value = mode
function setQueueState(items: string[]) {
isQueueMode.value = true
currentQueue.value = {
items: [...items],
skipped: [],
total: items.length,
completed: 0,
skipped: 0,
completed: [],
lastUpdated: new Date(),
}
}
async function setQueue(projectIds: string[]): Promise<void> {
await withMutation(() => {
setQueueState(projectIds, true)
setQueueState(projectIds)
})
}
async function setSingleProject(projectId: string): Promise<void> {
await withMutation(() => {
setQueueState([projectId], false)
})
}
async function completeCurrentProject(
projectId: string,
status: 'completed' | 'skipped' = 'completed',
): Promise<boolean> {
async function completeProject(projectId: string): Promise<boolean> {
return withMutation(() => {
if (!currentQueue.value.items.includes(projectId)) {
return currentQueue.value.items.length > 0
}
if (status === 'completed') {
currentQueue.value.completed++
} else {
currentQueue.value.skipped++
currentQueue.value.completed = [...currentQueue.value.completed, projectId]
currentQueue.value.items = currentQueue.value.items.filter((id) => id !== projectId)
currentQueue.value.lastUpdated = new Date()
return currentQueue.value.items.length > 0
})
}
async function excludeProject(projectId: string): Promise<boolean> {
return withMutation(() => {
if (!currentQueue.value.items.includes(projectId)) {
return currentQueue.value.items.length > 0
}
currentQueue.value.items = currentQueue.value.items.filter((id) => id !== projectId)
@@ -193,6 +196,26 @@ function createModerationQueueState(client: AbstractModrinthClient = injectModri
})
}
async function deferProject(projectId: string): Promise<boolean> {
return withMutation(() => {
if (!currentQueue.value.items.includes(projectId)) {
return currentQueue.value.items.length > 0
}
currentQueue.value.items = currentQueue.value.items.filter((id) => id !== projectId)
currentQueue.value.skipped = [...currentQueue.value.skipped, projectId]
currentQueue.value.lastUpdated = new Date()
return currentQueue.value.items.length > 0
})
}
async function startSkippedReview(): Promise<void> {
await withMutation(() => {
setQueueState(currentQueue.value.skipped)
})
}
function getCurrentProjectId(): string | null {
return currentQueue.value.items[0] || null
}
@@ -294,11 +317,14 @@ function createModerationQueueState(client: AbstractModrinthClient = injectModri
queueLength,
hasItems,
hasSkipped,
progress,
setQueue,
setSingleProject,
completeCurrentProject,
completeProject,
deferProject,
excludeProject,
startSkippedReview,
getCurrentProjectId,
resetQueue,
+2 -2
View File
@@ -4,8 +4,8 @@ import type {
LockStatusResponse,
ModerationQueue,
ModerationQueueService,
} from '~/services/moderation-queue.ts'
import { useModerationQueue } from '~/services/moderation-queue.ts'
} from '~/services/moderation/queue.ts'
import { useModerationQueue } from '~/services/moderation/queue.ts'
export type {
LockAcquireResponse,