mirror of
https://github.com/modrinth/code.git
synced 2026-08-26 01:26:23 +00:00
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:
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -108,6 +108,12 @@
|
||||
@switch-page="goToPage"
|
||||
/>
|
||||
<ConfettiExplosion v-if="visible" />
|
||||
<QueueSummaryModal
|
||||
ref="queueSummaryModal"
|
||||
:completed-ids="moderationQueue.currentQueue.completed"
|
||||
:skipped-ids="moderationQueue.currentQueue.skipped"
|
||||
@review-skipped="reviewSkippedQueue"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
@@ -160,8 +166,11 @@ import { useQuery } from '@tanstack/vue-query'
|
||||
import ConfettiExplosion from 'vue-confetti-explosion'
|
||||
|
||||
import ModerationQueueCard from '~/components/ui/moderation/ModerationQueueCard.vue'
|
||||
import QueueSummaryModal from '~/components/ui/moderation/QueueSummaryModal.vue'
|
||||
import { type ModerationProject, toModerationProjects } from '~/helpers/moderation.ts'
|
||||
import { useModerationQueue } from '~/services/moderation-queue.ts'
|
||||
import { getProjectTypeForUrlShorthand } from '~/helpers/projects.js'
|
||||
import { useModerationQueue } from '~/services/moderation/queue.ts'
|
||||
import { findNextEligibleQueueProject } from '~/services/moderation/queue-eligibility.ts'
|
||||
|
||||
useHead({ title: 'Projects queue - Modrinth' })
|
||||
|
||||
@@ -172,6 +181,8 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const client = injectModrinthClient()
|
||||
|
||||
const queueSummaryModal = ref()
|
||||
|
||||
const visible = ref(false)
|
||||
if (import.meta.client && history && history.state && history.state.confetti) {
|
||||
setTimeout(async () => {
|
||||
@@ -184,6 +195,14 @@ if (import.meta.client && history && history.state && history.state.confetti) {
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
if (import.meta.client && history && history.state && history.state.queueSummary) {
|
||||
setTimeout(async () => {
|
||||
history.state.queueSummary = false
|
||||
await nextTick()
|
||||
queueSummaryModal.value?.show()
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
moderate: {
|
||||
id: 'moderation.moderate',
|
||||
@@ -498,60 +517,36 @@ function goToPage(page: number) {
|
||||
currentPage.value = page
|
||||
}
|
||||
|
||||
function notifySkippedProjects(skippedCount: number) {
|
||||
if (skippedCount <= 0) return
|
||||
addNotification({
|
||||
title: 'Skipped projects',
|
||||
text: `Skipped ${skippedCount} project(s) already moderated or locked by others.`,
|
||||
type: 'info',
|
||||
autoCloseMs: 2000,
|
||||
})
|
||||
}
|
||||
|
||||
async function findFirstEligibleProject(): Promise<string | null> {
|
||||
let skippedCount = 0
|
||||
const candidateIds = [...moderationQueue.currentQueue.items]
|
||||
if (candidateIds.length === 0) return null
|
||||
|
||||
while (moderationQueue.hasItems) {
|
||||
const currentId = moderationQueue.getCurrentProjectId()
|
||||
if (!currentId) return null
|
||||
const next = await findNextEligibleQueueProject(client, moderationQueue, candidateIds)
|
||||
|
||||
const project = projectsById.value.get(currentId)
|
||||
|
||||
if (project && project.project.status !== 'processing') {
|
||||
await moderationQueue.completeCurrentProject(currentId, 'skipped')
|
||||
skippedCount++
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const lockStatus = await moderationQueue.checkLock(currentId)
|
||||
|
||||
if (!lockStatus.locked || lockStatus.expired || lockStatus.is_own_lock) {
|
||||
notifySkippedProjects(skippedCount)
|
||||
return currentId
|
||||
}
|
||||
|
||||
await moderationQueue.completeCurrentProject(currentId, 'skipped')
|
||||
skippedCount++
|
||||
} catch {
|
||||
return currentId
|
||||
}
|
||||
if (!next) {
|
||||
await Promise.all(candidateIds.map((id) => moderationQueue.excludeProject(id)))
|
||||
return null
|
||||
}
|
||||
|
||||
notifySkippedProjects(skippedCount)
|
||||
|
||||
return null
|
||||
await Promise.all(next.excluded.map((id) => moderationQueue.excludeProject(id)))
|
||||
return next.project
|
||||
}
|
||||
|
||||
function getProjectRouteParam(projectId: string): string {
|
||||
return projectsById.value.get(projectId)?.project.slug || projectId
|
||||
}
|
||||
|
||||
function getProjectRouteType(projectId: string): string {
|
||||
const projectType = projectsById.value.get(projectId)?.project.project_types[0]
|
||||
if (!projectType) return 'project'
|
||||
return getProjectTypeForUrlShorthand(projectType, [])
|
||||
}
|
||||
|
||||
async function navigateToModerationProject(projectId: string) {
|
||||
await navigateTo({
|
||||
name: 'type-project',
|
||||
params: {
|
||||
type: 'project',
|
||||
type: getProjectRouteType(projectId),
|
||||
project: getProjectRouteParam(projectId),
|
||||
},
|
||||
state: {
|
||||
@@ -593,12 +588,8 @@ async function moderateAllInFilter() {
|
||||
async function startFromProject(projectId: string) {
|
||||
const allFilteredProjectIds = await getFilteredProjectIds()
|
||||
const projectIndex = allFilteredProjectIds.indexOf(projectId)
|
||||
if (projectIndex === -1) {
|
||||
await moderationQueue.setSingleProject(projectId)
|
||||
} else {
|
||||
const projectIds = allFilteredProjectIds.slice(projectIndex)
|
||||
await moderationQueue.setQueue(projectIds)
|
||||
}
|
||||
const projectIds = projectIndex === -1 ? [projectId] : allFilteredProjectIds.slice(projectIndex)
|
||||
await moderationQueue.setQueue(projectIds)
|
||||
|
||||
const targetProjectId = await findFirstEligibleProject()
|
||||
|
||||
@@ -613,4 +604,21 @@ async function startFromProject(projectId: string) {
|
||||
|
||||
await navigateToModerationProject(targetProjectId)
|
||||
}
|
||||
|
||||
async function reviewSkippedQueue() {
|
||||
await moderationQueue.startSkippedReview()
|
||||
|
||||
const targetProjectId = await findFirstEligibleProject()
|
||||
|
||||
if (!targetProjectId) {
|
||||
addNotification({
|
||||
title: 'No projects available',
|
||||
text: 'All previously skipped projects are already moderated or locked by others.',
|
||||
type: 'warning',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await navigateToModerationProject(targetProjectId)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
export interface SessionChecklistState {
|
||||
visitedStages?: string[]
|
||||
}
|
||||
|
||||
function sessionStorageKey(projectId: string): string {
|
||||
return `moderation-checklist-session:${projectId}`
|
||||
}
|
||||
|
||||
export function getSessionChecklistState(projectId: string): SessionChecklistState {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(sessionStorageKey(projectId))
|
||||
return raw ? JSON.parse(raw) : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export function patchSessionChecklistState(
|
||||
projectId: string,
|
||||
patch: Partial<SessionChecklistState>,
|
||||
): void {
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
sessionStorageKey(projectId),
|
||||
JSON.stringify({ ...getSessionChecklistState(projectId), ...patch }),
|
||||
)
|
||||
} catch {
|
||||
// Shush
|
||||
}
|
||||
}
|
||||
|
||||
export function clearSessionChecklistState(projectId: string): void {
|
||||
try {
|
||||
sessionStorage.removeItem(sessionStorageKey(projectId))
|
||||
} catch {
|
||||
// Shush
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
import type { NodeState } from '@modrinth/moderation'
|
||||
import type { NodeState } from '@modrinth/moderation/src/types/node'
|
||||
|
||||
import { dbDelete, dbGet, dbPut, dbScan } from './moderation-db.ts'
|
||||
import { dbDelete, dbGet, dbPut, dbScan } from './db.ts'
|
||||
|
||||
export interface PersistedChecklistState {
|
||||
savedAt: string
|
||||
@@ -9,6 +9,7 @@ export interface PersistedChecklistState {
|
||||
stage?: string
|
||||
message?: string
|
||||
state?: Record<string, Record<string, NodeState>>
|
||||
activatedStages?: string[]
|
||||
}
|
||||
|
||||
const STORE = 'checklist'
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { AbstractModrinthClient } from '@modrinth/api-client'
|
||||
|
||||
import type { ModerationQueueService } from './queue.ts'
|
||||
|
||||
export interface QueueCandidateCheck {
|
||||
locked: boolean
|
||||
expired?: boolean
|
||||
isOwnLock?: boolean
|
||||
slug?: string
|
||||
projectType?: string
|
||||
status?: string
|
||||
isProcessing: boolean
|
||||
}
|
||||
|
||||
export interface EligibleQueueProject {
|
||||
project: string
|
||||
result: QueueCandidateCheck
|
||||
excluded: string[]
|
||||
}
|
||||
|
||||
const BATCH_SIZE = 5
|
||||
|
||||
export function isEligibleQueueCandidate(result: QueueCandidateCheck | undefined): boolean {
|
||||
if (!result?.isProcessing) return false
|
||||
return !result.locked || !!result.expired || !!result.isOwnLock
|
||||
}
|
||||
|
||||
export async function batchCheckQueueCandidates(
|
||||
client: AbstractModrinthClient,
|
||||
moderationQueue: ModerationQueueService,
|
||||
projectIds: string[],
|
||||
): Promise<Map<string, QueueCandidateCheck>> {
|
||||
const results = new Map<string, QueueCandidateCheck>()
|
||||
|
||||
const projects = await client.labrinth.projects_v3.getMultiple(projectIds).catch(() => [])
|
||||
const projectsById = new Map(projects.map((project) => [project.id, project]))
|
||||
|
||||
const checks = await Promise.allSettled(
|
||||
projectIds.map(async (id) => {
|
||||
const lockResponse = await moderationQueue.checkLock(id)
|
||||
const project = projectsById.get(id) ?? null
|
||||
|
||||
return {
|
||||
id,
|
||||
locked: lockResponse.locked,
|
||||
expired: lockResponse.expired,
|
||||
isOwnLock: lockResponse.is_own_lock,
|
||||
slug: project?.slug,
|
||||
projectType: project?.project_types[0],
|
||||
status: project?.status,
|
||||
isProcessing: project === null ? true : project.status === 'processing',
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
checks.forEach((result, index) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
results.set(result.value.id, result.value)
|
||||
} else {
|
||||
results.set(projectIds[index], { locked: false, isProcessing: true })
|
||||
}
|
||||
})
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
export async function findNextEligibleQueueProject(
|
||||
client: AbstractModrinthClient,
|
||||
moderationQueue: ModerationQueueService,
|
||||
candidateIds: string[],
|
||||
): Promise<EligibleQueueProject | null> {
|
||||
const excluded: string[] = []
|
||||
let checkedCount = 0
|
||||
|
||||
while (checkedCount < candidateIds.length) {
|
||||
const batch = candidateIds.slice(checkedCount, checkedCount + BATCH_SIZE)
|
||||
checkedCount += batch.length
|
||||
|
||||
const results = await batchCheckQueueCandidates(client, moderationQueue, batch)
|
||||
|
||||
for (const id of batch) {
|
||||
const result = results.get(id)
|
||||
if (isEligibleQueueCandidate(result)) {
|
||||
return { project: id, result: result!, excluded: [...excluded] }
|
||||
}
|
||||
excluded.push(id)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
+5
-5
@@ -1,13 +1,13 @@
|
||||
import { dbDelete, dbGet, dbPut } from './moderation-db.ts'
|
||||
import { dbDelete, dbGet, dbPut } from './db.ts'
|
||||
|
||||
export interface PersistedModerationQueueState {
|
||||
version: 1
|
||||
savedAt: string
|
||||
currentQueue: {
|
||||
items: string[]
|
||||
skipped?: string[]
|
||||
total: number
|
||||
completed: number
|
||||
skipped: number
|
||||
completed: string[]
|
||||
lastUpdated: string
|
||||
}
|
||||
isQueueMode: boolean
|
||||
@@ -31,9 +31,9 @@ function isPersistedStateCandidate(value: unknown): value is PersistedModeration
|
||||
const queue = candidate.currentQueue
|
||||
if (!queue || typeof queue !== 'object') return false
|
||||
if (!isStringArray(queue.items)) return false
|
||||
if (queue.skipped !== undefined && !isStringArray(queue.skipped)) return false
|
||||
if (typeof queue.total !== 'number' || Number.isNaN(queue.total)) return false
|
||||
if (typeof queue.completed !== 'number' || Number.isNaN(queue.completed)) return false
|
||||
if (typeof queue.skipped !== 'number' || Number.isNaN(queue.skipped)) return false
|
||||
if (!isStringArray(queue.completed)) return false
|
||||
if (typeof queue.lastUpdated !== 'string') return false
|
||||
|
||||
return true
|
||||
+62
-36
@@ -6,13 +6,13 @@ import {
|
||||
loadQueueState,
|
||||
type PersistedModerationQueueState,
|
||||
saveQueueState,
|
||||
} from './moderation-queue-storage.ts'
|
||||
} from './queue-storage.ts'
|
||||
|
||||
export interface ModerationQueue {
|
||||
items: string[]
|
||||
skipped: string[]
|
||||
total: number
|
||||
completed: number
|
||||
skipped: number
|
||||
completed: string[]
|
||||
lastUpdated: Date
|
||||
}
|
||||
|
||||
@@ -29,11 +29,14 @@ export interface ModerationQueueService {
|
||||
|
||||
queueLength: number
|
||||
hasItems: boolean
|
||||
hasSkipped: boolean
|
||||
progress: number
|
||||
|
||||
setQueue(projectIds: string[]): Promise<void>
|
||||
setSingleProject(projectId: string): Promise<void>
|
||||
completeCurrentProject(projectId: string, status?: 'completed' | 'skipped'): Promise<boolean>
|
||||
completeProject(projectId: string): Promise<boolean>
|
||||
deferProject(projectId: string): Promise<boolean>
|
||||
excludeProject(projectId: string): Promise<boolean>
|
||||
startSkippedReview(): Promise<void>
|
||||
getCurrentProjectId(): string | null
|
||||
resetQueue(): Promise<void>
|
||||
|
||||
@@ -46,31 +49,31 @@ export interface ModerationQueueService {
|
||||
|
||||
const EMPTY_QUEUE: ModerationQueue = {
|
||||
items: [],
|
||||
skipped: [],
|
||||
total: 0,
|
||||
completed: 0,
|
||||
skipped: 0,
|
||||
completed: [],
|
||||
lastUpdated: new Date(),
|
||||
}
|
||||
|
||||
function createEmptyQueue(): ModerationQueue {
|
||||
return { ...EMPTY_QUEUE, lastUpdated: new Date(), items: [] }
|
||||
return { ...EMPTY_QUEUE, lastUpdated: new Date(), items: [], skipped: [], completed: [] }
|
||||
}
|
||||
|
||||
function sanitizeQueue(raw: PersistedModerationQueueState['currentQueue']): ModerationQueue {
|
||||
const lastUpdated = new Date(raw.lastUpdated)
|
||||
const items = raw.items.filter((id): id is string => typeof id === 'string')
|
||||
const completed = Number.isFinite(raw.completed) ? Math.max(Math.trunc(raw.completed), 0) : 0
|
||||
const skipped = Number.isFinite(raw.skipped) ? Math.max(Math.trunc(raw.skipped), 0) : 0
|
||||
const minimumTotal = items.length + completed + skipped
|
||||
const skipped = (raw.skipped ?? []).filter((id): id is string => typeof id === 'string')
|
||||
const completed = (raw.completed ?? []).filter((id): id is string => typeof id === 'string')
|
||||
const minimumTotal = items.length + completed.length
|
||||
const total = Number.isFinite(raw.total)
|
||||
? Math.max(Math.trunc(raw.total), minimumTotal)
|
||||
: minimumTotal
|
||||
|
||||
return {
|
||||
items,
|
||||
skipped,
|
||||
total,
|
||||
completed,
|
||||
skipped,
|
||||
lastUpdated: Number.isNaN(lastUpdated.getTime()) ? new Date() : lastUpdated,
|
||||
}
|
||||
}
|
||||
@@ -84,9 +87,9 @@ function persistedPayload(
|
||||
savedAt: new Date().toISOString(),
|
||||
currentQueue: {
|
||||
items: [...queue.items],
|
||||
skipped: [...queue.skipped],
|
||||
total: queue.total,
|
||||
completed: queue.completed,
|
||||
skipped: queue.skipped,
|
||||
completed: [...queue.completed],
|
||||
lastUpdated: queue.lastUpdated.toISOString(),
|
||||
},
|
||||
isQueueMode,
|
||||
@@ -101,9 +104,10 @@ function createModerationQueueState(client: AbstractModrinthClient = injectModri
|
||||
|
||||
const queueLength = computed(() => currentQueue.value.items.length)
|
||||
const hasItems = computed(() => currentQueue.value.items.length > 0)
|
||||
const hasSkipped = computed(() => currentQueue.value.skipped.length > 0)
|
||||
const progress = computed(() => {
|
||||
if (currentQueue.value.total === 0) return 0
|
||||
return (currentQueue.value.completed + currentQueue.value.skipped) / currentQueue.value.total
|
||||
return (currentQueue.value.total - currentQueue.value.items.length) / currentQueue.value.total
|
||||
})
|
||||
let mutationChain = Promise.resolve()
|
||||
|
||||
@@ -148,42 +152,41 @@ function createModerationQueueState(client: AbstractModrinthClient = injectModri
|
||||
return result
|
||||
}
|
||||
|
||||
function setQueueState(items: string[], mode: boolean) {
|
||||
isQueueMode.value = mode
|
||||
function setQueueState(items: string[]) {
|
||||
isQueueMode.value = true
|
||||
currentQueue.value = {
|
||||
items: [...items],
|
||||
skipped: [],
|
||||
total: items.length,
|
||||
completed: 0,
|
||||
skipped: 0,
|
||||
completed: [],
|
||||
lastUpdated: new Date(),
|
||||
}
|
||||
}
|
||||
|
||||
async function setQueue(projectIds: string[]): Promise<void> {
|
||||
await withMutation(() => {
|
||||
setQueueState(projectIds, true)
|
||||
setQueueState(projectIds)
|
||||
})
|
||||
}
|
||||
|
||||
async function setSingleProject(projectId: string): Promise<void> {
|
||||
await withMutation(() => {
|
||||
setQueueState([projectId], false)
|
||||
})
|
||||
}
|
||||
|
||||
async function completeCurrentProject(
|
||||
projectId: string,
|
||||
status: 'completed' | 'skipped' = 'completed',
|
||||
): Promise<boolean> {
|
||||
async function completeProject(projectId: string): Promise<boolean> {
|
||||
return withMutation(() => {
|
||||
if (!currentQueue.value.items.includes(projectId)) {
|
||||
return currentQueue.value.items.length > 0
|
||||
}
|
||||
|
||||
if (status === 'completed') {
|
||||
currentQueue.value.completed++
|
||||
} else {
|
||||
currentQueue.value.skipped++
|
||||
currentQueue.value.completed = [...currentQueue.value.completed, projectId]
|
||||
currentQueue.value.items = currentQueue.value.items.filter((id) => id !== projectId)
|
||||
currentQueue.value.lastUpdated = new Date()
|
||||
|
||||
return currentQueue.value.items.length > 0
|
||||
})
|
||||
}
|
||||
|
||||
async function excludeProject(projectId: string): Promise<boolean> {
|
||||
return withMutation(() => {
|
||||
if (!currentQueue.value.items.includes(projectId)) {
|
||||
return currentQueue.value.items.length > 0
|
||||
}
|
||||
|
||||
currentQueue.value.items = currentQueue.value.items.filter((id) => id !== projectId)
|
||||
@@ -193,6 +196,26 @@ function createModerationQueueState(client: AbstractModrinthClient = injectModri
|
||||
})
|
||||
}
|
||||
|
||||
async function deferProject(projectId: string): Promise<boolean> {
|
||||
return withMutation(() => {
|
||||
if (!currentQueue.value.items.includes(projectId)) {
|
||||
return currentQueue.value.items.length > 0
|
||||
}
|
||||
|
||||
currentQueue.value.items = currentQueue.value.items.filter((id) => id !== projectId)
|
||||
currentQueue.value.skipped = [...currentQueue.value.skipped, projectId]
|
||||
currentQueue.value.lastUpdated = new Date()
|
||||
|
||||
return currentQueue.value.items.length > 0
|
||||
})
|
||||
}
|
||||
|
||||
async function startSkippedReview(): Promise<void> {
|
||||
await withMutation(() => {
|
||||
setQueueState(currentQueue.value.skipped)
|
||||
})
|
||||
}
|
||||
|
||||
function getCurrentProjectId(): string | null {
|
||||
return currentQueue.value.items[0] || null
|
||||
}
|
||||
@@ -294,11 +317,14 @@ function createModerationQueueState(client: AbstractModrinthClient = injectModri
|
||||
|
||||
queueLength,
|
||||
hasItems,
|
||||
hasSkipped,
|
||||
progress,
|
||||
|
||||
setQueue,
|
||||
setSingleProject,
|
||||
completeCurrentProject,
|
||||
completeProject,
|
||||
deferProject,
|
||||
excludeProject,
|
||||
startSkippedReview,
|
||||
getCurrentProjectId,
|
||||
resetQueue,
|
||||
|
||||
@@ -4,8 +4,8 @@ import type {
|
||||
LockStatusResponse,
|
||||
ModerationQueue,
|
||||
ModerationQueueService,
|
||||
} from '~/services/moderation-queue.ts'
|
||||
import { useModerationQueue } from '~/services/moderation-queue.ts'
|
||||
} from '~/services/moderation/queue.ts'
|
||||
import { useModerationQueue } from '~/services/moderation/queue.ts'
|
||||
|
||||
export type {
|
||||
LockAcquireResponse,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 +1,2 @@
|
||||
src/locales/**
|
||||
src/types/node/pipe.ts
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"devDependencies": {
|
||||
"@formatjs/cli": "^6.2.12",
|
||||
"@modrinth/tooling-config": "workspace:*",
|
||||
"@modrinth/ui": "workspace:*"
|
||||
"@modrinth/ui": "workspace:*",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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.
|
||||
+1
@@ -0,0 +1 @@
|
||||
This project should have the following game version(s) selected: %GAME_VERSIONS%.
|
||||
+5
@@ -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,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),
|
||||
|
||||
@@ -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()),
|
||||
|
||||
+22
-38
@@ -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(),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}>()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+19
-231
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user