Moderation Changes Phase 2.-1 (#6833)

* better default state handling

* make next stage button work for going to next project

* fix skill issue

* actually fix initial values this time,
make single line inputs with varying lengths (text input and dropdown) use their widest possible length instead of whatever the hell they used to be

* loaders in progress

* loaders and versions test (probably dont use them as the messages suck and idrk what they should do quick fix/message wise)

* WIP the wip

* good enough for now slug taken improvements

* trim all those extra newlines

* viewedness

* batch prefetch queue + technically slightly faster checklist init

* show all valid alternative project types regardless of current project type

* Hide moderation permissions stage when it should be, and I'm sure this won't cause any issues

* Hide moderation permissions stage when it should be, and I'm sure this won't cause any issues

* gimme them versions

* better moderation permission stage shown check?

* betterer moderation permission stage shown check?

* bettererer moderation permission stage shown check? + also began deleting legacy checklist types

* fix certain stages being able to be considered viewed before being viewed

* more removals + i forgot to remove an import last commit

* changed nothing™️

* remove severities + bandaid post-approval message priorities + dependencies

* fix message preview tooltips

* next stage = next project when done

* insufficient description custom fix

* fix that thing coolbot told me was broken I forgot what it was oops

* oh there was a second bug there

* make alternate versions and incorrect project type required cuz they are

* re-navigate button also damn that's a lot of other stuff that pnpm fix... fixed?

* you cant just quick fix a slug into a taken one

* maybe lets not just try and set a slug to something entirely invalid?

* what if we just didn't drop ur queue :smart:

* more queue upgrades

* tweaks

* I love unbreaking things

* un-nuked renavigate button

* prepr

* prepr worked this time i think

* undo more mistakes

* i love intellij refactoring

* ok i think we're good
This commit is contained in:
chyz
2026-08-07 23:40:11 +00:00
committed by GitHub
parent e4cbec6b2c
commit 5072c1d298
77 changed files with 3596 additions and 3502 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
@@ -573,8 +573,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({
@@ -1106,7 +1106,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 : []
@@ -1893,23 +1893,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,
+2
View File
@@ -182,6 +182,7 @@ import _LogInIcon from './icons/log-in.svg?component'
import _LogOutIcon from './icons/log-out.svg?component'
import _MailIcon from './icons/mail.svg?component'
import _ManageIcon from './icons/manage.svg?component'
import _MapPinIcon from './icons/map-pin.svg?component'
import _MaximizeIcon from './icons/maximize.svg?component'
import _MemoryStickIcon from './icons/memory-stick.svg?component'
import _MessageIcon from './icons/message.svg?component'
@@ -614,6 +615,7 @@ export const LogInIcon = _LogInIcon
export const LogOutIcon = _LogOutIcon
export const MailIcon = _MailIcon
export const ManageIcon = _ManageIcon
export const MapPinIcon = _MapPinIcon
export const MaximizeIcon = _MaximizeIcon
export const MemoryStickIcon = _MemoryStickIcon
export const MessageIcon = _MessageIcon
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-map-pin-icon lucide-map-pin"><path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"/><circle cx="12" cy="10" r="3"/></svg>

After

Width:  |  Height:  |  Size: 380 B

+1
View File
@@ -1 +1,2 @@
src/locales/**
src/types/node/pipe.ts
+2 -1
View File
@@ -18,6 +18,7 @@
"devDependencies": {
"@formatjs/cli": "^6.2.12",
"@modrinth/tooling-config": "workspace:*",
"@modrinth/ui": "workspace:*"
"@modrinth/ui": "workspace:*",
"typescript": "^5.4.5"
}
}
+5 -12
View File
@@ -1,8 +1,6 @@
import type { Ref } from 'vue'
import { provide, ref } from 'vue'
import type { NodeState, StageFn, StageNodeBuilder } from '../types/node'
import { group, STAGES_KEY } from '../types/node'
import type { NodeState, StageNode } from '../types/node'
import useCategoriesStage from './stages/categories'
import useDescriptionStage from './stages/description'
import useGalleryStage from './stages/gallery'
@@ -13,7 +11,7 @@ import usePermissionsStage from './stages/permissions'
import usePostApprovalStage from './stages/post-approval'
import useReReviewStage from './stages/re-review'
import useReuploadsStage from './stages/reupload'
import useOtherRulesStage from './stages/other-rules'
import useRulesStage from './stages/rules'
import useStatusAlertsStage from './stages/status-alerts'
import useSummaryStage from './stages/summary'
import useTitleSlugStage from './stages/title-slug'
@@ -22,8 +20,8 @@ import useVersionsStage from './stages/versions'
export function useStages(
globalState: Ref<Record<string, Record<string, NodeState>>>,
): StageNodeBuilder[] {
const mainStages: StageNodeBuilder[] = [
): StageNode[] {
const mainStages: StageNode[] = [
usePostApprovalStage(),
useUndefinedProjectStage(),
useReReviewStage(),
@@ -38,12 +36,7 @@ export function useStages(
useVersionsStage(),
useReuploadsStage(),
usePermissionsStage(),
useOtherRulesStage(),
useRulesStage(),
]
provide(STAGES_KEY, ref(mainStages))
return [...mainStages, useStatusAlertsStage(mainStages, globalState)]
}
export const stages: ReadonlyArray<StageFn> = []
export default group()
+2 -2
View File
@@ -1,6 +1,7 @@
import type { KeybindListener } from '../types/keybinds'
import type { Labrinth } from '@modrinth/api-client'
import type { KeybindListener } from '../types/keybinds'
const copyProjectLink = async (
project: Labrinth.Projects.v2.Project,
permalink: boolean,
@@ -32,7 +33,6 @@ const keybinds: { [id: string]: KeybindListener } = {
keybind: 'ArrowRight',
description: 'Go to next stage',
scope: 'checklist',
enabled: (ctx) => !ctx.state.isDone,
action: (ctx) => ctx.actions.tryGoNext(),
},
'previous-stage': {
@@ -2,5 +2,3 @@
Per section 2.1 of %RULES%, your %PROJECT_DESCRIPTION_FLINK% should clearly inform the reader of the content, purpose, and appeal of your %PROJECT_TYPE%.</br>
Currently, it looks like there are some missing details.
%CUSTOM_ADVICE%
@@ -0,0 +1,4 @@
## Missing Dependencies
Per section 5.6 of %RULES%, it is important that relevant dependencies be listed in the dependencies section of your project.
Please ensure that all relevant dependencies are included in the Dependencies section of each version of your project.
@@ -0,0 +1 @@
This project should have the following game version(s) selected: %GAME_VERSIONS%.
@@ -0,0 +1,5 @@
## Game Version Metadata
Per section 5.1 of %RULES%, it is important that the metadata of your project is accurate, including which Minecraft versions are selected.
%CORRECT%
@@ -0,0 +1 @@
This project should have the following loader(s) selected: %LOADERS%.
@@ -0,0 +1,5 @@
## Loader Metadata
Per section 5.1 of %RULES%, it is important that the metadata of your project is accurate, including which loaders are selected.
%CORRECT%
+1 -2
View File
@@ -1,8 +1,7 @@
import { defineMessage, formatProjectTypeSentence, useVIntl } from '@modrinth/ui'
import type { Nag, NagContext } from '../../types/nags'
import { licenseRequiresSource, licensesRequiringSource, notSourceAsDistributed } from '../../utils'
import license from '../stages/license'
import { licenseRequiresSource, notSourceAsDistributed } from '../../utils'
export const commonLinkDomains = {
source: [
@@ -8,4 +8,5 @@ export const Priorities = new (class extends Priority {
rejected = this.before()
withheld = this.before()
note = this.after()
tempLast = this.after()
})()
@@ -36,7 +36,7 @@ export default function () {
),
group().children(
toggle('inaccurate', 'Inaccurate').suggestedStatus('flagged').severity('low').message(),
toggle('inaccurate', 'Inaccurate').suggestedStatus('flagged').message(),
toggle('optimization-misused', 'Optimization')
.shown(
@@ -47,7 +47,6 @@ export default function () {
),
)
.suggestedStatus('flagged')
.severity('low')
.rawMessage(optimizationMsg)
.fix(
fix().project((patch) => {
@@ -61,7 +60,6 @@ export default function () {
toggle('resolutions-misused', 'Resolutions')
.shown(computed(() => project.value.project_types.includes('resourcepack')))
.suggestedStatus('flagged')
.severity('low')
.rawMessage(resolutionsMsg)
.fix(
fix().project((patch) => {
@@ -15,24 +15,23 @@ export default function () {
'https://www.notion.so/2e15ee711bf080e4a41df61bbab49892#2e15ee711bf080508042e70089dd787e',
)
.icon(LibraryIcon)
.navigate('/')
.navigate()
.children(
group()
.title('Description Issues?')
.children(
toggle('insufficient', 'Insufficient')
.suggestedStatus('flagged')
.severity('medium')
.message('insufficient/header', (s) => ({ CUSTOM_ADVICE: s.custom?.explainer }))
.message('insufficient/header')
.children(
group()
.title('Why is this Description Insufficient?')
.multiSelect('reason')
.children(
toggle('custom', 'Custom').children(
markdown('explainer')
.title('How can the author improve their description?')
.required(),
.required()
.rawMessage((state) => `${state.value}\n\n`),
),
toggle('fork', 'Fork').message('piece/fork'),
toggle('unfinished', 'Unfinished').message('piece/unfinished'),
@@ -44,15 +43,13 @@ export default function () {
`insufficient/default/${project.value?.minecraft_java_server ? 'servers' : project.value?.project_types?.includes('modpack') ? 'packs' : 'projects'}`,
)
.rawMessage(async (state) => {
const reasons = state?.reason instanceof Set ? state.reason : new Set<string>()
return SHOW_SPOILER_ADVICE.some((reason) => reasons.has(reason))
return SHOW_SPOILER_ADVICE.some((reason) => state?.[reason] === true)
? await md('checklist/messages/description/insufficient/piece/spoiler-guide')(state)
: ''
}),
toggle('non-english', 'Non-english')
.suggestedStatus('flagged')
.severity('medium')
.message(() => `non-english${project.value.minecraft_java_server ? '-server' : ''}`)
.shown(
computed(() => {
@@ -63,25 +60,13 @@ export default function () {
}),
),
toggle('headers-as-body', 'Headers as body text')
.suggestedStatus('flagged')
.severity('low')
.message(),
toggle('headers-as-body', 'Headers as body text').suggestedStatus('flagged').message(),
toggle('image-only', 'Image-only')
.suggestedStatus('flagged')
.severity('medium')
.message(),
toggle('image-only', 'Image-only').suggestedStatus('flagged').message(),
toggle('non-standard-text', 'Non-standard text')
.suggestedStatus('flagged')
.severity('medium')
.message(),
toggle('non-standard-text', 'Non-standard text').suggestedStatus('flagged').message(),
toggle('clarity', 'Unclear / Misleading')
.suggestedStatus('rejected')
.severity('high')
.message(),
toggle('clarity', 'Unclear / Misleading').suggestedStatus('rejected').message(),
),
)
}
@@ -16,18 +16,14 @@ export default function () {
.navigate('/gallery')
.children(
group().children(
toggle('insufficient', 'Insufficient').suggestedStatus('flagged').severity('low').message(),
toggle('insufficient', 'Insufficient').suggestedStatus('flagged').message(),
toggle('not-relevant', 'Not relevant')
.shown(computed(() => project.value.gallery.length > 0))
.suggestedStatus('flagged')
.severity('low')
.message(),
toggle('showcase-clarity', 'Showcase Clarity')
.suggestedStatus('rejected')
.severity('high')
.message(),
toggle('showcase-clarity', 'Showcase Clarity').suggestedStatus('rejected').message(),
),
)
}
@@ -53,7 +53,6 @@ export default function () {
toggle('invalid-link', 'Invalid Link')
.shown(computed(() => !!project.value.license?.url))
.suggestedStatus('flagged')
.severity('medium')
.message()
.children(check('custom-license', 'Invalid Link: Custom License').message())
.collect(),
@@ -61,12 +60,11 @@ export default function () {
toggle('no-source', 'No Source')
.shown(needSource)
.suggestedStatus('rejected')
.severity('medium')
.rawMessage(async (state) => {
if (state.fork) return noSourceForkMsg(state)
return noSourceMsg(state)
})
.children(check('fork', 'No Source: Fork').severity('high')),
.children(check('fork', 'No Source: Fork')),
),
)
}
@@ -3,15 +3,15 @@ import { injectProjectPageContext } from '@modrinth/ui'
import type { Ref } from 'vue'
import { computed } from 'vue'
import { group, md, mdOptional, stage, toggle } from '../../types/node'
import type { ChildEntry, GroupNodeBuilder } from '../../types/node'
import { promptSourceRequired } from '../..'
import type { ChildEntry, GroupNode } from '../../types/node'
import { group, md, mdOptional, stage, toggle } from '../../types/node'
export default function () {
const { projectV3: project } = injectProjectPageContext()
const linkNames: Record<string, string> = {}
type LinkSectionBuilder = GroupNodeBuilder & {
type LinkSectionBuilder = GroupNode & {
children(...extras: ChildEntry[]): LinkSectionBuilder
label(badge: Ref<boolean>): LinkSectionBuilder
}
@@ -75,7 +75,6 @@ export default function () {
.navigate('/settings/links')
.shown(computed(() => Object.keys(project.value.link_urls).length > 0))
.suggestedStatus('flagged')
.severity('low')
.rawMessage(async (state) => {
const sections = Object.entries(state).filter(
([, s]) => s && typeof s === 'object' && !(s instanceof Set),
+107 -66
View File
@@ -3,7 +3,16 @@ import { DatabaseIcon } from '@modrinth/assets'
import { ENVIRONMENTS_COPY, injectProjectPageContext, injectTags } from '@modrinth/ui'
import { computed } from 'vue'
import { dropdown, fix, group, md, option, stage, toggle } from '../../types/node'
import {
appComponent as _appComponent,
dropdown,
fix,
group,
md,
option,
stage,
toggle,
} from '../../types/node'
import { requiresEnvironmentInfo } from '../../utils'
const loaderLabels: Record<string, string> = {
@@ -13,7 +22,7 @@ const loaderLabels: Record<string, string> = {
resourcepack: 'Resource Pack',
}
function formatLoaderLabel(id: string): string {
function _formatLoaderLabel(id: string): string {
return (
loaderLabels[id] ??
id
@@ -25,7 +34,11 @@ function formatLoaderLabel(id: string): string {
export default function () {
const { projectV3: project } = injectProjectPageContext()
const { loaders } = injectTags()
const { loaders: _loaders, gameVersions: _gameVersions } = injectTags()
const _currentGameVersions = computed(
() => (project.value.game_versions as string[] | undefined) ?? [],
)
return (
stage('metadata', 'Metadata')
@@ -64,7 +77,6 @@ export default function () {
toggle('environment', 'Environment')
.shown(computed(() => requiresEnvironmentInfo(project.value.project_types)))
.suggestedStatus('flagged')
.severity('low')
.rawMessage(async (state) => {
const correctEnvironment = state?.['correct-environment'] as string | undefined
@@ -94,7 +106,7 @@ export default function () {
.title('Correct Environment')
.children(
dropdown('correct-environment')
.children(
.options(
...(Object.keys(ENVIRONMENTS_COPY) as Labrinth.Projects.v3.Environment[])
.filter((id) => id !== 'unknown')
.map((id) => option(id, ENVIRONMENTS_COPY[id].title.defaultMessage ?? id)),
@@ -103,67 +115,96 @@ export default function () {
.none('Unknown'),
),
),
// TODO: chyz, fix pls (make into single set of buttons where current loaders start selected and non current start non selected
// toggle('loader', `Loader${project.value.loaders.length > 1 ? 's' : ''}`).children(
// group()
// .title('Loader Issues?')
// .action(
// action()
// .suggestedStatus('flagged')
// .severity('medium')
// .message(async (state) => {
// //TODO: chyz
// //TODO: coolbot this one is a bit of a doozy
// const header = await md('checklist/messages/metadata/loader/incorrect')(state)
// const selected = state.loaders
// if (selected instanceof Set && selected.size > 0) {
// const list = [...selected]
// .map((id) => `- ${formatLoaderLabel(id)}`)
// .join('\n')
// return `${header}\n${list}`
// }
// return header
// }),
// )
// .children(
// toggle('incorrect', 'Incorrect').children(
// group()
// .title('Incorrect Loaders')
// .multiSelect('loaders')
// .children(
// ...project.value.loaders.map((id) => option(id, formatLoaderLabel(id))),
// ),
// ),
// TODO: chyz, this should be the same interface as incorrect, as a corrections scheme, with selected loaders default on.
// toggle('missing', 'Missing').children(
// group()
// .title('Missing Loaders')
// .multiSelect('loaders')
// .children(
// ...(() => {
// //TODO: chyz maybe this can be done better
// // (plugin loaders and datapack are marked as valid for mods which makes this suck)
// const existingTypes = new Set(
// loaders.value
// .filter((l) => project.value.loaders.includes(l.name))
// .flatMap((l) => l.supported_project_types),
// )
// const referenceTypes =
// existingTypes.size > 0
// ? existingTypes
// : new Set(project.value.project_types)
// return loaders.value
// .filter(
// (loader) =>
// loader.supported_project_types.every((t) => referenceTypes.has(t)) &&
// !project.value.loaders.includes(loader.name),
// )
// .map((loader) => option(loader.name, formatLoaderLabel(loader.name)))
// })(),
// )/
// ),
// ),
// ),
toggle('dependencies', 'Dependencies').suggestedStatus('flagged').message(),
// toggle('loader', 'Loaders (WIP)')
// .suggestedStatus('flagged')
// .rawMessage(async (state) => {
// const selected =
// state.loaders instanceof Set ? state.loaders : new Set(project.value.loaders)
// const current = new Set(project.value.loaders)
// const isCorrected =
// selected.size !== current.size || [...selected].some((id) => !current.has(id))
//
// let correct = ''
// if (isCorrected) {
// const list = [...selected].map((id) => formatLoaderLabel(id)).join(', ')
// correct = await md('checklist/messages/metadata/loader/correction', () => ({
// LOADERS: list || 'none',
// }))(state)
// }
//
// return md('checklist/messages/metadata/loader/inaccurate', () => ({
// CORRECT: correct,
// }))(state)
// })
// .fix(
// fix().project((patch, state) => {
// const selected =
// state.loaders instanceof Set ? state.loaders : new Set(project.value.loaders)
// const next = [...selected]
// const current = project.value.loaders
// if (next.length === current.length && next.every((id) => current.includes(id)))
// return
// patch.loaders = next
// }),
// )
// .children(
// appComponent('loaders', 'loader-picker')
// .valueKind('set')
// .initial(() => new Set(project.value.loaders))
// .props((ctx) => ({
// loaders: loaders.value,
// toggleLoader: ctx.toggleSetValue,
// })),
// ),
//
// toggle('game-version', 'Game Versions (WIP)')
// .suggestedStatus('flagged')
// .rawMessage(async (state) => {
// const selected =
// state['game-versions'] instanceof Set
// ? state['game-versions']
// : new Set(currentGameVersions.value)
// const current = new Set(currentGameVersions.value)
// const isCorrected =
// selected.size !== current.size || [...selected].some((id) => !current.has(id))
//
// let correct = ''
// if (isCorrected) {
// const list = [...selected].join(', ')
// correct = await md('checklist/messages/metadata/game-version/correction', () => ({
// GAME_VERSIONS: list || 'none',
// }))(state)
// }
//
// return md('checklist/messages/metadata/game-version/inaccurate', () => ({
// CORRECT: correct,
// }))(state)
// })
// .fix(
// fix().project((patch, state) => {
// const selected =
// state['game-versions'] instanceof Set
// ? state['game-versions']
// : new Set(currentGameVersions.value)
// const next = [...selected]
// const current = currentGameVersions.value
// if (next.length === current.length && next.every((id) => current.includes(id)))
// return
// patch.game_versions = next
// }),
// )
// .children(
// appComponent('game-versions', 'game-version-picker')
// .valueKind('set')
// .initial(() => new Set(currentGameVersions.value))
// .props(() => ({
// gameVersions: gameVersions.value,
// noHeader: true,
// })),
// ),
),
)
)
@@ -1,11 +1,30 @@
import type { Labrinth } from '@modrinth/api-client'
import { SignatureIcon } from '@modrinth/assets'
import { injectProjectPageContext } from '@modrinth/ui'
import { injectModrinthClient, injectProjectPageContext } from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import { computed } from 'vue'
import { group, stage, toggle } from '../../types/node'
function isResolved(attributionGroup: Labrinth.Attribution.Internal.AttributionGroup): boolean {
const attribution = attributionGroup.attribution
if (!attribution) return false
if (attribution.kind === 'globally_allowed') return true
return attribution.moderation_status?.kind === 'approved'
}
export default function () {
const { projectV3: project } = injectProjectPageContext()
const { labrinth } = injectModrinthClient()
const { data: attributionData } = useQuery({
queryKey: ['project-attribution', project.value.id],
queryFn: () => labrinth.attribution_internal.listProjectAttribution(project.value.id),
})
const unresolvedCount = computed(
() => (attributionData.value ?? []).filter((g) => !isResolved(g)).length,
)
return stage('permissions', 'Modpack Permissions')
.hint("Does this project's external content have any issues?")
@@ -16,30 +35,24 @@ export default function () {
computed(
() =>
(project.value.project_types?.includes('modpack') ?? false) &&
!project.value.minecraft_server,
!project.value.minecraft_server &&
unresolvedCount.value > 0,
),
)
.sticky()
.children(
group().children(
toggle('invalid-permissions', 'Invalid permissions')
.suggestedStatus('rejected')
.severity('high')
.message(),
toggle('invalid-permissions', 'Invalid permissions').suggestedStatus('rejected').message(),
toggle('prohibited-external-content', 'Prohibited externals')
.suggestedStatus('rejected')
.severity('high')
.message(),
toggle('missing-permissions', 'Missing permissions')
.suggestedStatus('rejected')
.severity('high')
.message(),
toggle('missing-permissions', 'Missing permissions').suggestedStatus('rejected').message(),
toggle('non-commercial-external-content', 'Non-commercial externals')
.shown(computed(() => project.value.monetization_status === 'monetized'))
.suggestedStatus('rejected')
.severity('high')
.message(),
),
)
@@ -3,6 +3,7 @@ import { injectProjectPageContext } from '@modrinth/ui'
import { computed } from 'vue'
import { group, stage, text, toggle } from '../../types/node'
import { Priorities } from '../priorities.ts'
//TODO chyz
//TODO coolbot needs discussion
@@ -20,15 +21,15 @@ export default function () {
group().children(
toggle('issue-warning', 'Issue warning')
.suggestedStatus('approved')
.severity('low')
.message(),
.message()
.priority(Priorities.tempLast),
toggle('missed-deadline', 'Missed due date')
.suggestedStatus('flagged')
.severity('high')
.message(undefined, (state) => ({
.message((state) => ({
STATUS: state.status,
}))
.priority(Priorities.tempLast)
.children(
//TODO: chyz this shouldn't need to be provided by moderator
text('status').title('What status is the project being set to?').required(),
@@ -36,35 +37,31 @@ export default function () {
toggle('metadata-issue', 'Incorrect metadata')
.suggestedStatus('approved')
.severity('low')
.message()
.children(
toggle('dependencies', 'Missing Dependencies')
.severity('low')
.message(undefined, (state) => ({
DEPENDENCY_NAME: state['dependency-name'],
DEPENDENCY_LINK: state['dependency-link'],
.message((state) => ({
DEPENDENCY_NAME: state['name'],
DEPENDENCY_LINK: state['link'],
}))
.children(
text('dependency-name').title('Dependency name').required(),
text('dependency-link').title('Dependency link').required(),
text('name').title('Dependency name').required(),
text('link').title('Dependency link').required(),
),
toggle('mc-versions', 'Game versions')
.severity('low')
.message(undefined, (state) => ({
.message((state) => ({
SPECIFICS: state.specifics,
}))
.children(text('specifics').title('More details about the game versions issue?')),
toggle('loaders', 'Loaders')
.severity('low')
.message(undefined, (state) => ({
.message((state) => ({
SPECIFICS: state.specifics,
}))
.children(text('specifics').title('More details about the loaders issue?')),
toggle('license', 'Inconsistent Licensing').severity('low').message(),
toggle('license', 'Inconsistent Licensing').message(),
)
.collect(),
),
@@ -32,13 +32,9 @@ export default function () {
group().children(
toggle('ignored', 'Yes')
.suggestedStatus('flagged')
.severity('medium')
.message()
.children(
toggle('warning', 'Multiple times in a row')
.suggestedStatus('rejected')
.severity('high')
.message(),
toggle('warning', 'Multiple times in a row').suggestedStatus('rejected').message(),
)
.collect(),
),
@@ -20,14 +20,13 @@ export default function () {
'https://www.notion.so/2e15ee711bf080e4a41df61bbab49892#2e35ee711bf080d1a0a2cda3ff2ce997',
)
.icon(CopyrightIcon)
.navigate('/')
.navigate()
.children(
group().children(
toggle('reupload', 'Re-upload')
.shown(computed(() => !project.value.minecraft_server))
.suggestedStatus('rejected')
.severity('high')
.message(undefined, (state) => ({
.message((state) => ({
ORIGINAL_PROJECT: state['original-project'],
ORIGINAL_AUTHOR: state['original-author'],
}))
@@ -39,25 +38,19 @@ export default function () {
toggle('unclear-fork', 'Unclear Fork')
.shown(computed(() => !project.value.minecraft_server))
.suggestedStatus('rejected')
.severity('high')
.message(),
toggle('insufficient-fork', 'Insufficient Fork')
.shown(computed(() => !project.value.minecraft_server))
.suggestedStatus('rejected')
.severity('high')
.message(),
toggle('request-proof', 'Proof of permissions')
.suggestedStatus('rejected')
.severity('high')
.message(),
toggle('request-proof', 'Proof of permissions').suggestedStatus('rejected').message(),
toggle('identity-verification', 'Verify Identity')
.shown(computed(() => !project.value.minecraft_server))
.suggestedStatus('rejected')
.severity('high')
.message(undefined, (state) => ({
.message((state) => ({
PLATFORM: state.platform,
}))
.children(text('platform').title('Where else can the project be found?').required()),
@@ -65,8 +58,7 @@ export default function () {
toggle('identity-verification-server', 'Verify Identity')
.shown(computed(() => !!project.value.minecraft_server))
.suggestedStatus('rejected')
.severity('high')
.message(undefined, (state) => ({
.message((state) => ({
CONTACT: state.contact,
}))
.children(text('contact').title('Known public contact method').required()),
@@ -74,17 +66,15 @@ export default function () {
toggle('request-proof-server', 'Reuploaded pack')
.shown(isServerModpack)
.suggestedStatus('rejected')
.severity('high')
.message(),
toggle('custom-pack-verification', 'Override verification')
.shown(isServerModpack)
.suggestedStatus('rejected')
.severity('high')
.message()
.children(
check('list', 'List overrides?')
.message(undefined, (state) => ({
.message((state) => ({
OVERRIDES: state.overrides,
}))
.children(markdown('overrides').title('Add list of overrides.')),
@@ -94,8 +84,7 @@ export default function () {
toggle('custom-pack-prohibited', 'Forbidden Overrides')
.shown(isServerModpack)
.suggestedStatus('rejected')
.severity('high')
.message(undefined, (state) => ({
.message((state) => ({
OVERRIDES: state.overrides,
}))
.children(markdown('overrides').title('Forbidden overrides list').required()),
@@ -2,9 +2,8 @@ import { ListBulletedIcon } from '@modrinth/assets'
import { injectProjectPageContext } from '@modrinth/ui'
import { computed } from 'vue'
import { group, markdown, option, stage, toggle } from '../../types/node'
import { group, markdown, stage, toggle } from '../../types/node'
//TODO: coolbot have fun :3
export default function () {
const { projectV3: project } = injectProjectPageContext()
@@ -20,61 +19,49 @@ export default function () {
toggle('paid-access-server', 'Paid access server')
.shown(computed(() => !!project.value.minecraft_server))
.suggestedStatus('rejected')
.severity('critical')
.message(),
// TODO: chyz, the lists built by these message have empty line gaps.
toggle('prohibited-content', 'Prohibited Content')
.suggestedStatus('rejected')
.severity('critical')
.message('prohibited-content-header')
.collect()
.children(
group()
.multiSelect('options')
.title('Which Prohibited Content rules does this project violate?')
.children(
option('objectionable', 'Objectionable').message(),
option('discriminatory', 'Discriminatory or Explicit').message(),
option('ip-infringement', 'IP Infringement').message(),
option('legal-rights', 'Rights Violation').message(),
option('illegal-activity', 'Illegal Activity').message(),
option('harmful', 'Harmful or Deceptive').message(),
option('misleading', 'Misleading claims').message(),
option('impersonation', 'Impersonation').message(),
option('false-endorsement', 'False Endorsement').message(),
option('profanity', 'Profanity').message(),
option('undisclosed-upload', 'Undisclosed Data Upload').message(),
option('mojang-bypass', 'Mojang Bypass').message(),
toggle('objectionable', 'Objectionable').message(),
toggle('discriminatory', 'Discriminatory or Explicit').message(),
toggle('ip-infringement', 'IP Infringement').message(),
toggle('legal-rights', 'Rights Violation').message(),
toggle('illegal-activity', 'Illegal Activity').message(),
toggle('harmful', 'Harmful or Deceptive').message(),
toggle('misleading', 'Misleading claims').message(),
toggle('impersonation', 'Impersonation').message(),
toggle('false-endorsement', 'False Endorsement').message(),
toggle('profanity', 'Profanity').message(),
toggle('undisclosed-upload', 'Undisclosed Data Upload').message(),
toggle('mojang-bypass', 'Mojang Bypass').message(),
),
),
toggle('cheat-or-hack-advertising', 'Hacks')
.suggestedStatus('rejected')
.severity('critical')
.message(),
toggle('cheat-or-hack-advertising', 'Hacks').suggestedStatus('rejected').message(),
toggle('server-side-opt-out', 'Opt-out')
.suggestedStatus('flagged')
.severity('high')
.message(),
toggle('server-side-opt-out', 'Opt-out').suggestedStatus('flagged').message(),
toggle('server-side-opt-in', 'Opt-in')
.suggestedStatus('flagged')
.severity('high')
.message('server-side-opt-in-header')
.collect()
.children(
group()
.multiSelect('options')
.title('Which features require a Server-side Opt-in?')
.children(
option('x-ray', 'X-ray').message(),
option('aim-bot', 'Aim Assist').message(),
option('movement', 'Movement').message(),
option('pvp', 'PvP').message(),
option('hiding-mods', 'Anti 3.x').message(),
option('item-duplication', 'Dupe').message(),
toggle('x-ray', 'X-ray').message(),
toggle('aim-bot', 'Aim Assist').message(),
toggle('movement', 'Movement').message(),
toggle('pvp', 'PvP').message(),
toggle('hiding-mods', 'Anti 3.x').message(),
toggle('item-duplication', 'Dupe').message(),
),
),
@@ -88,14 +75,11 @@ export default function () {
),
)
.suggestedStatus('flagged')
.severity('low')
.message(),
toggle('rule-breaking-other', 'Other')
// TODO: chyz, the required asterisk is on a separate line
.suggestedStatus('rejected')
.severity('critical')
.message(undefined, (state) => ({ MESSAGE: state.message }))
.message((state) => ({ MESSAGE: state.message }))
.children(
markdown('message').title('Explain how it infringes on content rules.').required(),
),
@@ -3,9 +3,9 @@ import { injectProjectPageContext } from '@modrinth/ui'
import type { Ref } from 'vue'
import { computed } from 'vue'
import type { NodeState, StageNodeBuilder } from '../../types/node'
import type { AnyNode, ChildNode, NodeState, StageNode } from '../../types/node'
import {
NodeBuilder,
externalGroup,
getBooleanChildState,
group,
isNodeActive,
@@ -18,7 +18,7 @@ import {
import { Priorities } from '../priorities.ts'
export default function (
mainStages: StageNodeBuilder[],
mainStages: StageNode[],
globalState: Ref<Record<string, Record<string, NodeState>>>,
) {
const { projectV3: project } = injectProjectPageContext()
@@ -47,23 +47,26 @@ export default function (
.priority(Priorities.alerts)
.applyFixes()
.children(
computed<NodeBuilder | null>(() => {
const fixNodes: NodeBuilder[] = []
computed<AnyNode | null>(() => {
const fixGroups: ChildNode[] = []
walkNodes(
[group().children(...mainStages)],
(globalState.value ?? {}) as unknown as Record<string, NodeState>,
(node, nodeState) => {
if (!node._fixes.length) return
(node, nodeState, _localState, path) => {
if (!('_fixes' in node) || !(node as { _fixes: unknown[] })._fixes.length) return
if (!isNodeActive(node, nodeState)) return
const childState = getBooleanChildState(nodeState)
fixNodes.push(
...resolveChildren(node, childState).filter(
(c): c is NodeBuilder => c instanceof NodeBuilder,
),
const children = resolveChildren(
node as never,
getBooleanChildState(nodeState),
).filter(
(c): c is Exclude<ChildNode, string | (() => unknown)> =>
typeof c === 'object' && c !== null,
)
if (children.length === 0) return
fixGroups.push(externalGroup(path).children(...children))
},
)
return fixNodes.length > 0 ? group().children(...fixNodes) : null
return fixGroups.length > 0 ? group().children(...fixGroups) : null
}),
),
@@ -31,20 +31,17 @@ export default function () {
toggle('insufficient', 'Insufficient')
.enabled((state) => !state['repeat-title'])
.suggestedStatus('flagged')
.severity('low')
.message(),
toggle('repeat-title', 'Repeat of Title')
.enabled((state) => !state.insufficient)
.suggestedStatus('flagged')
.severity('low')
.message(),
toggle('formatting', 'Formatting').suggestedStatus('flagged').severity('low').message(),
toggle('formatting', 'Formatting').suggestedStatus('flagged').message(),
toggle('non-english', 'Non-english')
.suggestedStatus('flagged')
.severity('medium')
.message()
.shown(
computed(() => {
@@ -62,7 +59,6 @@ export default function () {
toggle('repeat-ip', 'Repeat of IP')
.shown(computed(() => !!project.value?.minecraft_server))
.suggestedStatus('flagged')
.severity('medium')
.message(),
),
)
+108 -133
View File
@@ -5,18 +5,23 @@ import {
TagCategoryRefreshCcwIcon,
TagCategoryWandSparklesIcon,
UserPlusIcon,
WrenchIcon,
} from '@modrinth/assets'
import { Alert, injectModrinthClient, injectProjectPageContext } from '@modrinth/ui'
import {
Alert,
injectModrinthClient,
injectProjectPageContext,
ProjectStatusLink,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { computed, ref, watch } from 'vue'
import { md, type NodeState } from '../../types/node'
import { button, fix, group, option, stage, text, toggle } from '../../types/node'
import { check, fix, group, md, stage, text, toggle } from '../../types/node'
const STALE_TIME = 1000 * 60 * 5
type AutoSlugStatus = 'loading' | 'available' | 'unavailable'
type SlugValidation = 'checking' | 'available' | 'unchanged' | 'taken' | null
type SlugValidation = 'checking' | 'available' | 'unchanged' | 'taken' | 'empty' | 'invalid' | null
//TODO: make this not a copy of frontend/src/utils/slugs.generateUrlSlug
// (as in move the other one so we can use it here)
@@ -46,12 +51,6 @@ export default function () {
const slugValidation = ref<SlugValidation>(null)
let slugDebounceTimer: ReturnType<typeof setTimeout> | undefined
function currentSlug(state: Record<string, NodeState>) {
return (
(state['correct-slug'] as string | undefined) ?? resolvedAutoSlug.value ?? project.value.slug
)
}
async function checkSlugTaken(slug: string): Promise<Labrinth.Projects.v3.Project | null> {
try {
return await queryClient.fetchQuery({
@@ -66,8 +65,13 @@ export default function () {
}
const SlugStatus = () => {
const v = slugValidation.value
if (v === null) return null
const v =
slugValidation.value ??
(autoSlugStatus.value === 'loading'
? 'checking'
: autoSlugStatus.value === 'unavailable'
? 'taken'
: 'available')
if (v === 'checking')
return (
<Alert type="checking" class="w-full">
@@ -86,19 +90,25 @@ export default function () {
Slug is available
</Alert>
)
if (v === 'empty')
return (
<Alert type="error" class="w-full">
Slug cannot be empty
</Alert>
)
if (v === 'invalid')
return (
<Alert type="error" class="w-full">
Invalid Slug
</Alert>
)
const by = correctSlugConflict.value
return (
<Alert type="error" class="w-full">
Slug taken
{by ? (
<>
{' by '}
<a href={`/project/${by.slug}`} target="_blank" class="underline">
{by.name}
</a>
{` (${by.status})`}
</>
) : null}
<div class="flex flex-wrap items-center gap-1.5">
Slug taken
{by ? <ProjectStatusLink project={by} newTab /> : null}
</div>
</Alert>
)
}
@@ -157,7 +167,9 @@ export default function () {
return
}
}
} catch {}
} catch {
// fall through to 'unavailable' below
}
autoSlugStatus.value = 'unavailable'
},
@@ -190,12 +202,7 @@ export default function () {
{autoSlugStatus.value === 'loading' ? (
'...'
) : by ? (
<>
<a href={`/project/${by.slug}`} target="_blank" class="underline">
{by.name}
</a>
{` (${by.status})`}
</>
<ProjectStatusLink project={by} newTab />
) : (
'No'
)}
@@ -206,30 +213,22 @@ export default function () {
group('title')
.title('Title Issues?')
.children(
toggle('useless-info', 'Contains Useless Info')
.suggestedStatus('flagged')
.severity('low')
.message(),
toggle('useless-info', 'Contains Useless Info').suggestedStatus('flagged').message(),
toggle('minecraft-branding', 'Minecraft Title')
.suggestedStatus('flagged')
.severity('medium')
.message(),
toggle('minecraft-branding', 'Minecraft Title').suggestedStatus('flagged').message(),
toggle('similarities', 'Title Similarities')
.suggestedStatus('flagged')
.severity('medium')
.message()
.children(
group()
.title('Similarities Additional Info')
.multiSelect('options')
.children(
option('modpack', 'Modpack Named After Mod')
check('modpack', 'Modpack Named After Mod')
.shown(computed(() => project.value.project_types.includes('modpack')))
.message(),
option('fork', 'Forked Project')
check('fork', 'Forked Project')
.shown(computed(() => !project.value?.minecraft_server))
.message(),
),
@@ -241,100 +240,76 @@ export default function () {
.title('Slug Issues')
.shown(computed(() => hasCustomSlug(project.value)))
.children(
group()
.multiSelect('issues')
.children(
toggle('misused', 'Misused')
.children(
group()
.title('Correct Slug')
.children(
text('correct-slug')
.initial(() => resolvedAutoSlug.value ?? project.value.slug)
.onChange((value, { override }) => {
if (!value) return override(project.value.slug ?? '')
clearTimeout(slugDebounceTimer)
if (value === project.value.slug) {
slugValidation.value = 'unchanged'
return
group().children(
toggle('misused', 'Misused')
.children(
group()
.title('Correct Slug')
.children(
text('correct-slug')
.initial(() => resolvedAutoSlug.value ?? project.value.slug ?? '')
.onChange((value) => {
clearTimeout(slugDebounceTimer)
if (value === project.value.slug) {
slugValidation.value = 'unchanged'
return
}
if (!value) {
slugValidation.value = 'empty'
correctSlugConflict.value = null
return
}
if (generateUrlSlug(value) !== value) {
slugValidation.value = 'invalid'
correctSlugConflict.value = null
return
}
slugValidation.value = 'checking'
slugDebounceTimer = setTimeout(async () => {
const conflict = await checkSlugTaken(value).catch(() => null)
if (conflict !== null && conflict.id !== project.value.id) {
correctSlugConflict.value = conflict
slugValidation.value = 'taken'
} else {
correctSlugConflict.value = null
slugValidation.value = 'available'
}
slugValidation.value = 'checking'
slugDebounceTimer = setTimeout(async () => {
const conflict = await checkSlugTaken(value).catch(() => null)
if (conflict !== null && conflict.id !== project.value.id) {
correctSlugConflict.value = conflict
slugValidation.value = 'taken'
} else {
correctSlugConflict.value = null
slugValidation.value = 'available'
}
}, 400)
}),
}, 400)
})
.tweak(TagCategoryWandSparklesIcon, () =>
autoSlugStatus.value === 'available' ? resolvedAutoSlug.value : null,
)
.tweak(UserPlusIcon, (current) =>
ownerUsername.value && !current?.includes(ownerUsername.value)
? `${current}-${ownerUsername.value}`
: null,
)
.tweak(WrenchIcon, (current) => generateUrlSlug(current ?? ''))
.tweak(TagCategoryRefreshCcwIcon, () => project.value.slug),
button()
.icon(TagCategoryWandSparklesIcon)
.tooltip(computed(() => resolvedAutoSlug.value ?? ''))
.enabled(
(state) =>
autoSlugStatus.value === 'available' &&
resolvedAutoSlug.value !== null &&
resolvedAutoSlug.value !== currentSlug(state),
)
.onClick((state) => {
if (resolvedAutoSlug.value) state['correct-slug'] = resolvedAutoSlug.value
}),
button()
.icon(UserPlusIcon)
.tooltip((state) => {
const current = currentSlug(state)
if (!ownerUsername.value || current?.includes(ownerUsername.value))
return current ?? ''
return `${current}-${ownerUsername.value}`
})
.enabled(
(state) =>
ownerUsername.value !== null &&
!currentSlug(state)?.includes(ownerUsername.value),
)
.onClick((state) => {
state['correct-slug'] = `${currentSlug(state)}-${ownerUsername.value}`
}),
button()
.icon(TagCategoryRefreshCcwIcon)
.tooltip(computed(() => project.value.slug ?? ''))
.enabled((state) => currentSlug(state) !== project.value.slug)
.onClick((state) => {
state['correct-slug'] = project.value.slug
}),
SlugStatus,
),
)
.rawMessage(async (state) => {
let correct = ''
if (slugValidation.value === 'available') {
const slug = state['correct-slug'] as string | undefined
if (slug)
correct = await md('checklist/messages/title-slug/slug/correction', () => ({
SUGGESTED_SLUG: slug,
}))(state)
}
return md('checklist/messages/title-slug/slug/misused', () => ({
CORRECT: correct,
}))(state)
})
.fix(
//TODO chyz think of some way to have initial values actually be reflected in state without having to store them
fix().project((patch, state) => {
const slug =
(state['correct-slug'] as string | undefined) ?? resolvedAutoSlug.value
if (!slug || slug === project.value.slug) return
patch.slug = slug
}),
),
),
SlugStatus,
),
)
.rawMessage(async (state) => {
let correct = ''
if (slugValidation.value === 'available') {
const slug = state['correct-slug'] as string | undefined
if (slug)
correct = await md('checklist/messages/title-slug/slug/correction', () => ({
SUGGESTED_SLUG: slug,
}))(state)
}
return md('checklist/messages/title-slug/slug/misused', () => ({
CORRECT: correct,
}))(state)
})
.fix(
fix().project((patch, state) => {
if (slugValidation.value !== 'available') return
patch.slug = state['correct-slug'] as string
}),
),
),
),
)
}
@@ -19,38 +19,31 @@ export default function () {
group().children(
toggle('incorrect-additional-files', 'Incorrect additional files')
.suggestedStatus('flagged')
.severity('medium')
.message(),
toggle('incorrect-project-type', 'Incorrect Project Type')
.suggestedStatus('rejected')
.severity('medium')
.children(
dropdown('type')
.title('Correct Project Type')
.required()
.none('Unknown')
.children(
option('modpack', 'Modpack')
.shown(computed(() => !project.value.project_types.includes('modpack')))
.message(),
option('resourcepack', 'Resource Pack')
.shown(computed(() => !project.value.project_types.includes('resourcepack')))
.message(),
option('datapack', 'Data Pack')
.shown(computed(() => !project.value.loaders.includes('datapack')))
.message(),
.options(
option('modpack', 'Modpack').message(),
option('resourcepack', 'Resource Pack').message(),
option('datapack', 'Data Pack').message(),
),
)
.collect(),
toggle('alternate-versions', 'Alternate Versions')
.suggestedStatus('rejected')
.severity('high')
.children(
dropdown('distribution')
.title('Distribution Type')
.required()
.none('Unknown')
.children(
.options(
option('primary', 'Primary Files').message(),
option('additional', 'Additional Files').message(),
option('mono', 'Monofile')
@@ -78,7 +71,6 @@ export default function () {
toggle('vanilla-assets', 'Vanilla Assets')
.shown(computed(() => project.value.project_types.includes('resourcepack')))
.suggestedStatus('rejected')
.severity('medium')
.message(),
toggle('redist-libs', 'Packed Libs')
@@ -90,18 +82,15 @@ export default function () {
),
)
.suggestedStatus('rejected')
.severity('medium')
.message(),
toggle('duplicate-primary-files', 'Duplicate Primary Files')
.suggestedStatus('flagged')
.severity('medium')
.message(),
toggle('unsupported', 'Unsupported')
.suggestedStatus('rejected')
.severity('medium')
.message(undefined, (state) => ({
.message((state) => ({
INVALID_TYPE: state['invalid-type'],
}))
.children(text('invalid-type').title('Unsupported Type').required()),
+5 -1
View File
@@ -1,3 +1,4 @@
import keybinds from '../data/keybinds.ts'
import {
type KeybindDefinition,
type KeybindListener,
@@ -5,7 +6,6 @@ import {
type ModerationContext,
normalizeKeybind,
} from '../types/keybinds.ts'
import keybinds from '../data/keybinds.ts'
function normalizeKeybinds(
keybind: KeybindDefinition | KeybindDefinition[] | string | string[],
@@ -58,6 +58,9 @@ export class Keybinds {
continue
}
// The scope check above guarantees ctx matches keybind's expected context shape,
// but TS can't correlate that narrowing across these two independently-typed variables.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (keybind.enabled && !keybind.enabled(ctx as any)) {
continue
}
@@ -66,6 +69,7 @@ export class Keybinds {
const matches = definitions.some((def) => matchesKeybind(event, def))
if (matches) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
keybind.action(ctx as any)
const shouldPrevent = definitions.some((def) => def.preventDefault !== false)
+9 -5
View File
@@ -1,12 +1,12 @@
import { isValidFor, type SettingDefinitionBase } from '../types/settings.ts'
import { moderationSettings } from '../index.ts'
import { isValidFor, type SettingDefinitionBase } from '../types/settings.ts'
export class Settings {
private readonly settings: { [id: string]: any }
private readonly settings: { [id: string]: unknown }
private readonly onChange: () => void
constructor(
settings: { [id: string]: any } | undefined = undefined,
settings: { [id: string]: unknown } | undefined = undefined,
onChange: () => void = () => {},
) {
this.settings = settings || {}
@@ -16,11 +16,15 @@ export class Settings {
get<T>(definition: SettingDefinitionBase<T>): T {
const value = this.settings[definition.id]
return (isValidFor(definition, value) ? value : undefined) ?? definition.default
return (
(isValidFor(definition as SettingDefinitionBase<unknown>, value)
? (value as T)
: undefined) ?? definition.default
)
}
set<T>(definition: SettingDefinitionBase<T>, value?: T): void {
const previous = this.settings[definition.id] ?? definition.default
const previous = (this.settings[definition.id] as T | undefined) ?? definition.default
this.settings[definition.id] = value
definition.onChange?.(previous, value ?? definition.default)
this.onChange()
+5 -9
View File
@@ -1,6 +1,5 @@
export { default as checklist, stages, useStages } from './data/checklist'
export { useStages } from './data/checklist'
export { default as keybinds } from './data/keybinds'
export { default as moderationSettings } from './data/settings'
export { default as nags } from './data/nags'
export * from './data/nags/index'
export { default as attributionQuickReplies } from './data/quick-replies/permissions-quick-replies'
@@ -9,17 +8,14 @@ export {
type TechReviewContext,
default as techReviewQuickReplies,
} from './data/quick-replies/tech-review-quick-replies'
export { default as moderationSettings } from './data/settings'
export * from './handles/keybinds'
export * from './handles/settings'
export * from './locales'
export * from './types/actions'
export * from './types/keybinds'
export * from './types/settings'
export * from './types/messages'
export * from './types/nags'
export * from './types/node'
export * from './types/priority'
export * from './types/quick-reply'
export * from './types/reports'
export * from './types/stage'
export * from './types/settings'
export * from './utils'
export * from './handles/keybinds'
export * from './handles/settings'
-298
View File
@@ -1,298 +0,0 @@
import type { Labrinth } from '@modrinth/api-client'
import type { WeightedMessage } from './messages'
export type ActionType =
| 'button'
| 'dropdown'
| 'multi-select-chips'
| 'toggle'
| 'conditional-button'
export type Action =
| ButtonAction
| DropdownAction
| MultiSelectChipsAction
| ToggleAction
| ConditionalButtonAction
export type ModerationStatus = 'approved' | 'rejected' | 'flagged'
export type ModerationSeverity = 'low' | 'medium' | 'high' | 'critical'
export interface ChecklistActionContext {
project: Labrinth.Projects.v2.Project
projectV3: Labrinth.Projects.v3.Project
versions?: Labrinth.Versions.v2.Version[] | null
}
export interface BaseAction {
/**
* The type of action, which determines how the action is presented to the moderator and what it does.
*/
type: ActionType
/**
* Any additional text data that is required to complete the action.
*/
relevantExtraInput?: AdditionalTextInput[]
/**
* Suggested moderation status when this action is selected.
*/
suggestedStatus?: ModerationStatus
/**
* Suggested severity level for this moderation action.
*/
severity?: ModerationSeverity
/**
* Actions that become available when this action is selected.
*/
enablesActions?: Action[]
/**
* Actions that become unavailable when this action is selected.
*/
disablesActions?: string[] // Array of action IDs
/**
* Unique identifier for this action, used for conditional logic.
*/
id?: string
/**
* A function that determines whether this action should be shown for a given project.
*
* By default, it returns `true`, meaning the action is always shown.
*/
shouldShow?: (
project: Labrinth.Projects.v2.Project,
projectV3: Labrinth.Projects.v3.Project,
context?: ChecklistActionContext,
) => boolean
}
/**
* Represents a conditional message that changes based on other selected actions.
*/
export interface ConditionalMessage extends WeightedMessage {
/**
* Conditions that must be met for this message to be used.
*/
conditions: {
/**
* Action IDs that must be selected for this message to apply.
*/
requiredActions?: string[]
/**
* Action IDs that must NOT be selected for this message to apply.
*/
excludedActions?: string[]
}
}
/**
* Represents a button action, which is a simple toggle button that can be used to append a message to the final moderation message.
*/
export interface ButtonAction extends BaseAction, WeightedMessage {
type: 'button'
/**
* The label of the button, which is displayed to the moderator. The text on the button.
*/
label: string
/**
* Alternative messages based on other selected actions.
*/
conditionalMessages?: ConditionalMessage[]
}
/**
* Represents a simple toggle/checkbox action with separate layout handling.
*/
export interface ToggleAction extends BaseAction, WeightedMessage {
type: 'toggle'
/**
* The label of the toggle, which is displayed to the moderator.
*/
label: string
/**
* Description text that appears below the toggle.
*/
description?: string
/**
* Whether the toggle is checked by default.
*/
defaultChecked?: boolean
/**
* Alternative messages based on other selected actions.
*/
conditionalMessages?: ConditionalMessage[]
}
/**
* Represents a button that has different behavior based on other selected actions.
*/
export interface ConditionalButtonAction extends BaseAction {
type: 'conditional-button'
/**
* The label of the button, which is displayed to the moderator.
*/
label: string
/**
* Different message configurations based on conditions.
*/
messageVariants: ConditionalMessage[]
/**
* Global fallback message if no variants match their conditions.
*/
fallbackMessage?: () => Promise<string>
/**
* The weight of the action's fallback message, used to determine the place where the message is placed in the final moderation message.
*/
fallbackWeight?: number
}
export interface DropdownActionOption extends WeightedMessage {
/**
* The label of the option, which is displayed to the moderator.
*/
label: string
/**
* A function that determines whether this option should be shown for a given project.
*
* By default, it returns `true`, meaning the option is always shown.
*/
shouldShow?: (
project: Labrinth.Projects.v2.Project,
projectV3: Labrinth.Projects.v3.Project,
context?: ChecklistActionContext,
) => boolean
}
export interface DropdownAction extends BaseAction {
type: 'dropdown'
/**
* The label associated with the dropdown.
*/
label: string
/**
* The options available in the dropdown.
*/
options: DropdownActionOption[]
/**
* The default option selected in the dropdown, by index.
*/
defaultOption?: number
}
export interface MultiSelectChipsOption extends WeightedMessage {
/**
* Stable identifier for the option. If omitted, the label is used.
*/
id?: string
/**
* The label of the chip, which is displayed to the moderator.
*/
label: string
/**
* A function that determines whether this option should be shown for a given project.
*
* By default, it returns `true`, meaning the option is always shown.
*/
shouldShow?: (
project: Labrinth.Projects.v2.Project,
projectV3: Labrinth.Projects.v3.Project,
context?: ChecklistActionContext,
) => boolean
}
export type MultiSelectChipsOptionsResolver = (
context: ChecklistActionContext,
) => MultiSelectChipsOption[]
export interface MultiSelectChipsAction extends BaseAction {
type: 'multi-select-chips'
/**
* The label associated with the multi-select chips.
*/
label: string
/**
* The options available in the multi-select chips.
*/
options: MultiSelectChipsOption[] | MultiSelectChipsOptionsResolver
/**
* If set, all selected option messages are joined with this string and emitted as a single
* message part rather than individual parts. Useful for building bullet lists under a shared header.
*/
joinWith?: string
}
export interface AdditionalTextInput {
/**
* The label of the text input, which is displayed to the moderator.
*/
label: string
/**
* The placeholder text for the text input.
*/
placeholder?: string
/**
* Whether the text input is required to be filled out before the action can be completed.
*/
required?: boolean
/**
* Whether the text input should use the full markdown editor rather than a simple text input.
*/
large?: boolean
/**
* The variable name that will be replaced in the message with the input value.
* For example, if variable is "MESSAGE", then "%MESSAGE%" in the action message
* will be replaced with the input value.
*/
variable?: string
/**
* Conditions that determine when this input is shown.
*/
showWhen?: {
/**
* Action IDs that must be selected for this input to be shown.
*/
requiredActions?: string[]
/**
* Action IDs that must NOT be selected for this input to be shown.
*/
excludedActions?: string[]
}
/**
* Optional suggestions for the input. Useful for repeating phrases or common responses.
*/
suggestions?: string[]
}
-13
View File
@@ -1,13 +0,0 @@
export interface WeightedMessage {
/**
* The weight of the action's active message, used to determine the place where the message is placed in the final moderation message.
*/
weight: number
/**
* The message which is appended to the final moderation message if the button is active.
* @returns A function that lazily loads the message which is appended if the button is active.
* @example async () => (await import('../messages/example.md?raw')).default,
*/
message: () => Promise<string>
}
-880
View File
@@ -1,880 +0,0 @@
import type { Labrinth } from '@modrinth/api-client'
import type { FunctionalComponent, InjectionKey, Ref, SVGAttributes } from 'vue'
import { markRaw, toValue } from 'vue'
import {
expandVariables,
flattenProjectV3Variables,
flattenProjectVariables,
flattenStaticVariables,
} from '../utils'
import type { ModerationSeverity, ModerationStatus } from './actions'
import type { Priority } from './priority.ts'
// ─── State ────────────────────────────────────────────────────────────────────
export type NodeState =
| boolean
| string
| number
| Set<string>
| NodeStateWithChildren
| null
| undefined
export interface NodeStateWithChildren {
value?: NodeState
[childId: string]: NodeState
}
// ─── Function types ───────────────────────────────────────────────────────────
export type MessageFn = ((state: Record<string, NodeState>) => Promise<string>) & {
concat(...others: MessageFn[]): MessageFn
}
export type ContentFn = (state: Record<string, NodeState>) => string | Promise<string>
export type ChildrenFn = (state: Record<string, NodeState>) => ChildEntry[]
export type Reactive<T> = T | Ref<T>
export function resolve<T>(value: Reactive<T>): T {
return toValue(value as T | Ref<T>)
}
export type ChildNode = NodeBuilder | (() => unknown) | string
export type ChildEntry =
| NodeBuilder
| string
| (() => unknown)
| null
| Ref<NodeBuilder | null>
| ((state?: Record<string, NodeState>) => NodeBuilder | NodeBuilder[] | null)
export type NodeType =
| 'toggle'
| 'check'
| 'button'
| 'text'
| 'markdown'
| 'group'
| 'dropdown'
| 'option'
| 'stage'
// ─── Message helpers ──────────────────────────────────────────────────────────
const messageFiles = import.meta.glob('../data/messages/**/*.md', {
query: '?raw',
import: 'default',
})
export function mdOptional(
path: string,
getVars?: (state: Record<string, any>) => Record<string, any>,
): MessageFn {
return makeMessageFn(async (state) => {
const loader = messageFiles[`../data/messages/${path}.md`]
if (!loader) return ''
return loadMd(path, state, _project!.value, _projectV2!.value, getVars)
})
}
export function mdEscape(text: string): string {
return text.replace(/[\\*_`[~]/g, '\\$&')
}
const USER_CONTENT_KEYS = [
'PROJECT_TITLE',
'PROJECT_SLUG',
'PROJECT_SUMMARY',
'PROJECT_TYPE',
'PROJECT_STATUS',
]
export async function loadMd(
path: string,
state: Record<string, NodeState>,
project: Labrinth.Projects.v3.Project,
projectV2: Labrinth.Projects.v2.Project,
getVars?: (state: Record<string, any>) => Record<string, any>,
): Promise<string> {
// Call getVars before any await so Vue's watchEffect tracks reactive reads inside it
const extraVars = getVars ? getVars(state) : null
const loader = messageFiles[`../data/messages/${path}.md`]
if (!loader) {
_onMissingMd?.(path)
return ''
}
const raw = (await loader()) as string
const vars: Record<string, string> = {
...flattenStaticVariables(),
...flattenProjectVariables(projectV2),
...flattenProjectV3Variables(project),
}
for (const key of USER_CONTENT_KEYS) {
if (key in vars) vars[key] = mdEscape(vars[key])
}
if (extraVars) {
for (const [k, v] of Object.entries(extraVars)) {
vars[k] = String(v ?? '')
}
}
const expanded = expandVariables(raw, projectV2, project, vars)
// Code spans render literally — markdown escapes inside them show as-is, so strip them
return expanded.replace(/`[^`\n]*`/g, (match) => match.replace(/\\([\\*_`[~])/g, '$1'))
}
function makeMessageFn(fn: (state: Record<string, NodeState>) => Promise<string>): MessageFn {
const rich = fn as MessageFn
rich.concat = (...others) =>
makeMessageFn(async (state) =>
(await Promise.all([rich, ...others].map((f) => f(state)))).join(''),
)
return rich
}
let _project: Ref<Labrinth.Projects.v3.Project> | null = null
let _projectV2: Ref<Labrinth.Projects.v2.Project> | null = null
let _onMissingMd: ((path: string) => void) | null = null
export function setMissingMdHandler(handler: (path: string) => void) {
_onMissingMd = handler
}
export function setMessageProject(
project: Ref<Labrinth.Projects.v3.Project>,
projectV2: Ref<Labrinth.Projects.v2.Project>,
) {
_project = project
_projectV2 = projectV2
}
export function md(
path: string | ((state: Record<string, NodeState>) => string),
getVars?: (state: Record<string, any>) => Record<string, any>,
): MessageFn {
return makeMessageFn(async (state) => {
const resolvedPath = typeof path === 'function' ? path(state) : path
return loadMd(resolvedPath, state, _project!.value, _projectV2!.value, getVars)
})
}
// ─── Fix builder ──────────────────────────────────────────────────────────────
export function createTrackedPatch<T extends object>(
source: T,
): { proxy: T; changes: () => Partial<T> } {
const written = new Set<string | symbol>()
const data = { ...source }
const proxy = new Proxy(data, {
set(target, key, value) {
if (value !== (source as Record<string | symbol, unknown>)[key]) {
written.add(key)
} else {
written.delete(key)
}
;(target as Record<string | symbol, unknown>)[key] = value
return true
},
})
return {
proxy,
changes: () =>
Object.fromEntries([...written].map((k) => [k, data[k as keyof T]])) as Partial<T>,
}
}
export class FixBuilder {
_projectFn?: (
patch: Labrinth.Projects.v3.EditProjectRequest,
state: Record<string, NodeState>,
) => void
_versionFn?: (
patch: Labrinth.Versions.v3.ModifyVersionRequest,
state: Record<string, NodeState>,
) => void
project(
fn: (patch: Labrinth.Projects.v3.EditProjectRequest, state: Record<string, NodeState>) => void,
): this {
this._projectFn = fn
return this
}
version(
fn: (
patch: Labrinth.Versions.v3.ModifyVersionRequest,
state: Record<string, NodeState>,
) => void,
): this {
this._versionFn = fn
return this
}
}
export function fix(): FixBuilder {
return new FixBuilder()
}
// ─── Message segments ─────────────────────────────────────────────────────────
type GetVarsFn = (state: Record<string, any>) => Record<string, any>
export type MessageSegment =
| { type: 'fn'; fn: ContentFn }
| { type: 'auto'; getVars?: GetVarsFn }
| { type: 'path'; path: string | (() => string); getVars?: GetVarsFn }
| { type: 'collect'; fallback?: MessageSegment }
export function resolveRelativeMessagePath(
messagePath: string | (() => string),
statePath: string[],
): string {
const name = typeof messagePath === 'function' ? messagePath() : messagePath
if (name.startsWith('/')) return `checklist/messages${name}`
const parts = [...statePath.slice(0, -1), ...name.split('/')]
const normalized = parts.reduce<string[]>((acc, p) => {
if (p === '..') acc.pop()
else if (p) acc.push(p)
return acc
}, [])
return `checklist/messages/${normalized.join('/')}`
}
export async function evalSegment(
seg: MessageSegment,
state: Record<string, NodeState>,
statePath: string[],
): Promise<string> {
if (seg.type === 'collect') return ''
if (seg.type === 'fn') return String((await seg.fn(state)) ?? '')
if (seg.type === 'auto')
return loadMd(
`checklist/messages/${statePath.join('/')}`,
state,
_project!.value,
_projectV2!.value,
seg.getVars,
)
return loadMd(
resolveRelativeMessagePath(seg.path, statePath),
state,
_project!.value,
_projectV2!.value,
seg.getVars,
)
}
// ─── Node builders ────────────────────────────────────────────────────────────
export abstract class NodeBuilder {
abstract readonly type: NodeType
_shown?: Reactive<boolean>
_title?: Reactive<string>
_icon?: FunctionalComponent<SVGAttributes>
_tooltip?: Reactive<string> | ((state: Record<string, NodeState>) => string)
shown(condition: Reactive<boolean>): this {
this._shown = condition
return this
}
title(t: Reactive<string>): this {
this._title = t
return this
}
icon(i: FunctionalComponent<SVGAttributes>): this {
this._icon = markRaw(i)
return this
}
tooltip(t: Reactive<string> | ((state: Record<string, NodeState>) => string)): this {
this._tooltip = t
return this
}
}
export class ButtonNodeBuilder extends NodeBuilder {
readonly type = 'button' as const
readonly label?: string
_onClick?: (state: Record<string, NodeState>) => void
_enabled?: Reactive<boolean> | ((state: Record<string, NodeState>) => boolean)
constructor(nodeLabel?: string) {
super()
this.label = nodeLabel
}
onClick(fn: (state: Record<string, NodeState>) => void): this {
this._onClick = fn
return this
}
enabled(condition: Reactive<boolean> | ((state: Record<string, NodeState>) => boolean)): this {
this._enabled = condition
return this
}
}
export abstract class IdentifiedNodeBuilder extends NodeBuilder {
readonly id: string | undefined
_children: ChildEntry[] = []
_childrenFn?: ChildrenFn
_computingChildren = false
_segments: MessageSegment[] = []
_suggestedStatus?: ModerationStatus
_severity?: ModerationSeverity
_priority?: Priority
_fixes: FixBuilder[] = []
_applyFixes = false
_enabled?: Reactive<boolean> | ((state: Record<string, NodeState>) => boolean)
_statePath?: string[]
constructor(id: string | undefined) {
super()
this.id = id
}
children(fn: ChildrenFn): this
children(...entries: ChildEntry[]): this
children(...args: [ChildrenFn] | ChildEntry[]): this {
if (args.length === 1 && typeof args[0] === 'function' && args[0].length >= 1) {
this._childrenFn = args[0] as ChildrenFn
} else {
this._children.push(...(args as ChildEntry[]))
}
return this
}
message(path?: string | (() => string), getVars?: GetVarsFn): this {
const segment: MessageSegment =
path === undefined
? { type: 'auto', ...(getVars && { getVars }) }
: { type: 'path', path, ...(getVars && { getVars }) }
this._segments.push(segment)
return this
}
rawMessage(content: string | ContentFn): this {
const fn: ContentFn = typeof content === 'string' ? () => content : content
this._segments.push({ type: 'fn', fn })
return this
}
collect(path?: string | (() => string), getVars?: GetVarsFn): this {
const fallback =
path !== undefined || getVars !== undefined
? path === undefined
? { type: 'auto' as const, ...(getVars && { getVars }) }
: { type: 'path' as const, path, ...(getVars && { getVars }) }
: undefined
this._segments.push({ type: 'collect', ...(fallback && { fallback }) })
return this
}
suggestedStatus(s: ModerationStatus): this {
this._suggestedStatus = s
return this
}
severity(s: ModerationSeverity): this {
this._severity = s
return this
}
priority(p: Priority): this {
this._priority = p
return this
}
fix(f: FixBuilder): this {
this._fixes.push(f)
return this
}
applyFixes(): this {
this._applyFixes = true
return this
}
enabled(condition: Reactive<boolean> | ((state: Record<string, NodeState>) => boolean)): this {
this._enabled = condition
return this
}
statePath(path: string[]): this {
this._statePath = path
return this
}
}
export abstract class LabeledNodeBuilder extends IdentifiedNodeBuilder {
readonly label: string
constructor(id: string, label: string) {
super(id)
this.label = label
}
}
export abstract class ValueNodeBuilder extends LabeledNodeBuilder {
_defaultValue?: NodeState | ((state: Record<string, NodeState>) => NodeState)
_required?: boolean
initial(v: NodeState | ((state: Record<string, NodeState>) => NodeState)): this {
this._defaultValue = v
return this
}
required(v = true): this {
this._required = v
return this
}
}
export class BooleanNodeBuilder extends ValueNodeBuilder {
readonly type: 'toggle' | 'check'
constructor(id: string, nodeLabel: string, type: 'toggle' | 'check') {
super(id, nodeLabel)
this.type = type
}
}
export type OverrideValue = { readonly __override: string }
export type OnChangeFn = (
value: string,
helpers: { override: (value: string) => OverrideValue },
) => OverrideValue | void
export class InputNodeBuilder extends IdentifiedNodeBuilder {
readonly type: 'text' | 'markdown'
_placeholder?: Reactive<string>
_defaultValue?: NodeState | ((state: Record<string, NodeState>) => NodeState)
_required?: boolean
_onChange?: OnChangeFn
constructor(id: string, type: 'text' | 'markdown') {
super(id)
this.type = type
}
placeholder(p: Reactive<string>): this {
this._placeholder = p
return this
}
initial(v: NodeState | ((state: Record<string, NodeState>) => NodeState)): this {
this._defaultValue = v
return this
}
required(v = true): this {
this._required = v
return this
}
onChange(fn: OnChangeFn): this {
this._onChange = fn
return this
}
}
export class GroupNodeBuilder extends IdentifiedNodeBuilder {
readonly type = 'group' as const
_layout?: 'flex' | 'column'
_required?: boolean
_selectMode?: 'single' | 'multi'
_selectId?: string
layout(l: 'flex' | 'column'): this {
this._layout = l
return this
}
required(v = true): this {
this._required = v
return this
}
singleSelect(id?: string): this {
this._selectMode = 'single'
if (id !== undefined) this._selectId = id
return this
}
multiSelect(id?: string): this {
this._selectMode = 'multi'
if (id !== undefined) this._selectId = id
return this
}
}
export class DropdownNodeBuilder extends IdentifiedNodeBuilder {
readonly type = 'dropdown' as const
_none?: string
override children(fn: ChildrenFn): this
override children(...nodes: OptionNodeBuilder[]): this
override children(...args: [ChildrenFn] | OptionNodeBuilder[]): this {
if (args.length === 1 && typeof args[0] === 'function' && args[0].length >= 1) {
super.children(args[0] as ChildrenFn)
} else {
super.children(...(args as OptionNodeBuilder[]))
}
return this
}
none(text: string): this {
this._none = text
return this
}
}
export class OptionNodeBuilder extends LabeledNodeBuilder {
readonly type = 'option' as const
}
export class StageNodeBuilder extends LabeledNodeBuilder {
readonly type = 'stage' as const
_hint?: string
_guidanceUrl?: string
_icon?: FunctionalComponent<SVGAttributes>
_navigate?: string
hint(h: string): this {
this._hint = h
return this
}
guidance(url: string): this {
this._guidanceUrl = url
return this
}
icon(i: FunctionalComponent<SVGAttributes>): this {
this._icon = markRaw(i)
return this
}
navigate(path: string): this {
this._navigate = path
return this
}
override children(fn: ChildrenFn): this
override children(...entries: ChildEntry[]): this
override children(...args: [ChildrenFn] | ChildEntry[]): this {
if (args.length === 1 && typeof args[0] === 'function' && args[0].length >= 1) {
super.children(args[0] as ChildrenFn)
if (!this._statePath) this._statePath = [this.id!]
return this
}
const entries = args as ChildEntry[]
super.children(...entries)
if (!this._statePath) this._statePath = [this.id!]
stampChildPaths(entries, [this.id!])
return this
}
}
export const STAGES_KEY: InjectionKey<Ref<StageNodeBuilder[]>> = Symbol('checklistStages')
export const GLOBAL_STATE_KEY: InjectionKey<Ref<Record<string, Record<string, NodeState>>>> =
Symbol('checklistGlobalState')
// ─── Node traversal ───────────────────────────────────────────────────────────
function childrenScopePath(node: IdentifiedNodeBuilder): string[] | null {
if (!node._statePath) return null
switch (node.type) {
case 'toggle':
case 'check':
case 'option':
case 'stage':
return node._statePath
case 'group': {
const g = node as GroupNodeBuilder
if (g._selectMode) {
// Option ids become namespaces at the same level as the group
return node._statePath.slice(0, -1)
}
return node._statePath
}
case 'dropdown':
// Option ids become namespaces at the same level as the dropdown
return node._statePath.slice(0, -1)
default:
return null
}
}
function stampChildPaths(entries: ChildEntry[], scopePath: string[]): void {
for (const entry of entries) {
// Skip reactive entries (refs, functions, nulls) — they get stamped on resolution
if (!(entry instanceof NodeBuilder)) continue
if (!(entry instanceof IdentifiedNodeBuilder)) continue
const selectId = entry instanceof GroupNodeBuilder ? entry._selectId : undefined
if (!entry.id && !selectId) {
// Truly transparent: no structural id and no select id
stampChildPaths(entry._children, scopePath)
continue
}
if (!entry._statePath) {
// Structural id contributes to path first, then select id
const pathComponents = entry.id
? selectId
? [...scopePath, entry.id, selectId]
: [...scopePath, entry.id]
: [...scopePath, selectId!]
entry._statePath = pathComponents
}
const childScope = childrenScopePath(entry)
if (childScope) stampChildPaths(entry._children, childScope)
}
}
export function isNodeActive(node: NodeBuilder, state: NodeState): boolean {
switch (node.type) {
case 'toggle':
case 'check':
case 'option': {
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
}
return (node as BooleanNodeBuilder)._defaultValue === true
}
case 'group': {
const g = node as GroupNodeBuilder
if (g._selectMode === 'single') return typeof state === 'string' && state !== ''
if (g._selectMode === 'multi') return state instanceof Set && state.size > 0
return false
}
case 'dropdown':
return typeof state === 'string' && state !== ''
case 'text':
case 'markdown':
return typeof state === 'string' && state !== ''
default:
return false
}
}
export function getBooleanChildState(nodeState: NodeState): Record<string, NodeState> {
if (nodeState && typeof nodeState === 'object' && !(nodeState instanceof Set)) {
return nodeState as Record<string, NodeState>
}
return {}
}
export function resolveChildren(
node: IdentifiedNodeBuilder,
state: Record<string, NodeState>,
): ChildNode[] {
let entries: ChildEntry[]
let stampStatic = false
if (node._childrenFn) {
if (node._computingChildren) return []
node._computingChildren = true
try {
entries = node._childrenFn(state)
stampStatic = true // childrenFn results haven't been pre-stamped
} finally {
node._computingChildren = false
}
} else {
entries = node._children
}
const scopePath = childrenScopePath(node)
const result: ChildNode[] = []
for (const entry of entries) {
if (entry == null) continue
if (entry instanceof NodeBuilder) {
if (entry._shown !== undefined && !resolve(entry._shown)) continue
if (stampStatic && scopePath) stampChildPaths([entry], scopePath)
result.push(entry)
continue
}
if (typeof entry === 'string') {
result.push(entry)
continue
}
if (typeof entry === 'function') {
if (entry.length === 0) {
result.push(entry as () => unknown)
continue
}
const resolved = entry(state)
if (resolved == null) continue
if (Array.isArray(resolved)) {
for (const r of resolved) {
if (r == null) continue
if (r._shown !== undefined && !resolve(r._shown)) continue
if (scopePath) stampChildPaths([r], scopePath)
result.push(r)
}
continue
}
if (resolved._shown !== undefined && !resolve(resolved._shown)) continue
if (scopePath) stampChildPaths([resolved], scopePath)
result.push(resolved)
continue
}
// Ref<NodeBuilder | null>
const resolved = toValue(entry as Ref<NodeBuilder | null>)
if (resolved == null) continue
if (resolved._shown !== undefined && !resolve(resolved._shown)) continue
if (scopePath) stampChildPaths([resolved], scopePath)
result.push(resolved)
}
return result
}
export function walkNodes(
nodes: ChildNode[],
stageState: Record<string, NodeState>,
visitor: (
node: IdentifiedNodeBuilder,
nodeState: NodeState,
localState: Record<string, NodeState>,
) => void,
): void {
for (const node of nodes) {
if (!(node instanceof NodeBuilder)) continue
if (node._shown !== undefined && !resolve(node._shown)) continue
if (node.type === 'stage') {
const identified = node as IdentifiedNodeBuilder
if (identified.id) {
const raw = stageState[identified.id]
const childState =
raw && typeof raw === 'object' && !(raw instanceof Set)
? (raw as Record<string, NodeState>)
: {}
walkNodes(resolveChildren(identified, childState), childState, visitor)
} else {
walkNodes(resolveChildren(identified, stageState), stageState, visitor)
}
continue
}
if (node.type === 'group') {
const g = node as GroupNodeBuilder
if (!g._selectMode) {
// Plain container: traverse children in group's own state scope
if (g.id) {
const raw = stageState[g.id]
const childState =
raw && typeof raw === 'object' && !(raw instanceof Set)
? (raw as Record<string, NodeState>)
: {}
walkNodes(resolveChildren(g, childState), childState, visitor)
} else {
walkNodes(resolveChildren(g, stageState), stageState, visitor)
}
continue
}
// Fall through to value-node path for groups with selectMode
}
if (node.type === 'button') continue
const identified = node as IdentifiedNodeBuilder
const stateKey =
node.type === 'group'
? ((node as GroupNodeBuilder)._selectId ?? identified.id!)
: identified.id!
const nodeState = stageState[stateKey]
visitor(identified, nodeState, stageState)
const active = isNodeActive(node, nodeState)
const children = resolveChildren(identified, stageState)
if (children.length === 0 || !active) continue
if (node.type === 'group' && (node as GroupNodeBuilder)._selectMode === 'multi') {
const selected = nodeState instanceof Set ? nodeState : new Set<string>()
for (const child of children) {
const childId = child as IdentifiedNodeBuilder
if (!selected.has(childId.id!) || (child._shown !== undefined && !resolve(child._shown)))
continue
const rawChildState = stageState[childId.id!]
// Option children are active by virtue of being in the selected Set, not by their own
// boolean state. If they have child state but no explicit value, inject value: true
// so isNodeActive returns true even after child state has been written to their path.
const childState: NodeState =
rawChildState !== null &&
rawChildState !== undefined &&
typeof rawChildState === 'object' &&
!(rawChildState instanceof Set) &&
(rawChildState as NodeStateWithChildren).value === undefined
? { ...(rawChildState as NodeStateWithChildren), value: true }
: (rawChildState ?? true)
visitor(childId, childState, stageState)
walkNodes(resolveChildren(childId, stageState), stageState, visitor)
}
} else if (node.type === 'toggle' || node.type === 'check') {
const childState = getBooleanChildState(nodeState)
walkNodes(children, childState, visitor)
} else if (
(node.type === 'group' && (node as GroupNodeBuilder)._selectMode === 'single') ||
node.type === 'dropdown'
) {
const selectedId = typeof nodeState === 'string' ? nodeState : undefined
if (selectedId) {
for (const child of children) {
const childId = child as IdentifiedNodeBuilder
if (childId.id !== selectedId || (child._shown !== undefined && !resolve(child._shown)))
continue
const rawChildState = stageState[childId.id!]
const childState: NodeState =
rawChildState !== null &&
rawChildState !== undefined &&
typeof rawChildState === 'object' &&
!(rawChildState instanceof Set) &&
(rawChildState as NodeStateWithChildren).value === undefined
? { ...(rawChildState as NodeStateWithChildren), value: true }
: (rawChildState ?? true)
visitor(childId, childState, stageState)
walkNodes(resolveChildren(childId, stageState), stageState, visitor)
break
}
}
} else {
walkNodes(children, stageState, visitor)
}
}
}
// ─── Factory functions ────────────────────────────────────────────────────────
export type StageFn = (
project: Labrinth.Projects.v3.Project,
projectV2: Labrinth.Projects.v2.Project,
) => StageNodeBuilder
export function stageFn(factory: StageFn): StageFn {
const cache = new WeakMap<object, StageNodeBuilder>()
return (project, projectV2) => {
if (!cache.has(project)) cache.set(project, factory(project, projectV2))
return cache.get(project)!
}
}
export const toggle = (id: string, nodeLabel: string) =>
new BooleanNodeBuilder(id, nodeLabel, 'toggle')
export const check = (id: string, nodeLabel: string) =>
new BooleanNodeBuilder(id, nodeLabel, 'check')
export const button = (nodeLabel?: string) => new ButtonNodeBuilder(nodeLabel)
export const text = (id: string) => new InputNodeBuilder(id, 'text')
export const markdown = (id: string) => new InputNodeBuilder(id, 'markdown')
export const group = (id?: string) => new GroupNodeBuilder(id)
export const dropdown = (id: string) => new DropdownNodeBuilder(id)
export const option = (id: string, nodeLabel: string) => new OptionNodeBuilder(id, nodeLabel)
export const stage = (id: string, title: string) => new StageNodeBuilder(id, title)
@@ -0,0 +1,56 @@
import type { Ref } from 'vue'
import type { Configurable, Visible } from './capabilities'
import type { NodeState } from './state'
export type AnyNode = Visible
export type ChildNode = AnyNode | (() => unknown) | string
export type ChildEntry =
| AnyNode
| string
| (() => unknown)
| null
| Ref<AnyNode | null>
| ((state?: Record<string, NodeState>) => AnyNode | AnyNode[] | null)
export type ChildrenFn = (state: Record<string, NodeState>) => ChildEntry[]
export interface HasChildren {
_children: ChildEntry[]
_childrenFn?: ChildrenFn
children(this: this, fn: ChildrenFn): this
children(this: this, ...entries: ChildEntry[]): this
}
export function withChildren<T extends object>(node: T): T & HasChildren {
return Object.assign(node, {
_children: [] as ChildEntry[],
children(this: HasChildren, ...args: [ChildrenFn] | ChildEntry[]) {
if (args.length === 1 && typeof args[0] === 'function' && args[0].length >= 1) {
this._childrenFn = args[0] as ChildrenFn
} else {
this._children.push(...(args as ChildEntry[]))
}
return this
},
})
}
export function withAutoProps<T extends Configurable>(
node: T,
): T & { [key: string]: (value: unknown) => T } {
return new Proxy(node, {
get(target, prop, receiver) {
if (typeof prop === 'string' && !prop.startsWith('_') && !(prop in target)) {
return (value: unknown) => {
const prev = target._extraProps
target._extraProps = (ctx) => ({ ...prev?.(ctx), [prop]: value })
return receiver
}
}
return Reflect.get(target, prop, receiver)
},
}) as T & { [key: string]: (value: unknown) => T }
}
@@ -0,0 +1,449 @@
import type { Component, FunctionalComponent, SVGAttributes } from 'vue'
import { markRaw } from 'vue'
import { Priority } from '../priority.ts'
import type { FixBuilder } from './fix'
import type { GetVarsFn, MessageSegment, ModerationStatus, NodeState, Reactive } from './state'
export interface Visible {
_shown: Reactive<boolean> | undefined
shown(this: this, condition: Reactive<boolean>): this
}
export function withShown<T extends object>(node: T): T & Visible {
return Object.assign(node, {
_shown: undefined as Reactive<boolean> | undefined,
shown(this: Visible, condition: Reactive<boolean>) {
this._shown = condition
return this
},
})
}
export interface Iconable {
_icon: FunctionalComponent<SVGAttributes> | undefined
icon(this: this, icon: FunctionalComponent<SVGAttributes> | undefined): this
}
export function withIcon<T extends object>(node: T): T & Iconable {
return Object.assign(node, {
_icon: undefined as FunctionalComponent<SVGAttributes> | undefined,
icon(this: Iconable, icon: FunctionalComponent<SVGAttributes> | undefined) {
this._icon = icon ? markRaw(icon) : undefined
return this
},
})
}
export interface Tooltippable {
_tooltip: Reactive<string> | ((state: Record<string, NodeState>) => string) | undefined
tooltip(
this: this,
tooltip: Reactive<string> | ((state: Record<string, NodeState>) => string) | undefined,
): this
}
export function withTooltip<T extends object>(node: T): T & Tooltippable {
return Object.assign(node, {
_tooltip: undefined as Tooltippable['_tooltip'],
tooltip(this: Tooltippable, tooltip: Tooltippable['_tooltip']) {
this._tooltip = tooltip
return this
},
})
}
export interface Titled {
_title: Reactive<string> | undefined
title(this: this, title: Reactive<string>): this
}
export function withTitle<T extends object>(node: T): T & Titled {
return Object.assign(node, {
_title: undefined as Reactive<string> | undefined,
title(this: Titled, title: Reactive<string>) {
this._title = title
return this
},
})
}
export interface Identified {
id: string
_statePath?: string[]
statePath(this: this, path: string[]): this
}
export function withId<T extends object>(node: T, id: string): T & Identified {
return Object.assign(node, {
id,
statePath(this: Identified, path: string[]) {
this._statePath = path
return this
},
})
}
export interface Prioritizable {
_priority: Priority
priority(this: this, p: Priority): this
}
export function withPriority<T extends object>(node: T): T & Prioritizable {
return Object.assign(node, {
_priority: new Priority(),
priority(this: Prioritizable, p: Priority) {
this._priority = p
return this
},
})
}
export interface Messageable {
_segments: MessageSegment[]
_suggestedStatus?: ModerationStatus
suggestedStatus(this: this, status: ModerationStatus): this
message(this: this, path?: string | (() => string) | GetVarsFn, getVars?: GetVarsFn): this
rawMessage(
this: this,
content: string | ((state: Record<string, NodeState>) => string | Promise<string>),
): this
collect(this: this, path?: string | (() => string) | GetVarsFn, getVars?: GetVarsFn): this
}
function splitPathAndVars(
pathOrGetVars: string | (() => string) | GetVarsFn | undefined,
getVars: GetVarsFn | undefined,
): { path: string | (() => string) | undefined; getVars: GetVarsFn | undefined } {
if (typeof pathOrGetVars === 'function' && pathOrGetVars.length >= 1) {
return { path: undefined, getVars: pathOrGetVars as GetVarsFn }
}
return { path: pathOrGetVars as string | (() => string) | undefined, getVars }
}
export function withMessaging<T extends object>(node: T): T & Messageable {
return Object.assign(node, {
_segments: [] as MessageSegment[],
suggestedStatus(this: Messageable, status: ModerationStatus) {
this._suggestedStatus = status
return this
},
message(
this: Messageable,
pathOrGetVars?: string | (() => string) | GetVarsFn,
getVarsArg?: GetVarsFn,
) {
const { path, getVars } = splitPathAndVars(pathOrGetVars, getVarsArg)
const segment: MessageSegment =
path === undefined
? { type: 'auto', ...(getVars && { getVars }) }
: { type: 'path', path, ...(getVars && { getVars }) }
this._segments.push(segment)
return this
},
rawMessage(
this: Messageable,
content: string | ((state: Record<string, NodeState>) => string | Promise<string>),
) {
const fn = typeof content === 'string' ? () => content : content
this._segments.push({ type: 'fn', fn })
return this
},
collect(
this: Messageable,
pathOrGetVars?: string | (() => string) | GetVarsFn,
getVarsArg?: GetVarsFn,
) {
const { path, getVars } = splitPathAndVars(pathOrGetVars, getVarsArg)
const fallback: MessageSegment | undefined =
path !== undefined || getVars !== undefined
? path === undefined
? { type: 'auto', ...(getVars && { getVars }) }
: { type: 'path', path, ...(getVars && { getVars }) }
: undefined
this._segments.push({ type: 'collect', ...(fallback && { fallback }) })
return this
},
})
}
export interface Requireable {
_required: boolean | undefined
required(this: this, v?: boolean): this
}
export function withRequired<T extends object>(node: T): T & Requireable {
return Object.assign(node, {
_required: undefined as boolean | undefined,
required(this: Requireable, v = true) {
this._required = v
return this
},
})
}
export interface Fixable {
_fixes: FixBuilder[]
_applyFixes?: boolean
fix(this: this, f: FixBuilder): this
applyFixes(this: this): this
}
export function withFix<T extends object>(node: T): T & Fixable {
return Object.assign(node, {
_fixes: [] as FixBuilder[],
fix(this: Fixable, f: FixBuilder) {
this._fixes.push(f)
return this
},
applyFixes(this: Fixable) {
this._applyFixes = true
return this
},
})
}
export interface Enableable {
_enabled: Reactive<boolean> | ((state: Record<string, NodeState>) => boolean) | undefined
enabled(this: this, condition: Enableable['_enabled']): this
}
export function withEnabled<T extends object>(node: T): T & Enableable {
return Object.assign(node, {
_enabled: undefined as Enableable['_enabled'],
enabled(this: Enableable, condition: Enableable['_enabled']) {
this._enabled = condition
return this
},
})
}
export interface HasValue<V = unknown> {
_defaultValue?: V | ((state: Record<string, NodeState>) => V)
_getValue: (raw: NodeState) => V
_setValue: (raw: NodeState, next: V, isDefault?: boolean) => NodeState
_isActive: (value: V) => boolean
initial(this: this, v: V | ((state: Record<string, NodeState>) => V)): this
}
export function withValue<T extends object, V>(
node: T,
behavior: Pick<HasValue<V>, '_getValue' | '_setValue' | '_isActive'>,
): T & HasValue<V> {
return Object.assign(node, {
...behavior,
initial(this: HasValue<V>, v: V | ((state: Record<string, NodeState>) => V)) {
this._defaultValue = v
return this
},
})
}
export interface Clickable {
_onClick: ((state: Record<string, NodeState>) => void) | undefined
onClick(this: this, fn: Clickable['_onClick']): this
}
export function withOnClick<T extends object>(node: T): T & Clickable {
return Object.assign(node, {
_onClick: undefined as Clickable['_onClick'],
onClick(this: Clickable, fn: Clickable['_onClick']) {
this._onClick = fn
return this
},
})
}
export interface ComponentNodePropsContext {
onImageUpload?: (file: File) => Promise<string>
toggleSetValue?: (value: string) => void
nodeFacts?: { needsAttention: boolean; fixActionable: boolean }
tooltip?: Record<string, unknown>
}
export interface Renderable {
_component: Component | undefined
_rendererKey: string | undefined
_componentProps?: (ctx: ComponentNodePropsContext) => Record<string, unknown>
_modelProp: string
}
export function withComponent<T extends object>(
node: T,
opts: {
component?: Component
rendererKey?: string
modelProp?: string
componentProps?: Renderable['_componentProps']
},
): T & Renderable {
return Object.assign(node, {
_component: opts.component,
_rendererKey: opts.rendererKey,
_componentProps: opts.componentProps,
_modelProp: opts.modelProp ?? 'modelValue',
})
}
export interface Configurable {
_extraProps: ((ctx: ComponentNodePropsContext) => Record<string, unknown>) | undefined
}
export function withExtraProps<T extends object>(node: T): T & Configurable {
return Object.assign(node, {
_extraProps: undefined as Configurable['_extraProps'],
})
}
export interface HasNoneLabel {
_none: string | undefined
none(this: this, text: string): this
}
export function withNoneLabel<T extends object>(node: T): T & HasNoneLabel {
return Object.assign(node, {
_none: undefined as string | undefined,
none(this: HasNoneLabel, text: string) {
this._none = text
return this
},
})
}
export interface Layoutable {
_layout: 'flex' | 'column' | undefined
layout(this: this, value: 'flex' | 'column'): this
}
export function withLayout<T extends object>(node: T): T & Layoutable {
return Object.assign(node, {
_layout: undefined as 'flex' | 'column' | undefined,
layout(this: Layoutable, value: 'flex' | 'column') {
this._layout = value
return this
},
})
}
export interface Selectable {
_exclusive: boolean | undefined
singleSelect(this: this): this
}
export function withSelectable<T extends object>(node: T): T & Selectable {
return Object.assign(node, {
_exclusive: undefined as boolean | undefined,
singleSelect(this: Selectable) {
this._exclusive = true
return this
},
})
}
export interface StageMeta {
_hint: string | undefined
_guidanceUrl: string | undefined
_navigate: string | undefined
_shownSticky: boolean | undefined
hint(this: this, hint: string): this
guidance(this: this, url: string): this
navigate(this: this, path?: string): this
sticky(this: this): this
}
export function withStageMeta<T extends object>(node: T): T & StageMeta {
return Object.assign(node, {
_hint: undefined as string | undefined,
_guidanceUrl: undefined as string | undefined,
_navigate: undefined as string | undefined,
_shownSticky: undefined as boolean | undefined,
hint(this: StageMeta, hint: string) {
this._hint = hint
return this
},
guidance(this: StageMeta, url: string) {
this._guidanceUrl = url
return this
},
navigate(this: StageMeta, path?: string) {
this._navigate = path ?? ''
return this
},
sticky(this: StageMeta) {
this._shownSticky = true
return this
},
})
}
export type OverrideValue = { readonly __override: string }
export type OnChangeFn = (
value: string,
helpers: { override: (value: string) => OverrideValue },
) => OverrideValue | void
export interface Editable {
_placeholder: Reactive<string> | undefined
_onChange: OnChangeFn | undefined
_showTooltip: boolean | undefined
_imperativeSync: boolean | undefined
placeholder(this: this, p: Reactive<string>): this
onChange(this: this, fn: OnChangeFn): this
}
export function withEditable<T extends object>(node: T): T & Editable {
return Object.assign(node, {
_placeholder: undefined as Reactive<string> | undefined,
_onChange: undefined as OnChangeFn | undefined,
_showTooltip: undefined as boolean | undefined,
_imperativeSync: undefined as boolean | undefined,
placeholder(this: Editable, p: Reactive<string>) {
this._placeholder = p
return this
},
onChange(this: Editable, fn: OnChangeFn) {
this._onChange = fn
return this
},
})
}
export interface TweakDef<V = unknown> {
icon: FunctionalComponent<SVGAttributes>
compute: (current: V, state: Record<string, NodeState>) => V | null | undefined
}
export interface Tweakable<V = unknown> {
_tweaks: TweakDef<V>[]
tweak(this: this, icon: FunctionalComponent<SVGAttributes>, compute: TweakDef<V>['compute']): this
}
export function withTweak<T extends HasValue<V>, V>(node: T): T & Tweakable<V> {
return Object.assign(node, {
_tweaks: [] as TweakDef<V>[],
tweak(
this: Tweakable<V>,
icon: FunctionalComponent<SVGAttributes>,
compute: TweakDef<V>['compute'],
) {
this._tweaks.push({ icon: markRaw(icon), compute })
return this
},
})
}
export interface StateOrigin {
_stateOrigin: string[] | undefined
stateOrigin(this: this, path: string[]): this
}
export function withStateOrigin<T extends object>(node: T): T & StateOrigin {
return Object.assign(node, {
_stateOrigin: undefined as string[] | undefined,
stateOrigin(this: StateOrigin, path: string[]) {
this._stateOrigin = path
return this
},
})
}
@@ -0,0 +1,97 @@
import type { ChildNode } from './builder'
import { evalSegment } from './messages'
import { hasCap, isNodeActive, resolveActionState, walkNodes } from './resolve'
import type { MessageSegment, NodeState } from './state'
export interface ActiveAction {
node: object
state: Record<string, NodeState>
statePath: string[]
active: boolean
}
export function collectActiveActions(
children: ChildNode[],
stageState: Record<string, NodeState>,
basePath: string[] = [],
): ActiveAction[] {
const actions: ActiveAction[] = []
walkNodes(
children,
stageState,
(node, nodeState, localState, path) => {
if (!hasCap(node, '_segments')) return
const segments = node._segments as MessageSegment[]
if (segments.length === 0) return
if (!isNodeActive(node, nodeState, localState)) return
actions.push({
node,
state: resolveActionState(node, nodeState, localState),
statePath: path,
active: true,
})
},
basePath,
)
return actions
}
export function collectMessageNodes(
children: ChildNode[],
stageState: Record<string, NodeState>,
basePath: string[] = [],
): ActiveAction[] {
const actions: ActiveAction[] = []
walkNodes(
children,
stageState,
(node, nodeState, localState, path) => {
if (!hasCap(node, '_segments')) return
const segments = node._segments as MessageSegment[]
if (segments.length === 0) return
const active = isNodeActive(node, nodeState, localState)
actions.push({
node,
state: resolveActionState(node, nodeState, localState),
statePath: path,
active,
})
},
basePath,
)
return actions
}
function isDescendant(childPath: string[], ancestorPath: string[]): boolean {
return (
childPath.length > ancestorPath.length && ancestorPath.every((key, i) => childPath[i] === key)
)
}
export async function evalActiveAction(
entry: ActiveAction,
allActions: ActiveAction[],
consumed: Set<object>,
): Promise<string> {
let result = ''
const segments = (entry.node as { _segments: MessageSegment[] })._segments
for (const seg of segments) {
if (seg.type === 'collect') {
let collected = ''
for (const child of allActions) {
if (consumed.has(child.node)) continue
if (!isDescendant(child.statePath, entry.statePath)) continue
consumed.add(child.node)
if (!child.active) continue
collected += await evalActiveAction(child, allActions, consumed)
}
if (!collected.trim() && seg.fallback) {
collected = await evalSegment(seg.fallback, entry.state, entry.statePath)
}
result += collected
} else {
result += await evalSegment(seg, entry.state, entry.statePath)
}
}
return result
}
@@ -0,0 +1,39 @@
<template>
<Button
v-tooltip="tooltip"
:type="color === 'standard' ? 'base' : 'colored'"
:color="color === 'standard' ? undefined : color"
:disabled="disabled"
:aria-label="icon ? label : undefined"
@click="emit('update:modelValue', !modelValue)"
>
<component :is="icon" v-if="icon" />
<template v-else>{{ label }}</template>
</Button>
</template>
<script lang="ts" setup>
import { Button } from '@modrinth/ui'
import type { Component } from 'vue'
import { computed } from 'vue'
const props = defineProps<{
modelValue: boolean
label?: string
icon?: Component
disabled?: boolean
needsAttention?: boolean
fixActionable?: boolean
tooltip?: Record<string, unknown>
}>()
const emit = defineEmits<{
'update:modelValue': [boolean]
}>()
const color = computed(() => {
if (!props.modelValue) return 'standard'
if (props.needsAttention) return 'orange'
return props.fixActionable ? 'blue' : 'brand'
})
</script>
@@ -0,0 +1,401 @@
<script lang="ts" setup>
import { Button, IconButton } from '@modrinth/ui'
import { renderString } from '@modrinth/utils'
import type { Component } from 'vue'
import { computed, inject, watchEffect } from 'vue'
import type { AnyNode, ChildNode, HasChildren } from '../builder'
import type {
ComponentNodePropsContext,
Enableable,
HasValue,
Identified,
OnChangeFn,
TweakDef,
} from '../capabilities'
import { CHECKLIST_META_KEY } from '../context'
import type { Writer } from '../mutate'
import { childWriter, originScope, writeNodeValue } from '../mutate'
import {
getBooleanChildState,
getEffectiveValue,
hasCap,
hasChildrenCap,
hasIdCap,
hasOptionsCap,
hasValueCap,
isNodeActive,
isShown,
resolveChildren,
withStateDefaults,
} from '../resolve'
import type { NodeState, Reactive } from '../state'
import { resolve } from '../state'
import ActionButton from './ActionButton.vue'
const metaCtx = inject(CHECKLIST_META_KEY)
const props = defineProps<{
nodes: ChildNode[]
state: Record<string, NodeState>
write: Writer
onImageUpload?: (file: File) => Promise<string>
flex?: boolean
titleDepth?: number
appComponents?: Record<string, Component>
globalState?: Record<string, Record<string, NodeState>>
}>()
type RenderableValueNode = AnyNode &
HasValue &
Identified &
Partial<Enableable> & {
_component: Component | undefined
_rendererKey: string | undefined
_modelProp: string
_componentProps?: (ctx: ComponentNodePropsContext) => Record<string, unknown>
_extraProps?: (ctx: ComponentNodePropsContext) => Record<string, unknown>
_tweaks?: TweakDef[]
}
function resolveComponent(node: RenderableValueNode): Component | undefined {
return (
node._component ?? (node._rendererKey ? props.appComponents?.[node._rendererKey] : undefined)
)
}
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 getTitle(node: object): string | undefined {
if (!hasCap(node, '_title')) return undefined
const title = node._title as Reactive<string> | undefined
if (title === undefined) return undefined
return resolve(title) || undefined
}
function needsAttention(node: object): boolean {
return metaCtx?.value.attentionMap.get(node) ?? false
}
function isFixActionable(node: object): boolean {
return metaCtx?.value.metaMap.get(node)?.isFixActionable ?? false
}
const wrappedState = computed(() => withStateDefaults(props.state, props.nodes, props.write))
function isEnabled(node: Partial<Enableable>): boolean {
if (node._enabled === undefined) return true
if (typeof node._enabled === 'function') return node._enabled(wrappedState.value)
return resolve(node._enabled)
}
function toggleSetValue(node: RenderableValueNode, value: string): void {
const current = getEffectiveValue(
node,
props.state[node.id],
wrappedState.value,
) as unknown as string[]
const set = new Set(Array.isArray(current) ? current : [])
if (set.has(value)) set.delete(value)
else set.add(value)
writeNodeValue(node, props.state, props.write, Array.from(set) as never, wrappedState.value)
}
const DROPDOWN_TRIGGER_CHROME_PX = 16 * 2 + 10 + 20 + 2
let measureEl: HTMLSpanElement | null = null
function measureLabelWidth(label: string): number {
if (typeof document === 'undefined') return 0
if (!measureEl) {
measureEl = document.createElement('span')
measureEl.className = 'min-w-0 truncate text-primary font-semibold leading-tight'
Object.assign(measureEl.style, {
position: 'absolute',
visibility: 'hidden',
whiteSpace: 'nowrap',
left: '-9999px',
top: '0',
})
document.body.appendChild(measureEl)
}
measureEl.textContent = label
return measureEl.getBoundingClientRect().width
}
const dropdownMinWidthCache = new Map<string, string>()
function getDropdownMinWidth(options: { label: string }[]): string {
const key = options.map((o) => o.label).join(' ')
const cached = dropdownMinWidthCache.get(key)
if (cached) return cached
const maxLabelWidth = Math.max(0, ...options.map((o) => measureLabelWidth(o.label)))
const result = `${Math.ceil(maxLabelWidth) + DROPDOWN_TRIGGER_CHROME_PX}px`
dropdownMinWidthCache.set(key, result)
return result
}
function componentProps(node: RenderableValueNode): Record<string, unknown> {
const ctx: ComponentNodePropsContext = {
onImageUpload: props.onImageUpload,
toggleSetValue: (value) => toggleSetValue(node, value),
nodeFacts: { needsAttention: needsAttention(node), fixActionable: isFixActionable(node) },
tooltip: resolveTooltip(node),
}
const dropdownStyle = hasOptionsCap(node)
? {
class: '!w-auto max-w-full',
style: { minWidth: getDropdownMinWidth(node._options as unknown as { label: string }[]) },
}
: undefined
return {
disabled: !isEnabled(node),
...dropdownStyle,
...node._componentProps?.(ctx),
...node._extraProps?.(ctx),
}
}
function containerScope(node: HasChildren & Partial<Identified>): {
state: Record<string, NodeState>
write: Writer
} {
if (hasCap(node, '_stateOrigin') && node._stateOrigin && props.globalState) {
return originScope(props.globalState, node._stateOrigin as string[])
}
if (!hasIdCap(node)) return { state: props.state, write: props.write }
const raw = props.state[node.id]
const state =
raw && typeof raw === 'object' && !(raw instanceof Set)
? (raw as Record<string, NodeState>)
: {}
return { state, write: childWriter(props.state, props.write, node.id) }
}
function valueScope(node: HasValue & Identified): {
state: Record<string, NodeState>
write: Writer
} {
const state = getBooleanChildState(props.state[node.id])
return { state, write: childWriter(props.state, props.write, node.id) }
}
const TOOLTIP_BASE = {
delay: { show: 500, hide: 0 },
triggers: ['hover', 'focus'],
placement: 'top',
}
function resolveTooltip(node: object): Record<string, unknown> | undefined {
if (hasCap(node, '_tooltip')) {
const t = node._tooltip as
| Reactive<string>
| ((state: Record<string, NodeState>) => string)
| undefined
if (t !== undefined) {
const content = typeof t === 'function' ? t(wrappedState.value) : resolve(t)
if (content) return { ...TOOLTIP_BASE, content }
}
}
const hasSegments = hasCap(node, '_segments')
const html = hasSegments ? metaCtx?.value.tooltipHtml.get(node) : undefined
return html ? { ...TOOLTIP_BASE, content: html, html: true } : undefined
}
function clickButton(node: object): void {
if (!hasCap(node, '_onClick')) return
;(node._onClick as (state: Record<string, NodeState>) => void)?.(wrappedState.value)
}
function tweakCurrent(node: RenderableValueNode): unknown {
return getEffectiveValue(node, props.state[node.id], wrappedState.value)
}
function tweakResult(tweak: TweakDef, node: RenderableValueNode): unknown {
return tweak.compute(tweakCurrent(node), wrappedState.value)
}
function tweakEnabled(tweak: TweakDef, node: RenderableValueNode): boolean {
const result = tweakResult(tweak, node)
return result !== null && result !== undefined && result !== tweakCurrent(node)
}
function tweakTooltip(
tweak: TweakDef,
node: RenderableValueNode,
): Record<string, unknown> | undefined {
if (!tweakEnabled(tweak, node)) return undefined
const content = tweakResult(tweak, node)
return content ? { ...TOOLTIP_BASE, content: String(content) } : undefined
}
function tweakLabel(tweak: TweakDef, node: RenderableValueNode): string {
const result = tweakResult(tweak, node)
return result !== null && result !== undefined ? String(result) : 'Apply suggested value'
}
function applyTweak(tweak: TweakDef, node: RenderableValueNode): void {
const result = tweakResult(tweak, node)
if (result !== null && result !== undefined) {
updateValue(node, result)
}
}
function nodeKey(item: ChildNode, idx: number): string {
return typeof item === 'object' && item !== null && hasIdCap(item) ? item.id : `n-${idx}`
}
function modelProp(item: object): string {
return (item as RenderableValueNode)._modelProp
}
function updateEvent(item: object): string {
return `update:${modelProp(item)}`
}
function updateValue(item: RenderableValueNode, v: unknown): void {
const onChange = hasCap(item, '_onChange')
? (item._onChange as OnChangeFn | undefined)
: undefined
if (onChange) {
const result = onChange(v as string, { override: (ov) => ({ __override: ov }) })
if (result && typeof result === 'object' && '__override' in result) {
writeNodeValue(item, props.state, props.write, result.__override as never, wrappedState.value)
return
}
}
writeNodeValue(item, props.state, props.write, v as never, wrappedState.value)
}
const seenOnChangeValues = new Map<object, unknown>()
watchEffect(() => {
for (const node of props.nodes) {
if (typeof node !== 'object' || node === null) continue
if (!hasCap(node, '_onChange') || !(node as { _onChange: unknown })._onChange) continue
if (!hasValueCap(node) || !hasIdCap(node)) continue
if (!isShown(node as AnyNode)) continue
const value = getEffectiveValue(node, props.state[node.id], wrappedState.value)
if (seenOnChangeValues.has(node) && seenOnChangeValues.get(node) === value) continue
seenOnChangeValues.set(node, value)
const onChange = (node as RenderableValueNode)._onChange as OnChangeFn | undefined
onChange?.(value as never, { override: (ov) => ({ __override: ov }) })
}
})
</script>
<template>
<div :class="[flex ? 'flex flex-wrap gap-2' : 'space-y-4', 'w-full']">
<template v-for="(item, idx) in nodes" :key="nodeKey(item, idx)">
<template v-if="typeof item !== 'object' || item === null">
<template v-if="typeof item === 'string'">{{ item }}</template>
<component :is="item" v-else />
</template>
<template v-else-if="isShown(item)">
<div
:class="
hasChildrenCap(item) && !hasValueCap(item)
? 'w-full'
: !getTitle(item)
? 'contents'
: undefined
"
>
<div v-if="getTitle(item)" class="mb-2" :class="titleClass(titleDepth ?? 0)">
<!-- eslint-disable vue/no-v-html -- title text is author-controlled (stage definitions), not user input -->
<span
v-html="renderString(getTitle(item)!).replace(/^<p>([\s\S]*)<\/p>\n?$/, '$1')"
/><span v-if="needsAttention(item)" class="text-red">*</span>
<!-- eslint-enable vue/no-v-html -->
</div>
<template v-if="hasChildrenCap(item) && !hasValueCap(item)">
<NodeRenderer
:nodes="resolveChildren(item, containerScope(item).state)"
:state="containerScope(item).state"
:write="containerScope(item).write"
:on-image-upload="onImageUpload"
:app-components="appComponents"
:global-state="globalState"
:flex="(item as any)._layout !== 'column'"
:title-depth="getTitle(item) !== undefined ? (titleDepth ?? 0) + 1 : titleDepth"
/>
</template>
<template v-else-if="hasValueCap(item) && hasIdCap(item)">
<component
:is="resolveComponent(item as RenderableValueNode)"
v-if="resolveComponent(item as RenderableValueNode) === ActionButton"
v-bind="componentProps(item as RenderableValueNode)"
:[modelProp(item)]="
getEffectiveValue(item as RenderableValueNode, state[item.id], wrappedState)
"
@[updateEvent(item)]="(v: unknown) => updateValue(item as RenderableValueNode, v)"
/>
<component
:is="resolveComponent(item as RenderableValueNode)"
v-else
v-tooltip="resolveTooltip(item)"
v-bind="componentProps(item as RenderableValueNode)"
:[modelProp(item)]="
getEffectiveValue(item as RenderableValueNode, state[item.id], wrappedState)
"
@[updateEvent(item)]="(v: unknown) => updateValue(item as RenderableValueNode, v)"
/>
<template
v-for="(tweak, tIdx) in (item as RenderableValueNode)._tweaks ?? []"
:key="`tweak-${tIdx}`"
>
<IconButton
v-tooltip="tweakTooltip(tweak, item as RenderableValueNode)"
:label="tweakLabel(tweak, item as RenderableValueNode)"
:disabled="!tweakEnabled(tweak, item as RenderableValueNode)"
@click="applyTweak(tweak, item as RenderableValueNode)"
>
<component :is="tweak.icon" />
</IconButton>
</template>
</template>
<template v-else-if="hasCap(item, '_onClick')">
<Button
v-tooltip="resolveTooltip(item)"
:disabled="!isEnabled(item as any)"
:aria-label="(item as any)._icon ? (item as any).label : undefined"
@click="clickButton(item)"
>
<component :is="(item as any)._icon" v-if="(item as any)._icon" />
<template v-else>{{ (item as any).label }}</template>
</Button>
</template>
</div>
</template>
</template>
<template v-for="(item, idx) in nodes" :key="`children-${nodeKey(item, idx)}`">
<NodeRenderer
v-if="
typeof item === 'object' &&
item !== null &&
isShown(item) &&
hasValueCap(item) &&
hasIdCap(item) &&
hasChildrenCap(item) &&
isNodeActive(item, state[item.id], wrappedState) &&
resolveChildren(item, valueScope(item).state).length
"
:nodes="resolveChildren(item, valueScope(item).state)"
:state="valueScope(item).state"
:write="valueScope(item).write"
:on-image-upload="onImageUpload"
:app-components="appComponents"
:global-state="globalState"
:title-depth="getTitle(item) !== undefined ? (titleDepth ?? 0) + 1 : titleDepth"
class="w-full"
/>
</template>
</div>
</template>
@@ -0,0 +1,11 @@
import type { InjectionKey, Ref } from 'vue'
import type { NodeMeta } from './node-meta'
export interface ChecklistMetaContext {
metaMap: Map<object, NodeMeta>
attentionMap: Map<object, boolean>
tooltipHtml: Map<object, string>
}
export const CHECKLIST_META_KEY: InjectionKey<Ref<ChecklistMetaContext>> = Symbol('checklistMeta')
@@ -0,0 +1,372 @@
import { Checkbox, Combobox, MarkdownEditor, StyledInput, Toggle } from '@modrinth/ui'
import { markRaw } from 'vue'
import { withAutoProps, withChildren } from './builder'
import type { ComponentNodePropsContext, Configurable } from './capabilities'
import {
withComponent,
withEditable,
withEnabled,
withExtraProps,
withFix,
withIcon,
withId,
withLayout,
withMessaging,
withNoneLabel,
withOnClick,
withPriority,
withRequired,
withSelectable,
withShown,
withStageMeta,
withStateOrigin,
withTitle,
withTooltip,
withTweak,
withValue,
} from './capabilities'
import ActionButton from './components/ActionButton.vue'
import { pipe } from './pipe'
import type { NodeState, NodeStateWithChildren } from './state'
function getBooleanValue(raw: NodeState): boolean {
if (typeof raw === 'boolean') return raw
if (raw && typeof raw === 'object' && !(raw instanceof Set)) {
const v = (raw as NodeStateWithChildren).value
if (typeof v === 'boolean') return v
}
return false
}
function setBooleanValue(raw: NodeState, next: boolean, isDefault?: boolean): NodeState {
if (raw && typeof raw === 'object' && !(raw instanceof Set)) {
const { value: _v, ...children } = raw as NodeStateWithChildren & Record<string, NodeState>
if (Object.keys(children).length > 0) return isDefault ? children : { ...children, value: next }
}
return isDefault ? undefined : next
}
export const booleanValue = {
_getValue: getBooleanValue,
_setValue: setBooleanValue,
_isActive: (v: boolean) => v === true,
}
export function toggle(id: string, label: string) {
return pipe(
{ label } as { label: string },
(n) => withId(n, id),
withChildren,
withShown,
withIcon,
withTooltip,
withTitle,
withMessaging,
withPriority,
withRequired,
withFix,
withEnabled,
(n) => withValue(n, booleanValue),
(n) =>
withComponent(n, {
component: markRaw(ActionButton),
componentProps: (ctx) => ({
label: n.label,
icon: n._icon,
needsAttention: ctx.nodeFacts?.needsAttention ?? false,
fixActionable: ctx.nodeFacts?.fixActionable ?? false,
tooltip: ctx.tooltip,
}),
}),
)
}
export function button(label?: string) {
return pipe(
{ label } as { label?: string },
withShown,
withIcon,
withTooltip,
withEnabled,
withOnClick,
)
}
export function check(id: string, label: string) {
return withAutoProps(
pipe(
{ label } as { label: string },
(n) => withId(n, id),
withChildren,
withShown,
withTooltip,
withTitle,
withMessaging,
withPriority,
withRequired,
withFix,
withEnabled,
(n) => withValue(n, booleanValue),
(n) =>
withComponent(n, {
component: markRaw(Checkbox),
componentProps: () => ({ label }),
}),
withExtraProps,
),
)
}
export function toggleSwitch(id: string, label: string) {
return withAutoProps(
pipe(
{ label } as { label: string },
(n) => withId(n, id),
withChildren,
withShown,
withTooltip,
withTitle,
withMessaging,
withPriority,
withRequired,
withFix,
withEnabled,
(n) => withValue(n, booleanValue),
(n) => withComponent(n, { component: markRaw(Toggle) }),
withExtraProps,
),
)
}
export function group(id?: string) {
const base = pipe(
{} as Record<string, never>,
withChildren,
withShown,
withTitle,
withRequired,
withSelectable,
withLayout,
)
return id === undefined ? base : withId(base, id)
}
export type GroupNode = ReturnType<typeof group>
export function externalGroup(path: string[]) {
return pipe({} as Record<string, never>, withChildren, withShown, (n) =>
withStateOrigin(n).stateOrigin(path),
)
}
const optionValue = {
_getValue: () => true,
_setValue: () => undefined,
_isActive: () => true,
}
export function option(value: string, label: string) {
return pipe(
{ value, label } as { value: string; label: string },
withChildren,
withShown,
withMessaging,
withPriority,
(n) => withValue(n, optionValue),
)
}
type OptionNode = ReturnType<typeof option>
interface HasOptions {
_options: OptionNode[]
options(this: this, ...opts: OptionNode[]): this
}
function withOptions<T extends object>(node: T): T & HasOptions {
return Object.assign(node, {
_options: [] as OptionNode[],
options(this: HasOptions, ...opts: OptionNode[]) {
this._options = opts
return this
},
})
}
function getStringValue(raw: NodeState): string {
if (typeof raw === 'string') return raw
if (raw && typeof raw === 'object' && !(raw instanceof Set)) {
const v = (raw as NodeStateWithChildren).value
if (typeof v === 'string') return v
}
return ''
}
function setStringValue(raw: NodeState, next: string, isDefault?: boolean): NodeState {
if (raw && typeof raw === 'object' && !(raw instanceof Set)) {
const { value: _v, ...children } = raw as NodeStateWithChildren & Record<string, NodeState>
if (Object.keys(children).length > 0) return isDefault ? children : { ...children, value: next }
}
return isDefault ? undefined : next
}
const dropdownValue = {
_getValue: getStringValue,
_setValue: setStringValue,
_isActive: (v: string) => v !== '',
}
export function dropdown(id: string) {
return pipe(
{} as Record<string, never>,
(n) => withId(n, id),
withShown,
withTitle,
withRequired,
(n) => withValue(n, dropdownValue),
withNoneLabel,
withOptions,
(n) =>
withComponent(n, {
component: markRaw(Combobox),
componentProps: () => ({
options: [
...(n._none !== undefined ? [{ value: '', label: n._none }] : []),
...n._options.map((o) => ({ value: o.value, label: o.label })),
],
triggerClass:
'!bg-[var(--color-button-bg)] !rounded-[var(--radius-md)] !shadow-[var(--shadow-inset-sm),0_0_0_0_transparent]',
dropdownClass: '!rounded-[var(--radius-md)] !bg-[var(--color-button-bg)] !border-0',
}),
}),
)
}
export function stage(id: string, title: string) {
return pipe(
{ label: title } as { label: string },
(n) => withId(n, id),
withChildren,
withShown,
withIcon,
withStageMeta,
withMessaging,
withPriority,
)
}
export type StageNode = ReturnType<typeof stage>
const stringValue = dropdownValue
export function text(id: string) {
return withAutoProps(
pipe(
{} as Record<string, never>,
(n) => withId(n, id),
withChildren,
withShown,
withTooltip,
withTitle,
withMessaging,
withPriority,
withRequired,
withFix,
withEnabled,
withEditable,
(n) => Object.assign(n, { _showTooltip: true, _imperativeSync: true }),
(n) => withValue(n, stringValue),
withTweak,
(n) =>
withComponent(n, {
component: markRaw(StyledInput),
componentProps: () => ({ class: 'min-w-40 flex-1', autocomplete: 'off' }),
}),
withExtraProps,
),
)
}
export function markdown(id: string) {
return withAutoProps(
pipe(
{} as Record<string, never>,
(n) => withId(n, id),
withChildren,
withShown,
withTooltip,
withTitle,
withMessaging,
withPriority,
withRequired,
withFix,
withEnabled,
withEditable,
(n) => withValue(n, stringValue),
(n) =>
withComponent(n, {
component: markRaw(MarkdownEditor),
componentProps: (ctx) => ({
maxHeight: 300,
disabled: false,
headingButtons: false,
onImageUpload: ctx.onImageUpload,
}),
}),
withExtraProps,
),
)
}
function getSetValue(raw: NodeState): string[] {
return raw instanceof Set ? Array.from(raw) : []
}
function setSetValue(_raw: NodeState, next: string[], isDefault?: boolean): NodeState {
return isDefault || next.length === 0 ? undefined : new Set(next)
}
const setValue = {
_getValue: getSetValue,
_setValue: setSetValue,
_isActive: (v: string[]) => v.length > 0,
}
const stringValueBehavior = {
_getValue: getStringValue,
_setValue: setStringValue,
_isActive: (v: string) => v !== '',
}
export function appComponent(id: string, rendererKey: string) {
const node = pipe(
{} as Record<string, never>,
(n) => withId(n, id),
withChildren,
withShown,
withTooltip,
withTitle,
withMessaging,
withPriority,
withRequired,
withFix,
withEnabled,
(n) => withValue(n, stringValueBehavior),
(n) => withComponent(n, { rendererKey }),
withExtraProps,
)
return Object.assign(node, {
valueKind(this: Configurable, kind: 'boolean' | 'string' | 'set') {
Object.assign(
this,
kind === 'boolean' ? booleanValue : kind === 'set' ? setValue : stringValueBehavior,
)
return this
},
props(this: Configurable, fn: (ctx: ComponentNodePropsContext) => Record<string, unknown>) {
this._extraProps = fn
return this
},
})
}
+58
View File
@@ -0,0 +1,58 @@
import type { Labrinth } from '@modrinth/api-client'
import type { NodeState } from './state'
export class FixBuilder {
_projectFn?: (
patch: Labrinth.Projects.v3.EditProjectRequest,
state: Record<string, NodeState>,
) => void
_versionFn?: (
patch: Labrinth.Versions.v3.ModifyVersionRequest,
state: Record<string, NodeState>,
) => void
project(
fn: (patch: Labrinth.Projects.v3.EditProjectRequest, state: Record<string, NodeState>) => void,
): this {
this._projectFn = fn
return this
}
version(
fn: (
patch: Labrinth.Versions.v3.ModifyVersionRequest,
state: Record<string, NodeState>,
) => void,
): this {
this._versionFn = fn
return this
}
}
export function fix(): FixBuilder {
return new FixBuilder()
}
export function createTrackedPatch<T extends object>(
source: T,
): { proxy: T; changes: () => Partial<T> } {
const written = new Set<string | symbol>()
const data = { ...source }
const proxy = new Proxy(data, {
set(target, key, value) {
if (value !== (source as Record<string | symbol, unknown>)[key]) {
written.add(key)
} else {
written.delete(key)
}
;(target as Record<string | symbol, unknown>)[key] = value
return true
},
})
return {
proxy,
changes: () =>
Object.fromEntries([...written].map((k) => [k, data[k as keyof T]])) as Partial<T>,
}
}
@@ -0,0 +1,12 @@
export * from './builder'
export * from './capabilities'
export * from './collect'
export * from './context'
export * from './factories'
export * from './fix'
export * from './messages'
export * from './mutate'
export * from './node-meta'
export * from './pipe'
export * from './resolve'
export * from './state'
@@ -0,0 +1,145 @@
import type { Labrinth } from '@modrinth/api-client'
import type { Ref } from 'vue'
import {
expandVariables,
flattenProjectV3Variables,
flattenProjectVariables,
flattenStaticVariables,
} from '../../utils'
import type { GetVarsFn, MessageSegment, NodeState } from './state'
const messageFiles = import.meta.glob('../../data/messages/**/*.md', {
query: '?raw',
import: 'default',
})
export type MessageFn = ((state: Record<string, NodeState>) => Promise<string>) & {
concat(...others: MessageFn[]): MessageFn
}
function makeMessageFn(fn: (state: Record<string, NodeState>) => Promise<string>): MessageFn {
const rich = fn as MessageFn
rich.concat = (...others) =>
makeMessageFn(async (state) =>
(await Promise.all([rich, ...others].map((f) => f(state)))).join(''),
)
return rich
}
let _project: Ref<Labrinth.Projects.v3.Project> | null = null
let _projectV2: Ref<Labrinth.Projects.v2.Project> | null = null
let _onMissingMd: ((path: string) => void) | null = null
export function setMissingMdHandler(handler: (path: string) => void) {
_onMissingMd = handler
}
export function setMessageProject(
project: Ref<Labrinth.Projects.v3.Project>,
projectV2: Ref<Labrinth.Projects.v2.Project>,
) {
_project = project
_projectV2 = projectV2
}
export function mdEscape(text: string): string {
return text.replace(/[\\*_`[~]/g, '\\$&')
}
const USER_CONTENT_KEYS = [
'PROJECT_TITLE',
'PROJECT_SLUG',
'PROJECT_SUMMARY',
'PROJECT_TYPE',
'PROJECT_STATUS',
]
export async function loadMd(
path: string,
state: Record<string, NodeState>,
project: Labrinth.Projects.v3.Project,
projectV2: Labrinth.Projects.v2.Project,
getVars?: GetVarsFn,
): Promise<string> {
const extraVars = getVars ? getVars(state) : null
const loader = messageFiles[`../../data/messages/${path}.md`]
if (!loader) {
_onMissingMd?.(path)
return ''
}
const raw = (await loader()) as string
const vars: Record<string, string> = {
...flattenStaticVariables(),
...flattenProjectVariables(projectV2),
...flattenProjectV3Variables(project),
}
for (const key of USER_CONTENT_KEYS) {
if (key in vars) vars[key] = mdEscape(vars[key])
}
if (extraVars) {
for (const [k, v] of Object.entries(extraVars)) {
vars[k] = String(v ?? '')
}
}
const expanded = expandVariables(raw, projectV2, project, vars)
return expanded.replace(/`[^`\n]*`/g, (match) => match.replace(/\\([\\*_`[~])/g, '$1'))
}
export function mdOptional(path: string, getVars?: GetVarsFn): MessageFn {
return makeMessageFn(async (state) => {
const loader = messageFiles[`../../data/messages/${path}.md`]
if (!loader) return ''
return loadMd(path, state, _project!.value, _projectV2!.value, getVars)
})
}
export function md(
path: string | ((state: Record<string, NodeState>) => string),
getVars?: GetVarsFn,
): MessageFn {
return makeMessageFn(async (state) => {
const resolvedPath = typeof path === 'function' ? path(state) : path
return loadMd(resolvedPath, state, _project!.value, _projectV2!.value, getVars)
})
}
export function resolveRelativeMessagePath(
messagePath: string | (() => string),
statePath: string[],
): string {
const name = typeof messagePath === 'function' ? messagePath() : messagePath
if (name.startsWith('/')) return `checklist/messages${name}`
const parts = [...statePath.slice(0, -1), ...name.split('/')]
const normalized = parts.reduce<string[]>((acc, p) => {
if (p === '..') acc.pop()
else if (p) acc.push(p)
return acc
}, [])
return `checklist/messages/${normalized.join('/')}`
}
export async function evalSegment(
seg: MessageSegment,
state: Record<string, NodeState>,
statePath: string[],
): Promise<string> {
if (seg.type === 'collect') return ''
if (seg.type === 'fn') return String((await seg.fn(state)) ?? '')
if (seg.type === 'auto') {
return loadMd(
`checklist/messages/${statePath.join('/')}`,
state,
_project!.value,
_projectV2!.value,
seg.getVars,
)
}
return loadMd(
resolveRelativeMessagePath(seg.path, statePath),
state,
_project!.value,
_projectV2!.value,
seg.getVars,
)
}
@@ -0,0 +1,64 @@
import type { HasValue, Identified } from './capabilities'
import { getEffectiveValue } from './resolve'
import type { NodeState } from './state'
function valuesEqual(a: unknown, b: unknown): boolean {
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false
const setB = new Set(b)
return a.every((v) => setB.has(v))
}
return a === b
}
export type Writer = (id: string, value: NodeState) => void
export function childWriter(
parentRead: Record<string, NodeState>,
parentWrite: Writer,
containerId: string,
): Writer {
return (childId, value) => {
const existing = parentRead[containerId]
const container: Record<string, NodeState> =
existing && typeof existing === 'object' && !(existing instanceof Set)
? { ...(existing as Record<string, NodeState>) }
: existing !== undefined
? { value: existing }
: {}
if (value === undefined) Reflect.deleteProperty(container, childId)
else container[childId] = value
parentWrite(containerId, Object.keys(container).length === 0 ? undefined : container)
}
}
export function writeNodeValue<V>(
node: HasValue<V> & Identified,
read: Record<string, NodeState>,
write: Writer,
next: V,
contextState: Record<string, NodeState> = read,
): void {
const isDefault = valuesEqual(next, getEffectiveValue(node, undefined, contextState))
write(node.id, node._setValue(read[node.id], next, isDefault))
}
export function originScope(
root: Record<string, NodeState>,
path: string[],
): { state: Record<string, NodeState>; write: Writer } {
let state = root
let write: Writer = (id, value) => {
if (value === undefined) Reflect.deleteProperty(root, id)
else root[id] = value
}
for (const segment of path) {
write = childWriter(state, write, segment)
const raw = state[segment]
state =
raw && typeof raw === 'object' && !(raw instanceof Set)
? (raw as Record<string, NodeState>)
: {}
}
return { state, write }
}
@@ -0,0 +1,129 @@
import type { AnyNode, ChildNode, HasChildren } from './builder'
import type { FixBuilder } from './fix'
import {
getBooleanChildState,
hasCap,
hasChildrenCap,
hasIdCap,
hasOptionsCap,
hasValueCap,
isNodeActive,
isShown,
resolveActionState,
resolveChildren,
walkNodes,
} from './resolve'
import type { NodeState } from './state'
export interface NodeMeta {
hasRequiredMissing: boolean
isFixActionable: boolean
}
export function computeNodeMeta(
children: ChildNode[],
stageState: Record<string, NodeState>,
isFixActionable: (fixes: FixBuilder[], state: Record<string, NodeState>) => boolean,
): Map<object, NodeMeta> {
const map = new Map<object, NodeMeta>()
walkNodes(children, stageState, (node, nodeState, localState) => {
const active = isNodeActive(node, nodeState, localState)
const required = hasCap(node, '_required') && node._required === true
const hasRequiredMissing = required && !active
let fixActionable = false
if (active && hasCap(node, '_fixes')) {
const fixes = node._fixes as FixBuilder[]
if (fixes.length > 0) {
fixActionable = isFixActionable(fixes, resolveActionState(node, nodeState, localState))
}
}
map.set(node, { hasRequiredMissing, isFixActionable: fixActionable })
})
return map
}
export function computeAttentionMap(
nodes: ChildNode[],
stageState: Record<string, NodeState>,
metaMap: Map<object, NodeMeta>,
attention: Map<object, boolean> = new Map(),
): Map<object, boolean> {
for (const node of nodes) {
if (typeof node !== 'object' || node === null) continue
if (!isShown(node as AnyNode)) continue
if (hasChildrenCap(node) && !hasValueCap(node)) {
const childState = childScopeFor(node, stageState)
computeAttentionMap(resolveChildren(node, childState), childState, metaMap, attention)
const childrenNeedAttention = someChildNeedsAttention(node, childState, attention)
const ownRequired = hasCap(node, '_required') && node._required === true
const unsatisfied = ownRequired && !hasActiveChild(node, childState)
attention.set(node, unsatisfied || childrenNeedAttention)
continue
}
if (hasValueCap(node) && hasIdCap(node)) {
const rawNodeState = stageState[node.id]
const active = isNodeActive(node, rawNodeState, stageState)
let childrenNeedAttention = false
if (active) {
if (hasOptionsCap(node)) {
const selectedValue = typeof rawNodeState === 'string' ? rawNodeState : undefined
const selected = node._options.find((o) => o.value === selectedValue)
if (selected && isShown(selected) && hasChildrenCap(selected)) {
computeAttentionMap(
resolveChildren(selected, stageState),
stageState,
metaMap,
attention,
)
childrenNeedAttention = someChildNeedsAttention(selected, stageState, attention)
}
} else if (hasChildrenCap(node)) {
const childState = getBooleanChildState(rawNodeState)
computeAttentionMap(resolveChildren(node, childState), childState, metaMap, attention)
childrenNeedAttention = someChildNeedsAttention(node, childState, attention)
}
}
const ownMissing = metaMap.get(node)?.hasRequiredMissing ?? false
attention.set(node, ownMissing || childrenNeedAttention)
}
}
return attention
}
function childScopeFor(
node: HasChildren & { id?: string },
state: Record<string, NodeState>,
): Record<string, NodeState> {
if (!hasIdCap(node)) return state
const raw = state[node.id]
return raw && typeof raw === 'object' && !(raw instanceof Set)
? (raw as Record<string, NodeState>)
: {}
}
function hasActiveChild(node: HasChildren, childState: Record<string, NodeState>): boolean {
return resolveChildren(node, childState).some((child) => {
if (typeof child !== 'object' || child === null) return false
const raw = hasIdCap(child) ? childState[child.id] : undefined
return isNodeActive(child, raw, childState)
})
}
function someChildNeedsAttention(
node: HasChildren,
childState: Record<string, NodeState>,
attention: Map<object, boolean>,
): boolean {
return resolveChildren(node, childState).some(
(child) => typeof child === 'object' && child !== null && attention.get(child) === true,
)
}
@@ -0,0 +1,29 @@
export function pipe<A>(a: A): A
export function pipe<A, B>(a: A, f1: (a: A) => B,): B
export function pipe<A, B, C>(a: A, f1: (a: A) => B, f2: (b: B) => C,): C
export function pipe<A, B, C, D>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D,): D
export function pipe<A, B, C, D, E>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E,): E
export function pipe<A, B, C, D, E, F>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F,): F
export function pipe<A, B, C, D, E, F, G>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G,): G
export function pipe<A, B, C, D, E, F, G, H>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H,): H
export function pipe<A, B, C, D, E, F, G, H, I>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I,): I
export function pipe<A, B, C, D, E, F, G, H, I, J>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J,): J
export function pipe<A, B, C, D, E, F, G, H, I, J, K>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K,): K
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L,): L
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L, M>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L, f12: (l: L) => M,): M
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L, M, N>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L, f12: (l: L) => M, f13: (m: M) => N,): N
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L, M, N, O>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L, f12: (l: L) => M, f13: (m: M) => N, f14: (n: N) => O,): O
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L, f12: (l: L) => M, f13: (m: M) => N, f14: (n: N) => O, f15: (o: O) => P,): P
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L, f12: (l: L) => M, f13: (m: M) => N, f14: (n: N) => O, f15: (o: O) => P, f16: (p: P) => Q,): Q
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L, f12: (l: L) => M, f13: (m: M) => N, f14: (n: N) => O, f15: (o: O) => P, f16: (p: P) => Q, f17: (q: Q) => R,): R
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L, f12: (l: L) => M, f13: (m: M) => N, f14: (n: N) => O, f15: (o: O) => P, f16: (p: P) => Q, f17: (q: Q) => R, f18: (r: R) => S,): S
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L, f12: (l: L) => M, f13: (m: M) => N, f14: (n: N) => O, f15: (o: O) => P, f16: (p: P) => Q, f17: (q: Q) => R, f18: (r: R) => S, f19: (s: S) => T,): T
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L, f12: (l: L) => M, f13: (m: M) => N, f14: (n: N) => O, f15: (o: O) => P, f16: (p: P) => Q, f17: (q: Q) => R, f18: (r: R) => S, f19: (s: S) => T, f20: (t: T) => U,): U
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L, f12: (l: L) => M, f13: (m: M) => N, f14: (n: N) => O, f15: (o: O) => P, f16: (p: P) => Q, f17: (q: Q) => R, f18: (r: R) => S, f19: (s: S) => T, f20: (t: T) => U, f21: (u: U) => V,): V
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V, W>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L, f12: (l: L) => M, f13: (m: M) => N, f14: (n: N) => O, f15: (o: O) => P, f16: (p: P) => Q, f17: (q: Q) => R, f18: (r: R) => S, f19: (s: S) => T, f20: (t: T) => U, f21: (u: U) => V, f22: (v: V) => W,): W
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V, W, X>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L, f12: (l: L) => M, f13: (m: M) => N, f14: (n: N) => O, f15: (o: O) => P, f16: (p: P) => Q, f17: (q: Q) => R, f18: (r: R) => S, f19: (s: S) => T, f20: (t: T) => U, f21: (u: U) => V, f22: (v: V) => W, f23: (w: W) => X,): X
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V, W, X, Y>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L, f12: (l: L) => M, f13: (m: M) => N, f14: (n: N) => O, f15: (o: O) => P, f16: (p: P) => Q, f17: (q: Q) => R, f18: (r: R) => S, f19: (s: S) => T, f20: (t: T) => U, f21: (u: U) => V, f22: (v: V) => W, f23: (w: W) => X, f24: (x: X) => Y,): Y
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V, W, X, Y, Z>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L, f12: (l: L) => M, f13: (m: M) => N, f14: (n: N) => O, f15: (o: O) => P, f16: (p: P) => Q, f17: (q: Q) => R, f18: (r: R) => S, f19: (s: S) => T, f20: (t: T) => U, f21: (u: U) => V, f22: (v: V) => W, f23: (w: W) => X, f24: (x: X) => Y, f25: (y: Y) => Z,): Z
export function pipe(a: unknown, ...fns: Array<(x: unknown) => unknown>): unknown {
return fns.reduce((acc, fn) => fn(acc), a)
}
@@ -0,0 +1,325 @@
import type { Ref } from 'vue'
import { toValue } from 'vue'
import type { AnyNode, ChildEntry, ChildNode, HasChildren } from './builder'
import type { HasValue, Identified } from './capabilities'
import type { Writer } from './mutate'
import { childWriter, writeNodeValue } from './mutate'
import type { NodeState } from './state'
import { resolve } from './state'
export function hasCap<K extends string>(node: object, key: K): node is Record<K, unknown> {
return key in node
}
export function hasValueCap(node: object): node is HasValue {
return hasCap(node, '_getValue') && hasCap(node, '_isActive')
}
export function hasChildrenCap(node: object): node is HasChildren {
return hasCap(node, '_children')
}
export function hasOptionsCap(node: object): node is { _options: OptionLike[] } {
return hasCap(node, '_options')
}
export function hasIdCap(node: object): node is Identified {
return hasCap(node, 'id')
}
interface OptionLike extends AnyNode, Partial<HasChildren> {
value: string
_segments?: unknown[]
}
export function isShown(node: AnyNode): boolean {
return node._shown === undefined || resolve(node._shown)
}
export function getEffectiveValue<V>(
node: HasValue<V>,
rawState: NodeState,
contextState: Record<string, NodeState> = {},
): V {
if (rawState !== undefined) return node._getValue(rawState)
if (node._defaultValue !== undefined) {
const def =
typeof node._defaultValue === 'function'
? (node._defaultValue as (state: Record<string, NodeState>) => NodeState)(contextState)
: (node._defaultValue as unknown as NodeState)
return node._getValue(def)
}
return node._getValue(rawState)
}
export function isNodeActive(
node: object,
rawState: NodeState,
contextState: Record<string, NodeState> = {},
): boolean {
if (hasValueCap(node)) return node._isActive(getEffectiveValue(node, rawState, contextState))
return false
}
export function getBooleanChildState(nodeState: NodeState): Record<string, NodeState> {
if (nodeState && typeof nodeState === 'object' && !(nodeState instanceof Set)) {
return nodeState as Record<string, NodeState>
}
return {}
}
export function resolveChildren(node: HasChildren, state: Record<string, NodeState>): ChildNode[] {
const entries: ChildEntry[] = node._childrenFn ? node._childrenFn(state) : node._children
const result: ChildNode[] = []
for (const entry of entries) {
if (entry == null) continue
if (typeof entry === 'string') {
result.push(entry)
continue
}
if (typeof entry === 'function') {
if (entry.length === 0) {
result.push(entry as () => unknown)
continue
}
const fn = entry as (state?: Record<string, NodeState>) => AnyNode | AnyNode[] | null
const resolved = fn(state)
if (resolved == null) continue
if (Array.isArray(resolved)) {
for (const r of resolved) {
if (r != null && isShown(r)) result.push(r)
}
continue
}
if (isShown(resolved)) result.push(resolved)
continue
}
const resolved = toValue(entry as AnyNode | Ref<AnyNode | null>)
if (resolved != null && isShown(resolved)) result.push(resolved)
}
return result
}
function buildChildMap(
children: ChildNode[],
stateForFlattening: Record<string, NodeState>,
): Map<string, object> {
const map = new Map<string, object>()
function collect(nodes: ChildNode[]) {
for (const node of nodes) {
if (typeof node !== 'object' || node === null) continue
if (hasIdCap(node)) {
map.set(node.id, node)
continue
}
if (hasChildrenCap(node)) collect(resolveChildren(node, stateForFlattening))
}
}
collect(children)
return map
}
function optionChildrenFor(node: object, state: Record<string, NodeState>): ChildNode[] {
if (!hasOptionsCap(node) || !hasIdCap(node)) return []
const raw = state[node.id]
const selectedValue = typeof raw === 'string' ? raw : undefined
const selected = node._options.find((o) => o.value === selectedValue)
if (!selected || !hasChildrenCap(selected)) return []
return resolveChildren(selected, state)
}
export function withStateDefaults<T extends Record<string, NodeState>>(
rawState: T,
children: ChildNode[],
write?: Writer,
): T {
if (rawState == null || typeof rawState !== 'object' || rawState instanceof Set) return rawState
const childMap = buildChildMap(children, rawState)
for (const [, node] of childMap) {
for (const c of optionChildrenFor(node, rawState)) {
if (typeof c === 'object' && c !== null && hasIdCap(c)) childMap.set(c.id, c)
}
}
const resolving = new Set<string>()
const proxy = new Proxy(rawState, {
get(target, key, receiver) {
if (typeof key !== 'string') return Reflect.get(target, key, receiver)
const child = childMap.get(key)
let value: NodeState = Reflect.get(target, key, receiver)
if (value === undefined && child && hasValueCap(child) && !resolving.has(key)) {
resolving.add(key)
try {
const effective = getEffectiveValue(child, undefined, proxy)
const grandchildren = hasChildrenCap(child) ? resolveChildren(child, {}) : []
if (grandchildren.length > 0) {
return withStateDefaults(
{ value: effective } as Record<string, NodeState>,
grandchildren,
write && childWriter(target, write, key),
)
}
value = child._setValue(undefined, effective)
} finally {
resolving.delete(key)
}
}
if (
child &&
hasChildrenCap(child) &&
value !== null &&
typeof value === 'object' &&
!(value instanceof Set)
) {
const nested = value as Record<string, NodeState>
return withStateDefaults(
nested,
resolveChildren(child, nested),
write && childWriter(target, write, key),
)
}
if (value === undefined && child && hasChildrenCap(child)) {
const grandchildren = resolveChildren(child, {})
if (grandchildren.length > 0) return emptyScope(grandchildren)
}
return value
},
set(target, key, value, receiver) {
if (typeof key !== 'string') return Reflect.set(target, key, value, receiver)
const child = childMap.get(key)
if (child && hasValueCap(child) && write) {
writeNodeValue(child as HasValue & Identified, target, write, value as never, proxy)
return true
}
return Reflect.set(target, key, value, receiver)
},
}) as T
return proxy
}
export function resolveActionState(
node: object,
nodeState: NodeState,
localState: Record<string, NodeState>,
): Record<string, NodeState> {
if (!hasValueCap(node)) return localState
const childState = getBooleanChildState(nodeState)
const base = hasChildrenCap(node)
? withStateDefaults(childState, resolveChildren(node, childState))
: childState
return new Proxy(base, {
get(target, key, receiver) {
if (key === 'value') return getEffectiveValue(node, nodeState, localState) as NodeState
return Reflect.get(target, key, receiver)
},
})
}
function emptyScope(children: ChildNode[]): Record<string, NodeState> {
const childMap = buildChildMap(children, {})
const resolving = new Set<string>()
const cache = new Map<string, Record<string, NodeState>>()
const proxy: Record<string, NodeState> = new Proxy(
{},
{
get(_target, key) {
if (typeof key !== 'string') return undefined
const child = childMap.get(key)
if (!child) return undefined
if (hasValueCap(child) && !resolving.has(key)) {
resolving.add(key)
try {
const def = getEffectiveValue(child, undefined, proxy)
if (def !== undefined) return child._setValue(undefined, def)
} finally {
resolving.delete(key)
}
}
if (!hasChildrenCap(child)) return undefined
const cached = cache.get(key)
if (cached) return cached
const grandchildren = resolveChildren(child, {})
if (grandchildren.length === 0) return undefined
const nested = emptyScope(grandchildren)
cache.set(key, nested)
return nested
},
},
) as Record<string, NodeState>
return proxy
}
export type Visitor = (
node: object,
nodeState: NodeState,
localState: Record<string, NodeState>,
path: string[],
) => void
export function walkNodes(
nodes: ChildNode[],
stageState: Record<string, NodeState>,
visitor: Visitor,
path: string[] = [],
): void {
for (const node of nodes) {
if (typeof node !== 'object' || node === null) continue
if (!isShown(node as AnyNode)) continue
if (hasChildrenCap(node) && !hasValueCap(node)) {
if (hasIdCap(node)) {
const raw = stageState[node.id]
const childState =
raw && typeof raw === 'object' && !(raw instanceof Set)
? (raw as Record<string, NodeState>)
: {}
walkNodes(resolveChildren(node, childState), childState, visitor, [...path, node.id])
} else {
walkNodes(resolveChildren(node, stageState), stageState, visitor, path)
}
continue
}
if (hasValueCap(node) && hasIdCap(node)) {
const rawNodeState = stageState[node.id]
const nodePath = [...path, node.id]
visitor(node, rawNodeState, stageState, nodePath)
const active = isNodeActive(node, rawNodeState, stageState)
if (!active) continue
if (hasOptionsCap(node)) {
const selectedValue = typeof rawNodeState === 'string' ? rawNodeState : undefined
const selected = node._options.find((o) => o.value === selectedValue)
if (selected && isShown(selected)) {
const selectedPath = [...path, selected.value]
visitor(selected, undefined, stageState, selectedPath)
if (hasChildrenCap(selected)) {
walkNodes(resolveChildren(selected, stageState), stageState, visitor, selectedPath)
}
}
continue
}
if (hasChildrenCap(node)) {
const childState = getBooleanChildState(rawNodeState)
walkNodes(resolveChildren(node, childState), childState, visitor, nodePath)
}
}
}
}
@@ -0,0 +1,33 @@
import type { Ref } from 'vue'
import { toValue } from 'vue'
export type ModerationStatus = 'approved' | 'rejected' | 'flagged'
export type NodeState =
| boolean
| string
| number
| Set<string>
| NodeStateWithChildren
| null
| undefined
export interface NodeStateWithChildren {
value?: NodeState
[childId: string]: NodeState
}
export type Reactive<T> = T | Ref<T>
export function resolve<T>(value: Reactive<T>): T {
return toValue(value as T | Ref<T>)
}
export type GetVarsFn = (state: Record<string, NodeState>) => Record<string, unknown>
export type ContentFn = (state: Record<string, NodeState>) => string | Promise<string>
export type MessageSegment =
| { type: 'fn'; fn: ContentFn }
| { type: 'auto'; getVars?: GetVarsFn }
| { type: 'path'; path: string | (() => string); getVars?: GetVarsFn }
| { type: 'collect'; fallback?: MessageSegment }
+5 -5
View File
@@ -37,14 +37,14 @@ export const setting = {
asString: (data: StringSettingDefinition) => data,
}
export function isValidFor(definitionBase: SettingDefinitionBase<any>, value: any): boolean {
export function isValidFor(
definitionBase: SettingDefinitionBase<unknown>,
value: unknown,
): boolean {
if (value != null) {
// Tried my best with type safety but sadly having the `SettingDefinitions` as the type leads to issues handling types else where...
const definition = definitionBase as SettingDefinitions
if (
definition.type == 'enum' &&
definition.entries.map((entry) => entry.value).includes(value)
) {
if (definition.type == 'enum' && definition.entries.some((entry) => entry.value === value)) {
return true
} else if (definition.type == 'toggle' && typeof value === 'boolean') {
return true
-64
View File
@@ -1,64 +0,0 @@
import type { Labrinth } from '@modrinth/api-client'
import type { FunctionalComponent, SVGAttributes } from 'vue'
import type { Action } from './actions'
/**
* Represents a moderation stage with associated actions and optional navigation logic.
*/
export interface Stage {
/**
* The title of the stage, displayed in the checklist header.
*/
title: string
/**
* The hint for this stage, tells the moderator what to do.
*/
hint: string
/**
* An optional description or additional text for the stage.
*/
text?: (
project: Labrinth.Projects.v2.Project,
projectV3?: Labrinth.Projects.v3.Project,
) => Promise<string>
/**
* Optional id for the stage, used for identification in the checklist. Will be used in the stage list as well instead of the title.
*/
id?: string
/**
* Optional icon for the stage, displayed in the stage list and next to the title.
*/
icon?: FunctionalComponent<SVGAttributes>
/**
* URL to the guidance document for this stage.
*/
guidance_url: string
/**
* An array of actions that can be taken in this stage.
*/
actions: Action[]
/**
* Optional navigation path to redirect the moderator when this stage is shown.
*
* This is relative to the project page. For example, `/settings#side-types` would navigate to `https://modrinth.com/project/:id/settings#side-types`.
*/
navigate?: string
/**
* A function that determines whether this stage should be shown for a given project.
*
* By default, it returns `true`, meaning the stage is always shown.
*/
shouldShow?: (
project: Labrinth.Projects.v2.Project,
projectV3?: Labrinth.Projects.v3.Project,
) => boolean
}
-205
View File
@@ -1,181 +1,5 @@
import type { Labrinth } from '@modrinth/api-client'
import type {
Action,
AdditionalTextInput,
ButtonAction,
ConditionalMessage,
ToggleAction,
} from './types/actions'
export interface ActionState {
selected: boolean
value?: Set<string | number> | number | string | unknown
}
export interface MessagePart {
weight: number
content: string
actionId: string
stageIndex: number
}
export function getActionIdForStage(
action: Action,
stageIndex: number,
actionIndex?: number,
enabledIndex?: number,
): string {
if (action.id) {
return `stage-${stageIndex}-${action.id}`
}
const suffix = enabledIndex !== undefined ? `-enabled-${enabledIndex}` : ''
return `stage-${stageIndex}-action-${actionIndex}${suffix}`
}
export function getActionId(action: Action, currentStage: number, index?: number): string {
return getActionIdForStage(action, currentStage, index)
}
export function getActionKey(
action: Action,
currentStage: number,
visibleActions: Action[],
): string {
const index = visibleActions.indexOf(action)
return `${currentStage}-${index}-${getActionId(action, currentStage)}`
}
export function initializeActionState(action: Action): ActionState {
if (action.type === 'toggle') {
return {
selected: action.defaultChecked || false,
}
} else if (action.type === 'dropdown') {
return {
selected: true,
value: action.defaultOption || 0,
}
} else if (action.type === 'multi-select-chips') {
return {
selected: false,
value: new Set<string | number>(),
}
} else {
return {
selected: false,
}
}
}
export function processMessage(
message: string,
action: Action,
stageIndex: number,
textInputValues: Record<string, string>,
): string {
let processedMessage = message
if (action.relevantExtraInput) {
action.relevantExtraInput.forEach((input, index) => {
if (input.variable) {
const inputKey = `stage-${stageIndex}-${action.id || `action-${index}`}-${index}`
const value = textInputValues[inputKey] || ''
const regex = new RegExp(`%${input.variable}%`, 'g')
processedMessage = processedMessage.replace(regex, value)
}
})
}
return processedMessage
}
export function findMatchingVariant(
variants: ConditionalMessage[],
selectedActionIds: string[],
allValidActionIds?: string[],
currentStageIndex?: number,
): ConditionalMessage | null {
for (const variant of variants) {
const conditions = variant.conditions
const meetsRequired =
!conditions.requiredActions ||
conditions.requiredActions.every((id) => {
let fullId = id
if (currentStageIndex !== undefined && !id.startsWith('stage-')) {
fullId = `stage-${currentStageIndex}-${id}`
}
if (allValidActionIds && !allValidActionIds.includes(fullId)) {
return false
}
return selectedActionIds.includes(fullId)
})
const meetsExcluded =
!conditions.excludedActions ||
!conditions.excludedActions.some((id) => {
let fullId = id
if (currentStageIndex !== undefined && !id.startsWith('stage-')) {
fullId = `stage-${currentStageIndex}-${id}`
}
return selectedActionIds.includes(fullId)
})
if (meetsRequired && meetsExcluded) {
return variant
}
}
return null
}
export async function getActionMessage(
action: ButtonAction | ToggleAction,
selectedActionIds: string[],
allValidActionIds?: string[],
): Promise<string> {
if (action.conditionalMessages && action.conditionalMessages.length > 0) {
const matchingConditional = findMatchingVariant(
action.conditionalMessages,
selectedActionIds,
allValidActionIds,
)
if (matchingConditional) {
return (await matchingConditional.message()) as string
}
}
return (await action.message()) as string
}
export function getVisibleInputs(
action: Action,
actionStates: Record<string, ActionState>,
): AdditionalTextInput[] {
if (!action.relevantExtraInput) return []
const selectedActionIds = Object.entries(actionStates)
.filter(([, state]) => state.selected)
.map(([id]) => id)
return action.relevantExtraInput.filter((input) => {
if (!input.showWhen) return true
const meetsRequired =
!input.showWhen.requiredActions ||
input.showWhen.requiredActions.every((id) => selectedActionIds.includes(id))
const meetsExcluded =
!input.showWhen.excludedActions ||
!input.showWhen.excludedActions.some((id) => selectedActionIds.includes(id))
return meetsRequired && meetsExcluded
})
}
export function expandVariables(
template: string,
project: Labrinth.Projects.v2.Project,
@@ -283,35 +107,6 @@ export function formatProjectTypes(type: string, lower: boolean = false) {
return value
}
export function formatEnvironments(environment: string, lower: boolean = false) {
let value = environment
try {
value = value
.replaceAll('client_only_server_optional', 'Client and server: Optional on server')
.replaceAll('client_and_server', 'Required on both')
.replaceAll('client_only', 'Client-side only')
.replaceAll('server_only_client_optional', 'Client optional')
.replaceAll('dedicated_server_only', 'Dedicated server only')
.replaceAll('server_only', 'Servers and Singleplayer')
.replaceAll('singleplayer_only', 'Singleplayer only')
.replaceAll(
'client_or_server_prefers_both',
'Optional on both, works best when installed on both sides',
)
.replaceAll(
'client_or_server',
'Optional on both, works the same if installed on either side',
)
// This shouldn't come up for this use but yk
.replaceAll('unknown', 'Unknown')
} catch {
return 'No project environment'
}
if (lower === true) value = value.toLowerCase()
return value
}
export function requiresEnvironmentInfo(projectTypes): boolean {
return projectTypes.includes('mod') || projectTypes.includes('modpack')
}
@@ -312,7 +312,7 @@ defineExpose({ open: openMenu, close: closeMenu })
v-if="isOpen"
:id="menuId"
ref="panelElement"
class="fixed isolate z-[9999] flex min-w-48 flex-col gap-1 rounded-[14px] bg-surface-3 p-2 shadow-lg ring-1 ring-surface-5"
class="fixed isolate z-[9999] rounded-[14px] bg-surface-3 shadow-lg ring-1 ring-surface-5"
:style="[panelStyle, { transformOrigin: menuTransformOrigin }]"
role="menu"
:aria-label="props.label"
@@ -326,76 +326,85 @@ defineExpose({ open: openMenu, close: closeMenu })
:data-side="resolvedSide"
:style="anchorStyle"
/>
<template v-for="(option, index) in visibleOptions" :key="option.id ?? `divider-${index}`">
<div v-if="isDivider(option)" role="separator" class="my-1 h-px bg-surface-5" />
<RouterLink
v-else-if="isLink(option) && option.to !== undefined && !option.disabled"
v-tooltip="option.tooltip"
:to="option.to"
:class="menuItemClasses"
:style="getMenuItemStyle(option)"
:data-tone="option.tone && option.tone !== 'default' ? option.tone : undefined"
:data-hover-filled="option.hoverFilled || option.hoverFilledOnly || undefined"
:data-hover-filled-only="option.hoverFilledOnly || undefined"
role="menuitem"
tabindex="-1"
@click="handleLink(option, $event)"
@keydown="handleLinkKeydown(option, $event)"
@focus="selectedIndex = getMenuItems().indexOf($event.currentTarget as HTMLElement)"
<div
data-anchored-scroll-region
class="flex min-w-48 flex-col gap-1 overflow-y-auto p-2"
:style="{ maxHeight: panelStyle.maxHeight }"
>
<template
v-for="(option, index) in visibleOptions"
:key="option.id ?? `divider-${index}`"
>
<slot :name="option.id" :option="option">
<component :is="option.icon" v-if="option.icon" aria-hidden="true" />
{{ option.label }}
</slot>
</RouterLink>
<div v-if="isDivider(option)" role="separator" class="my-1 h-px bg-surface-5" />
<a
v-else-if="isLink(option)"
v-tooltip="option.tooltip"
:href="option.disabled ? undefined : option.href"
:target="option.target"
:rel="option.rel ?? (option.target === '_blank' ? 'noopener noreferrer' : undefined)"
:download="option.download"
:aria-disabled="option.disabled || undefined"
:class="menuItemClasses"
:style="getMenuItemStyle(option)"
:data-tone="option.tone && option.tone !== 'default' ? option.tone : undefined"
:data-hover-filled="option.hoverFilled || option.hoverFilledOnly || undefined"
:data-hover-filled-only="option.hoverFilledOnly || undefined"
role="menuitem"
tabindex="-1"
@click="handleLink(option, $event)"
@keydown="handleLinkKeydown(option, $event)"
@focus="selectedIndex = getMenuItems().indexOf($event.currentTarget as HTMLElement)"
>
<slot :name="option.id" :option="option">
<component :is="option.icon" v-if="option.icon" aria-hidden="true" />
{{ option.label }}
</slot>
</a>
<RouterLink
v-else-if="isLink(option) && option.to !== undefined && !option.disabled"
v-tooltip="option.tooltip"
:to="option.to"
:class="menuItemClasses"
:style="getMenuItemStyle(option)"
:data-tone="option.tone && option.tone !== 'default' ? option.tone : undefined"
:data-hover-filled="option.hoverFilled || option.hoverFilledOnly || undefined"
:data-hover-filled-only="option.hoverFilledOnly || undefined"
role="menuitem"
tabindex="-1"
@click="handleLink(option, $event)"
@keydown="handleLinkKeydown(option, $event)"
@focus="selectedIndex = getMenuItems().indexOf($event.currentTarget as HTMLElement)"
>
<slot :name="option.id" :option="option">
<component :is="option.icon" v-if="option.icon" aria-hidden="true" />
{{ option.label }}
</slot>
</RouterLink>
<button
v-else
v-tooltip="option.tooltip"
type="button"
:aria-disabled="option.disabled || undefined"
:class="menuItemClasses"
:style="getMenuItemStyle(option)"
:data-tone="option.tone && option.tone !== 'default' ? option.tone : undefined"
:data-hover-filled="option.hoverFilled || option.hoverFilledOnly || undefined"
:data-hover-filled-only="option.hoverFilledOnly || undefined"
role="menuitem"
tabindex="-1"
@click="handleAction(option, $event)"
@focus="selectedIndex = getMenuItems().indexOf($event.currentTarget as HTMLElement)"
>
<slot :name="option.id" :option="option">
<component :is="option.icon" v-if="option.icon" aria-hidden="true" />
{{ option.label }}
</slot>
</button>
</template>
<a
v-else-if="isLink(option)"
v-tooltip="option.tooltip"
:href="option.disabled ? undefined : option.href"
:target="option.target"
:rel="option.rel ?? (option.target === '_blank' ? 'noopener noreferrer' : undefined)"
:download="option.download"
:aria-disabled="option.disabled || undefined"
:class="menuItemClasses"
:style="getMenuItemStyle(option)"
:data-tone="option.tone && option.tone !== 'default' ? option.tone : undefined"
:data-hover-filled="option.hoverFilled || option.hoverFilledOnly || undefined"
:data-hover-filled-only="option.hoverFilledOnly || undefined"
role="menuitem"
tabindex="-1"
@click="handleLink(option, $event)"
@keydown="handleLinkKeydown(option, $event)"
@focus="selectedIndex = getMenuItems().indexOf($event.currentTarget as HTMLElement)"
>
<slot :name="option.id" :option="option">
<component :is="option.icon" v-if="option.icon" aria-hidden="true" />
{{ option.label }}
</slot>
</a>
<button
v-else
v-tooltip="option.tooltip"
type="button"
:aria-disabled="option.disabled || undefined"
:class="menuItemClasses"
:style="getMenuItemStyle(option)"
:data-tone="option.tone && option.tone !== 'default' ? option.tone : undefined"
:data-hover-filled="option.hoverFilled || option.hoverFilledOnly || undefined"
:data-hover-filled-only="option.hoverFilledOnly || undefined"
role="menuitem"
tabindex="-1"
@click="handleAction(option, $event)"
@focus="selectedIndex = getMenuItems().indexOf($event.currentTarget as HTMLElement)"
>
<slot :name="option.id" :option="option">
<component :is="option.icon" v-if="option.icon" aria-hidden="true" />
{{ option.label }}
</slot>
</button>
</template>
</div>
</div>
</Transition>
</Teleport>
@@ -128,7 +128,7 @@ defineExpose({ open: openMenu, close: closeMenu })
v-if="isOpen"
:id="panelId"
ref="panelElement"
class="fixed isolate z-[9999] rounded-[14px] bg-surface-3 p-4 text-primary shadow-lg ring-1 ring-surface-5"
class="fixed isolate z-[9999] overflow-y-auto rounded-[14px] bg-surface-3 p-4 text-primary shadow-lg ring-1 ring-surface-5"
:style="panelStyle"
:role="props.panelRole"
:aria-label="props.label"
@@ -474,7 +474,6 @@ import {
import {
type GameVersionTag,
getVersionGroupsForDisplay,
type Version,
type VersionDisplayGroup,
} from '@modrinth/utils'
import { Menu } from 'floating-vue'
@@ -500,15 +499,12 @@ const formatBytes = useFormatBytes()
const MAX_GAME_VERSION_TAGS = 5
const MAX_PLATFORM_TAGS = 3
type VersionWithDisplayUrlEnding = Version & {
type VersionWithDisplayUrlEnding = Labrinth.Versions.v3.Version & {
displayUrlEnding: string
environment?: Labrinth.Projects.v3.Environment
mrpack_loaders?: string[]
}
type DisplayVersion = VersionWithDisplayUrlEnding & {
noModLoader: boolean
files_missing_attribution?: boolean
}
type VersionTableColumn =
@@ -536,7 +532,7 @@ const props = withDefaults(
currentMember?: boolean
loaders: Labrinth.Tags.v2.Loader[]
gameVersions: GameVersionTag[]
versionLink?: (version: Version) => string
versionLink?: (version: Labrinth.Versions.v3.Version) => string
openModal?: () => void
createVersionButtonSecondary?: boolean
}>(),
@@ -0,0 +1,28 @@
<template>
<AutoLink
:to="`/project/${project.slug}`"
:target="newTab ? '_blank' : undefined"
class="group inline-flex min-w-0 max-w-full items-center gap-1"
:style="{ color: `var(--color-${getProjectStatusColor(project.status)})` }"
>
<component :is="getProjectStatusIcon(project.status)" class="size-4 shrink-0" />
<span class="min-w-0 truncate font-medium group-hover:underline">{{ project.name }}</span>
</AutoLink>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { getProjectStatusColor, getProjectStatusIcon } from '../../utils'
import AutoLink from '../base/AutoLink.vue'
withDefaults(
defineProps<{
project: Pick<Labrinth.Projects.v3.Project, 'name' | 'slug' | 'status'>
newTab?: boolean
}>(),
{
newTab: false,
},
)
</script>
@@ -16,4 +16,5 @@ export { default as ProjectSidebarLinks } from './ProjectSidebarLinks.vue'
export { default as ProjectSidebarServerInfo } from './ProjectSidebarServerInfo.vue'
export { default as ProjectSidebarTags } from './ProjectSidebarTags.vue'
export { default as ProjectStatusBadge } from './ProjectStatusBadge.vue'
export { default as ProjectStatusLink } from './ProjectStatusLink.vue'
export { default as TagsOverflow } from './TagsOverflow.vue'
@@ -120,16 +120,17 @@
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { ChevronLeftIcon, FilterIcon, XCircleIcon, XIcon } from '@modrinth/assets'
import type { MultiSelectOption } from '@modrinth/ui'
import { Checkbox, formatLoader, FormattedTag, MultiSelect, TagItem, useVIntl } from '@modrinth/ui'
import type { GameVersionTag, Version } from '@modrinth/utils'
import type { GameVersionTag } from '@modrinth/utils'
import { computed, ref } from 'vue'
import type { LocationQueryValue } from 'vue-router'
import { useRoute } from 'vue-router'
const props = defineProps<{
versions: Version[]
versions: Labrinth.Versions.v3.Version[]
gameVersions: GameVersionTag[]
baseId?: string
}>()
+1 -1
View File
@@ -15,7 +15,7 @@ export interface ProjectPageContext {
allMembers: Ref<Labrinth.Projects.v3.TeamMember[]>
organization: Ref<Labrinth.Projects.v3.Organization | null>
// Lazy version loading (client-side only)
versions: Ref<Labrinth.Versions.v2.Version[] | null>
versions: Ref<Labrinth.Versions.v3.Version[] | null>
versionsLoading: Ref<boolean>
versionsLoaded: Ref<boolean>
// Lazy dependencies loading (client-side only)
+20
View File
@@ -89,6 +89,22 @@ export const PROJECT_STATUS_ICONS: Record<ProjectStatus, Component> = {
unknown: UnknownIcon,
}
// this should probably be abstracted or something idk
export type BadgeColor = 'red' | 'orange' | 'green' | 'blue' | 'purple' | 'gray'
export const PROJECT_STATUS_COLORS: Record<ProjectStatus, BadgeColor> = {
approved: 'blue',
unlisted: 'purple',
withheld: 'red',
private: 'gray',
scheduled: 'orange',
draft: 'gray',
archived: 'gray',
rejected: 'red',
processing: 'orange',
unknown: 'gray',
}
export const DIRECTORY_ICONS: Record<string, Component> = {
config: FolderOpenIcon,
world: FolderOpenIcon,
@@ -124,6 +140,10 @@ export function getProjectStatusIcon(status: ProjectStatus): Component {
return PROJECT_STATUS_ICONS[status] ?? UnknownIcon
}
export function getProjectStatusColor(status: ProjectStatus): BadgeColor {
return PROJECT_STATUS_COLORS[status] ?? `gray`
}
export function getDirectoryIcon(name: string): Component {
return DIRECTORY_ICONS[name.toLowerCase()] ?? DIRECTORY_ICONS._default
}
+40 -14
View File
@@ -4,8 +4,10 @@ import { nextTick, onUnmounted, ref, watch } from 'vue'
export type AnchoredTeleportPlacement =
| 'bottom-start'
| 'bottom-end'
| 'bottom-center'
| 'top-start'
| 'top-end'
| 'top-center'
| 'right-start'
| 'right-end'
| 'left-start'
@@ -37,40 +39,63 @@ export function useAnchoredTeleport(
if (!isOpen.value || !trigger.value || !panel.value) return
const triggerRect = trigger.value.getBoundingClientRect()
const panelWidth = panel.value.offsetWidth
const panelHeight = panel.value.offsetHeight
const scrollRegion =
panel.value.querySelector<HTMLElement>('[data-anchored-scroll-region]') ?? panel.value
const panelRect = { width: panel.value.offsetWidth, height: scrollRegion.scrollHeight }
const offset = distance.value
const alignsEnd = placement.value.endsWith('end')
const alignsCenter = placement.value.endsWith('center')
const isHorizontal = placement.value.startsWith('right') || placement.value.startsWith('left')
let idealTop: number
let idealLeft: number
let maxHeight: number | undefined
if (isHorizontal) {
const prefersRight = placement.value.startsWith('right')
const spaceRight = window.innerWidth - triggerRect.right - viewportPadding
const spaceLeft = triggerRect.left - viewportPadding
const opensRight = prefersRight
? panelWidth + offset <= spaceRight || spaceRight > spaceLeft
: panelWidth + offset > spaceLeft && spaceRight > spaceLeft
? panelRect.width + offset <= spaceRight || spaceRight > spaceLeft
: panelRect.width + offset > spaceLeft && spaceRight > spaceLeft
resolvedSide.value = opensRight ? 'right' : 'left'
idealTop = alignsEnd ? triggerRect.bottom - panelHeight : triggerRect.top
idealLeft = opensRight ? triggerRect.right + offset : triggerRect.left - panelWidth - offset
idealTop = alignsEnd ? triggerRect.bottom - panelRect.height : triggerRect.top
idealLeft = opensRight
? triggerRect.right + offset
: triggerRect.left - panelRect.width - offset
} else {
const prefersTop = placement.value.startsWith('top')
const spaceBelow = window.innerHeight - triggerRect.bottom - viewportPadding
const spaceAbove = triggerRect.top - viewportPadding
const opensAbove = prefersTop
? panelHeight + offset <= spaceAbove || spaceAbove > spaceBelow
: panelHeight + offset > spaceBelow && spaceAbove > spaceBelow
? panelRect.height + offset <= spaceAbove || spaceAbove > spaceBelow
: panelRect.height + offset > spaceBelow && spaceAbove > spaceBelow
resolvedSide.value = opensAbove ? 'top' : 'bottom'
idealTop = opensAbove ? triggerRect.top - panelHeight - offset : triggerRect.bottom + offset
idealLeft = alignsEnd ? triggerRect.right - panelWidth : triggerRect.left
const availableHeight = opensAbove ? spaceAbove : spaceBelow
if (panelRect.height + offset > availableHeight) {
maxHeight = Math.max(0, availableHeight - offset)
}
idealTop = opensAbove
? triggerRect.top - (maxHeight ?? panelRect.height) - offset
: triggerRect.bottom + offset
if (alignsCenter) {
const centered = triggerRect.left + triggerRect.width / 2 - panelRect.width / 2
if (centered < viewportPadding) {
idealLeft = triggerRect.left
} else if (centered + panelRect.width > window.innerWidth - viewportPadding) {
idealLeft = triggerRect.right - panelRect.width
} else {
idealLeft = centered
}
} else {
idealLeft = alignsEnd ? triggerRect.right - panelRect.width : triggerRect.left
}
}
const maxTop = Math.max(viewportPadding, window.innerHeight - panelHeight - viewportPadding)
const maxLeft = Math.max(viewportPadding, window.innerWidth - panelWidth - viewportPadding)
const effectiveHeight = maxHeight ?? panelRect.height
const maxTop = Math.max(viewportPadding, window.innerHeight - effectiveHeight - viewportPadding)
const maxLeft = Math.max(viewportPadding, window.innerWidth - panelRect.width - viewportPadding)
const panelTop = Math.min(Math.max(idealTop, viewportPadding), maxTop)
const panelLeft = Math.min(Math.max(idealLeft, viewportPadding), maxLeft)
@@ -78,18 +103,19 @@ export function useAnchoredTeleport(
top: `${panelTop}px`,
left: `${panelLeft}px`,
visibility: 'visible',
...(maxHeight !== undefined ? { maxHeight: `${maxHeight}px` } : {}),
}
anchorStyle.value = isHorizontal
? {
top: `${Math.min(
Math.max(triggerRect.top + triggerRect.height / 2 - panelTop, anchorPadding),
Math.max(anchorPadding, panelHeight - anchorPadding),
Math.max(anchorPadding, panelRect.height - anchorPadding),
)}px`,
}
: {
left: `${Math.min(
Math.max(triggerRect.left + triggerRect.width / 2 - panelLeft, anchorPadding),
Math.max(anchorPadding, panelWidth - anchorPadding),
Math.max(anchorPadding, panelRect.width - anchorPadding),
)}px`,
}
}
+19 -231
View File
@@ -179,7 +179,7 @@ importers:
devDependencies:
'@eslint/compat':
specifier: ^1.1.1
version: 1.4.1(eslint@9.39.2(jiti@1.21.7))
version: 1.4.1(eslint@9.39.2(jiti@2.6.1))
'@formatjs/cli':
specifier: ^6.2.12
version: 6.12.2(@vue/compiler-core@3.5.27)(vue@3.5.27(typescript@5.9.3))
@@ -188,22 +188,22 @@ importers:
version: link:../../packages/tooling-config
'@nuxt/eslint-config':
specifier: ^0.5.6
version: 0.5.7(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
version: 0.5.7(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
'@taijased/vue-render-tracker':
specifier: ^1.0.7
version: 1.0.7(vue@3.5.27(typescript@5.9.3))
'@vitejs/plugin-vue':
specifier: ^6.0.3
version: 6.0.4(vite@8.0.3(@types/node@24.12.2)(esbuild@0.27.3)(jiti@1.21.7)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2))(vue@3.5.27(typescript@5.9.3))
version: 6.0.4(vite@8.0.3(@types/node@24.12.2)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2))(vue@3.5.27(typescript@5.9.3))
autoprefixer:
specifier: ^10.4.19
version: 10.4.24(postcss@8.5.6)
eslint:
specifier: ^9.9.1
version: 9.39.2(jiti@1.21.7)
version: 9.39.2(jiti@2.6.1)
eslint-plugin-turbo:
specifier: ^2.5.4
version: 2.8.2(eslint@9.39.2(jiti@1.21.7))(turbo@2.8.2)
version: 2.8.2(eslint@9.39.2(jiti@2.6.1))(turbo@2.8.2)
postcss:
specifier: ^8.4.39
version: 8.5.6
@@ -221,7 +221,7 @@ importers:
version: 5.9.3
vite:
specifier: ^8.0.0
version: 8.0.3(@types/node@24.12.2)(esbuild@0.27.3)(jiti@1.21.7)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2)
version: 8.0.3(@types/node@24.12.2)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2)
vue-component-type-helpers:
specifier: ^3.1.8
version: 3.2.4
@@ -564,6 +564,9 @@ importers:
'@modrinth/ui':
specifier: workspace:*
version: link:../ui
typescript:
specifier: ^5.4.5
version: 5.9.3
packages/tooling-config:
dependencies:
@@ -793,7 +796,7 @@ importers:
version: 5.2.4(vite@5.4.21(@types/node@24.12.2)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0))(vue@3.5.27(typescript@5.9.3))
eslint-plugin-storybook:
specifier: ^10.1.10
version: 10.2.4(eslint@9.39.2(jiti@2.6.1))(storybook@10.2.4(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)
version: 10.2.4(eslint@9.39.2(jiti@1.21.7))(storybook@10.2.4(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)
storybook:
specifier: ^10.1.10
version: 10.2.4(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -4774,6 +4777,7 @@ packages:
'@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
deprecated: Potential CWE-502 - Update to 1.3.1 or higher
'@unhead/vue@2.1.2':
resolution: {integrity: sha512-w5yxH/fkkLWAFAOnMSIbvAikNHYn6pgC7zGF/BasXf+K3CO1cYIPFehYAk5jpcsbiNPMc3goyyw1prGLoyD14g==}
@@ -9479,6 +9483,7 @@ packages:
tsconfck@3.1.6:
resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==}
engines: {node: ^18 || >=20}
deprecated: unmaintained
hasBin: true
peerDependencies:
typescript: ^5.0.0
@@ -10174,8 +10179,8 @@ packages:
vue-component-type-helpers@3.2.4:
resolution: {integrity: sha512-05lR16HeZDcDpB23ku5b5f1fBOoHqFnMiKRr2CiEvbG5Ux4Yi0McmQBOET0dR0nxDXosxyVqv67q6CzS3AK8rw==}
vue-component-type-helpers@3.3.8:
resolution: {integrity: sha512-troqCMmQodQDqUqn63NQaFi+CDSclSe7sc8VEBFqf5GFLqmGR2Ph3P2WEC7qwpRVyEWsTi/aAr4vyOe/B1hU3g==}
vue-component-type-helpers@3.3.9:
resolution: {integrity: sha512-3c/UfMe0SqyEfcGTyH7mfshHagJ9QTCbppCb0/uGpHZpFug7+If3GeGZN7I0YheKEExemx3xldQPoO7PQSOLQg==}
vue-confetti-explosion@1.0.2:
resolution: {integrity: sha512-80OboM3/6BItIoZ6DpNcZFqGpF607kjIVc5af56oKgtFmt5yWehvJeoYhkzYlqxrqdBe0Ko4Ie3bWrmLau+dJw==}
@@ -11499,12 +11504,6 @@ snapshots:
'@eslint-community/regexpp@4.12.2': {}
'@eslint/compat@1.4.1(eslint@9.39.2(jiti@1.21.7))':
dependencies:
'@eslint/core': 0.17.0
optionalDependencies:
eslint: 9.39.2(jiti@1.21.7)
'@eslint/compat@1.4.1(eslint@9.39.2(jiti@2.6.1))':
dependencies:
'@eslint/core': 0.17.0
@@ -12273,31 +12272,6 @@ snapshots:
- utf-8-validate
- vue
'@nuxt/eslint-config@0.5.7(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)':
dependencies:
'@eslint/js': 9.39.2
'@nuxt/eslint-plugin': 0.5.7(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
'@stylistic/eslint-plugin': 2.13.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
'@typescript-eslint/eslint-plugin': 8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
'@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
eslint: 9.39.2(jiti@1.21.7)
eslint-config-flat-gitignore: 0.3.0(eslint@9.39.2(jiti@1.21.7))
eslint-flat-config-utils: 0.4.0
eslint-plugin-import-x: 4.16.1(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))
eslint-plugin-jsdoc: 50.8.0(eslint@9.39.2(jiti@1.21.7))
eslint-plugin-regexp: 2.10.0(eslint@9.39.2(jiti@1.21.7))
eslint-plugin-unicorn: 55.0.0(eslint@9.39.2(jiti@1.21.7))
eslint-plugin-vue: 9.33.0(eslint@9.39.2(jiti@1.21.7))
globals: 15.15.0
local-pkg: 0.5.1
pathe: 1.1.2
vue-eslint-parser: 9.4.3(eslint@9.39.2(jiti@1.21.7))
transitivePeerDependencies:
- '@typescript-eslint/utils'
- eslint-import-resolver-node
- supports-color
- typescript
'@nuxt/eslint-config@0.5.7(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@eslint/js': 9.39.2
@@ -12323,15 +12297,6 @@ snapshots:
- supports-color
- typescript
'@nuxt/eslint-plugin@0.5.7(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)':
dependencies:
'@typescript-eslint/types': 8.54.0
'@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
eslint: 9.39.2(jiti@1.21.7)
transitivePeerDependencies:
- supports-color
- typescript
'@nuxt/eslint-plugin@0.5.7(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@typescript-eslint/types': 8.54.0
@@ -13822,22 +13787,10 @@ snapshots:
storybook: 10.2.4(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
type-fest: 2.19.0
vue: 3.5.27(typescript@5.9.3)
vue-component-type-helpers: 3.3.8
vue-component-type-helpers: 3.3.9
'@stripe/stripe-js@7.9.0': {}
'@stylistic/eslint-plugin@2.13.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)':
dependencies:
'@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
eslint: 9.39.2(jiti@1.21.7)
eslint-visitor-keys: 4.2.1
espree: 10.4.0
estraverse: 5.3.0
picomatch: 4.0.4
transitivePeerDependencies:
- supports-color
- typescript
'@stylistic/eslint-plugin@2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
@@ -14252,22 +14205,6 @@ snapshots:
dependencies:
'@types/node': 24.12.2
'@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
'@typescript-eslint/scope-manager': 8.54.0
'@typescript-eslint/type-utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
'@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.54.0
eslint: 9.39.2(jiti@1.21.7)
ignore: 7.0.5
natural-compare: 1.4.0
ts-api-utils: 2.4.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
@@ -14284,18 +14221,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)':
dependencies:
'@typescript-eslint/scope-manager': 8.54.0
'@typescript-eslint/types': 8.54.0
'@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.54.0
debug: 4.4.3
eslint: 9.39.2(jiti@1.21.7)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@typescript-eslint/scope-manager': 8.54.0
@@ -14339,18 +14264,6 @@ snapshots:
dependencies:
typescript: 5.9.3
'@typescript-eslint/type-utils@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)':
dependencies:
'@typescript-eslint/types': 8.54.0
'@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3)
'@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
debug: 4.4.3
eslint: 9.39.2(jiti@1.21.7)
ts-api-utils: 2.4.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/type-utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@typescript-eslint/types': 8.54.0
@@ -14543,12 +14456,6 @@ snapshots:
vite: 7.3.1(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2)
vue: 3.5.27(typescript@5.9.3)
'@vitejs/plugin-vue@6.0.4(vite@8.0.3(@types/node@24.12.2)(esbuild@0.27.3)(jiti@1.21.7)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2))(vue@3.5.27(typescript@5.9.3))':
dependencies:
'@rolldown/pluginutils': 1.0.0-rc.2
vite: 8.0.3(@types/node@24.12.2)(esbuild@0.27.3)(jiti@1.21.7)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2)
vue: 3.5.27(typescript@5.9.3)
'@vitejs/plugin-vue@6.0.4(vite@8.0.3(@types/node@24.12.2)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2))(vue@3.5.27(typescript@5.9.3))':
dependencies:
'@rolldown/pluginutils': 1.0.0-rc.2
@@ -16139,12 +16046,6 @@ snapshots:
escape-string-regexp@5.0.0: {}
eslint-config-flat-gitignore@0.3.0(eslint@9.39.2(jiti@1.21.7)):
dependencies:
'@eslint/compat': 1.4.1(eslint@9.39.2(jiti@1.21.7))
eslint: 9.39.2(jiti@1.21.7)
find-up-simple: 1.0.1
eslint-config-flat-gitignore@0.3.0(eslint@9.39.2(jiti@2.6.1)):
dependencies:
'@eslint/compat': 1.4.1(eslint@9.39.2(jiti@2.6.1))
@@ -16166,23 +16067,6 @@ snapshots:
optionalDependencies:
unrs-resolver: 1.11.1
eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7)):
dependencies:
'@typescript-eslint/types': 8.54.0
comment-parser: 1.4.5
debug: 4.4.3
eslint: 9.39.2(jiti@1.21.7)
eslint-import-context: 0.1.9(unrs-resolver@1.11.1)
is-glob: 4.0.3
minimatch: 10.1.2
semver: 7.7.4
stable-hash-x: 0.2.0
unrs-resolver: 1.11.1
optionalDependencies:
'@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
transitivePeerDependencies:
- supports-color
eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)):
dependencies:
'@typescript-eslint/types': 8.54.0
@@ -16200,22 +16084,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-plugin-jsdoc@50.8.0(eslint@9.39.2(jiti@1.21.7)):
dependencies:
'@es-joy/jsdoccomment': 0.50.2
are-docs-informative: 0.0.2
comment-parser: 1.4.1
debug: 4.4.3
escape-string-regexp: 4.0.0
eslint: 9.39.2(jiti@1.21.7)
espree: 10.4.0
esquery: 1.7.0
parse-imports-exports: 0.2.4
semver: 7.7.4
spdx-expression-parse: 4.0.0
transitivePeerDependencies:
- supports-color
eslint-plugin-jsdoc@50.8.0(eslint@9.39.2(jiti@2.6.1)):
dependencies:
'@es-joy/jsdoccomment': 0.50.2
@@ -16241,17 +16109,6 @@ snapshots:
optionalDependencies:
eslint-config-prettier: 10.1.8(eslint@9.39.2(jiti@2.6.1))
eslint-plugin-regexp@2.10.0(eslint@9.39.2(jiti@1.21.7)):
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@1.21.7))
'@eslint-community/regexpp': 4.12.2
comment-parser: 1.4.5
eslint: 9.39.2(jiti@1.21.7)
jsdoc-type-pratt-parser: 4.8.0
refa: 0.12.1
regexp-ast-analysis: 0.7.1
scslre: 0.3.0
eslint-plugin-regexp@2.10.0(eslint@9.39.2(jiti@2.6.1)):
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1))
@@ -16267,47 +16124,21 @@ snapshots:
dependencies:
eslint: 9.39.2(jiti@2.6.1)
eslint-plugin-storybook@10.2.4(eslint@9.39.2(jiti@2.6.1))(storybook@10.2.4(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3):
eslint-plugin-storybook@10.2.4(eslint@9.39.2(jiti@1.21.7))(storybook@10.2.4(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3):
dependencies:
'@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
eslint: 9.39.2(jiti@2.6.1)
'@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
eslint: 9.39.2(jiti@1.21.7)
storybook: 10.2.4(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
transitivePeerDependencies:
- supports-color
- typescript
eslint-plugin-turbo@2.8.2(eslint@9.39.2(jiti@1.21.7))(turbo@2.8.2):
dependencies:
dotenv: 16.0.3
eslint: 9.39.2(jiti@1.21.7)
turbo: 2.8.2
eslint-plugin-turbo@2.8.2(eslint@9.39.2(jiti@2.6.1))(turbo@2.8.2):
dependencies:
dotenv: 16.0.3
eslint: 9.39.2(jiti@2.6.1)
turbo: 2.8.2
eslint-plugin-unicorn@55.0.0(eslint@9.39.2(jiti@1.21.7)):
dependencies:
'@babel/helper-validator-identifier': 7.28.5
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@1.21.7))
ci-info: 4.4.0
clean-regexp: 1.0.0
core-js-compat: 3.48.0
eslint: 9.39.2(jiti@1.21.7)
esquery: 1.7.0
globals: 15.15.0
indent-string: 4.0.0
is-builtin-module: 3.2.1
jsesc: 3.1.0
pluralize: 8.0.0
read-pkg-up: 7.0.1
regexp-tree: 0.1.27
regjsparser: 0.10.0
semver: 7.7.4
strip-indent: 3.0.0
eslint-plugin-unicorn@55.0.0(eslint@9.39.2(jiti@2.6.1)):
dependencies:
'@babel/helper-validator-identifier': 7.28.5
@@ -16342,20 +16173,6 @@ snapshots:
'@stylistic/eslint-plugin': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
eslint-plugin-vue@9.33.0(eslint@9.39.2(jiti@1.21.7)):
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@1.21.7))
eslint: 9.39.2(jiti@1.21.7)
globals: 13.24.0
natural-compare: 1.4.0
nth-check: 2.1.1
postcss-selector-parser: 6.1.2
semver: 7.7.4
vue-eslint-parser: 9.4.3(eslint@9.39.2(jiti@1.21.7))
xml-name-validator: 4.0.0
transitivePeerDependencies:
- supports-color
eslint-plugin-vue@9.33.0(eslint@9.39.2(jiti@2.6.1)):
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1))
@@ -20888,22 +20705,6 @@ snapshots:
yaml: 2.8.2
optional: true
vite@8.0.3(@types/node@24.12.2)(esbuild@0.27.3)(jiti@1.21.7)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2):
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.4
postcss: 8.5.8
rolldown: 1.0.0-rc.12
tinyglobby: 0.2.15
optionalDependencies:
'@types/node': 24.12.2
esbuild: 0.27.3
fsevents: 2.3.3
jiti: 1.21.7
sass: 1.97.3
terser: 5.46.0
yaml: 2.8.2
vite@8.0.3(@types/node@24.12.2)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2):
dependencies:
lightningcss: 1.32.0
@@ -21040,7 +20841,7 @@ snapshots:
vue-component-type-helpers@3.2.4: {}
vue-component-type-helpers@3.3.8: {}
vue-component-type-helpers@3.3.9: {}
vue-confetti-explosion@1.0.2(vue@3.5.27(typescript@5.9.3)):
dependencies:
@@ -21080,19 +20881,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
vue-eslint-parser@9.4.3(eslint@9.39.2(jiti@1.21.7)):
dependencies:
debug: 4.4.3
eslint: 9.39.2(jiti@1.21.7)
eslint-scope: 7.2.2
eslint-visitor-keys: 3.4.3
espree: 9.6.1
esquery: 1.7.0
lodash: 4.17.23
semver: 7.7.4
transitivePeerDependencies:
- supports-color
vue-eslint-parser@9.4.3(eslint@9.39.2(jiti@2.6.1)):
dependencies:
debug: 4.4.3