mirror of
https://github.com/modrinth/code.git
synced 2026-08-30 11:36:05 +00:00
Merge remote-tracking branch 'origin/main' into prospector/disclosures-frontend
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -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
|
||||
}
|
||||
+5
-5
@@ -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
|
||||
+62
-36
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user