mirror of
https://github.com/modrinth/code.git
synced 2026-09-04 22:10:15 +00:00
refactor: clean up checklist structure + node renderer
This commit is contained in:
@@ -1,15 +0,0 @@
|
|||||||
import type { ActiveAction, NodeState } from '@modrinth/moderation/src/types/node'
|
|
||||||
import type { InjectionKey, Ref } from 'vue'
|
|
||||||
|
|
||||||
export interface LiveNode {
|
|
||||||
isActive: boolean
|
|
||||||
isVisible: boolean
|
|
||||||
isFixActionable: boolean
|
|
||||||
messageCount: number
|
|
||||||
fixCount: number
|
|
||||||
hasRequiredMissing: boolean
|
|
||||||
activeActions: ActiveAction[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export const STATE_KEY: InjectionKey<Ref<Record<string, Record<string, NodeState>>>> =
|
|
||||||
Symbol('checklistState')
|
|
||||||
+15
-8
@@ -1,30 +1,37 @@
|
|||||||
<template>
|
<template>
|
||||||
<Button
|
<IconButton
|
||||||
v-tooltip="tooltip"
|
v-if="icon"
|
||||||
:type="color === 'standard' ? 'base' : 'colored'"
|
:type="color === 'standard' ? 'base' : 'colored'"
|
||||||
:color="color === 'standard' ? undefined : color"
|
:color="color === 'standard' ? undefined : color"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:aria-label="icon ? label : undefined"
|
:label="label"
|
||||||
@click="emit('update:modelValue', !modelValue)"
|
@click="emit('update:modelValue', !modelValue)"
|
||||||
>
|
>
|
||||||
<component :is="icon" v-if="icon" />
|
<component :is="icon" aria-hidden="true" />
|
||||||
<template v-else>{{ label }}</template>
|
</IconButton>
|
||||||
|
<Button
|
||||||
|
v-else
|
||||||
|
:type="color === 'standard' ? 'base' : 'colored'"
|
||||||
|
:color="color === 'standard' ? undefined : color"
|
||||||
|
:disabled="disabled"
|
||||||
|
@click="emit('update:modelValue', !modelValue)"
|
||||||
|
>
|
||||||
|
{{ label }}
|
||||||
</Button>
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { Button } from '@modrinth/ui'
|
import { Button, IconButton } from '@modrinth/ui'
|
||||||
import type { Component } from 'vue'
|
import type { Component } from 'vue'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
label?: string
|
label: string
|
||||||
icon?: Component
|
icon?: Component
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
needsAttention?: boolean
|
needsAttention?: boolean
|
||||||
fixActionable?: boolean
|
fixActionable?: boolean
|
||||||
tooltip?: Record<string, unknown>
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
+192
-517
File diff suppressed because it is too large
Load Diff
+31
@@ -0,0 +1,31 @@
|
|||||||
|
const DROPDOWN_TRIGGER_CHROME_PX = 16 * 2 + 10 + 20 + 2
|
||||||
|
const dropdownMinWidthCache = new Map<string, string>()
|
||||||
|
let measureElement: HTMLSpanElement | null = null
|
||||||
|
|
||||||
|
function measureLabelWidth(label: string): number {
|
||||||
|
if (typeof document === 'undefined') return 0
|
||||||
|
if (!measureElement) {
|
||||||
|
measureElement = document.createElement('span')
|
||||||
|
measureElement.className = 'min-w-0 truncate text-primary font-semibold leading-tight'
|
||||||
|
Object.assign(measureElement.style, {
|
||||||
|
position: 'absolute',
|
||||||
|
visibility: 'hidden',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
left: '-9999px',
|
||||||
|
top: '0',
|
||||||
|
})
|
||||||
|
document.body.appendChild(measureElement)
|
||||||
|
}
|
||||||
|
measureElement.textContent = label
|
||||||
|
return measureElement.getBoundingClientRect().width
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDropdownMinWidth(options: { label: string }[]): string {
|
||||||
|
const key = options.map((option) => option.label).join(' ')
|
||||||
|
const cached = dropdownMinWidthCache.get(key)
|
||||||
|
if (cached) return cached
|
||||||
|
const maxLabelWidth = Math.max(0, ...options.map((option) => measureLabelWidth(option.label)))
|
||||||
|
const result = `${Math.ceil(maxLabelWidth) + DROPDOWN_TRIGGER_CHROME_PX}px`
|
||||||
|
dropdownMinWidthCache.set(key, result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
+157
@@ -0,0 +1,157 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { Button, IconButton } from '@modrinth/ui'
|
||||||
|
import { renderString } from '@modrinth/utils'
|
||||||
|
|
||||||
|
import type { NodeRendererProps, RenderableValueNode } from './types'
|
||||||
|
import { useNodeRenderer } from './use-node-renderer'
|
||||||
|
|
||||||
|
defineOptions({ name: 'NodeRenderer' })
|
||||||
|
|
||||||
|
const props = defineProps<NodeRendererProps>()
|
||||||
|
|
||||||
|
const {
|
||||||
|
applyTweak,
|
||||||
|
buttonIcon,
|
||||||
|
buttonLabel,
|
||||||
|
clickButton,
|
||||||
|
childLayout,
|
||||||
|
componentProps,
|
||||||
|
containerScope,
|
||||||
|
getEffectiveValue,
|
||||||
|
getTitle,
|
||||||
|
hasCap,
|
||||||
|
hasChildrenCap,
|
||||||
|
hasIdCap,
|
||||||
|
hasValueCap,
|
||||||
|
isEnabled,
|
||||||
|
isNodeActive,
|
||||||
|
isShown,
|
||||||
|
modelProp,
|
||||||
|
needsAttention,
|
||||||
|
nodeKey,
|
||||||
|
resolveChildren,
|
||||||
|
resolveComponent,
|
||||||
|
resolveTooltip,
|
||||||
|
titleClass,
|
||||||
|
tweakEnabled,
|
||||||
|
tweakLabel,
|
||||||
|
tweakTooltip,
|
||||||
|
updateEvent,
|
||||||
|
updateValue,
|
||||||
|
valueScope,
|
||||||
|
wrappedState,
|
||||||
|
} = useNodeRenderer(props)
|
||||||
|
</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"
|
||||||
|
:meta="meta"
|
||||||
|
:on-image-upload="onImageUpload"
|
||||||
|
:global-state="globalState"
|
||||||
|
:flex="childLayout(item) !== '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-tooltip="resolveTooltip(item)"
|
||||||
|
v-bind="componentProps(item as RenderableValueNode)"
|
||||||
|
:[modelProp(item)]="
|
||||||
|
getEffectiveValue(item as RenderableValueNode, state[item.id], wrappedState)
|
||||||
|
"
|
||||||
|
@[updateEvent(item)]="(value: unknown) => updateValue(item as RenderableValueNode, value)"
|
||||||
|
/>
|
||||||
|
<template
|
||||||
|
v-for="(tweak, tweakIndex) in (item as RenderableValueNode)._tweaks ?? []"
|
||||||
|
:key="`tweak-${tweakIndex}`"
|
||||||
|
>
|
||||||
|
<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" aria-hidden="true" />
|
||||||
|
</IconButton>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="hasCap(item, '_onClick') && buttonIcon(item)">
|
||||||
|
<IconButton
|
||||||
|
v-tooltip="resolveTooltip(item)"
|
||||||
|
:disabled="!isEnabled(item)"
|
||||||
|
:label="buttonLabel(item)"
|
||||||
|
@click="clickButton(item)"
|
||||||
|
>
|
||||||
|
<component :is="buttonIcon(item)" aria-hidden="true" />
|
||||||
|
</IconButton>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="hasCap(item, '_onClick')">
|
||||||
|
<Button
|
||||||
|
v-tooltip="resolveTooltip(item)"
|
||||||
|
:disabled="!isEnabled(item)"
|
||||||
|
@click="clickButton(item)"
|
||||||
|
>
|
||||||
|
{{ buttonLabel(item) }}
|
||||||
|
</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"
|
||||||
|
:meta="meta"
|
||||||
|
:on-image-upload="onImageUpload"
|
||||||
|
:global-state="globalState"
|
||||||
|
:title-depth="getTitle(item) !== undefined ? (titleDepth ?? 0) + 1 : titleDepth"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
+82
@@ -0,0 +1,82 @@
|
|||||||
|
import type { BuiltinRendererKey } from '@modrinth/moderation/src/types/node'
|
||||||
|
import { Checkbox, Combobox, MarkdownEditor, StyledInput, Toggle } from '@modrinth/ui'
|
||||||
|
import type { Component } from 'vue'
|
||||||
|
|
||||||
|
import LoaderPicker from '~/components/ui/create-project-version/components/LoaderPicker.vue'
|
||||||
|
import McVersionPicker from '~/components/ui/create-project-version/components/McVersionPicker.vue'
|
||||||
|
|
||||||
|
import ActionButton from '../action-button.vue'
|
||||||
|
import type { RenderableValueNode, RendererPropsContext } from './types'
|
||||||
|
|
||||||
|
interface RendererDefinition {
|
||||||
|
component: Component
|
||||||
|
props?: (
|
||||||
|
node: RenderableValueNode,
|
||||||
|
context: RendererPropsContext,
|
||||||
|
) => Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
const builtinRenderers = {
|
||||||
|
action: {
|
||||||
|
component: ActionButton,
|
||||||
|
props: (node, context) => ({
|
||||||
|
label: 'label' in node && typeof node.label === 'string' ? node.label : '',
|
||||||
|
icon: '_icon' in node ? node._icon : undefined,
|
||||||
|
needsAttention: context.nodeFacts.needsAttention,
|
||||||
|
fixActionable: context.nodeFacts.fixActionable,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
checkbox: {
|
||||||
|
component: Checkbox,
|
||||||
|
props: (node) => ({
|
||||||
|
label: 'label' in node && typeof node.label === 'string' ? node.label : '',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
toggle: { component: Toggle },
|
||||||
|
dropdown: {
|
||||||
|
component: Combobox,
|
||||||
|
props: (node) => {
|
||||||
|
if (!('_options' in node) || !Array.isArray(node._options)) return {}
|
||||||
|
const options = node._options as Array<{ value: string; label: string }>
|
||||||
|
const none = '_none' in node && typeof node._none === 'string' ? node._none : undefined
|
||||||
|
return {
|
||||||
|
options: [
|
||||||
|
...(none !== undefined ? [{ value: '', label: none }] : []),
|
||||||
|
...options.map((option) => ({
|
||||||
|
value: option.value,
|
||||||
|
label: option.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',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
component: StyledInput,
|
||||||
|
props: () => ({ class: 'min-w-40 flex-1', autocomplete: 'off' }),
|
||||||
|
},
|
||||||
|
markdown: {
|
||||||
|
component: MarkdownEditor,
|
||||||
|
props: (_node, context) => ({
|
||||||
|
maxHeight: 300,
|
||||||
|
disabled: false,
|
||||||
|
headingButtons: false,
|
||||||
|
onImageUpload: context.onImageUpload,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
} satisfies Record<BuiltinRendererKey, RendererDefinition>
|
||||||
|
|
||||||
|
const customRenderers = {
|
||||||
|
'loader-picker': LoaderPicker,
|
||||||
|
'game-version-picker': McVersionPicker,
|
||||||
|
} satisfies Record<string, Component>
|
||||||
|
|
||||||
|
export function resolveNodeRenderer(node: RenderableValueNode): RendererDefinition | undefined {
|
||||||
|
if (node._renderer.type === 'custom') {
|
||||||
|
const component = customRenderers[node._renderer.key as keyof typeof customRenderers]
|
||||||
|
return component ? { component } : undefined
|
||||||
|
}
|
||||||
|
return builtinRenderers[node._renderer.type]
|
||||||
|
}
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
import type {
|
||||||
|
AnyNode,
|
||||||
|
ChildNode,
|
||||||
|
Configurable,
|
||||||
|
Enableable,
|
||||||
|
HasValue,
|
||||||
|
Identified,
|
||||||
|
NodeMeta,
|
||||||
|
NodePropsContext,
|
||||||
|
NodeState,
|
||||||
|
Renderable,
|
||||||
|
Tweakable,
|
||||||
|
Writer,
|
||||||
|
} from '@modrinth/moderation/src/types/node'
|
||||||
|
|
||||||
|
export type RenderableValueNode = AnyNode &
|
||||||
|
HasValue &
|
||||||
|
Identified &
|
||||||
|
Partial<Enableable> &
|
||||||
|
Renderable &
|
||||||
|
Partial<Configurable> &
|
||||||
|
Partial<Tweakable>
|
||||||
|
|
||||||
|
export interface ChecklistMeta {
|
||||||
|
metaMap: Map<object, NodeMeta>
|
||||||
|
attentionMap: Map<object, boolean>
|
||||||
|
tooltipHtml: Map<object, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RendererPropsContext extends NodePropsContext {
|
||||||
|
nodeFacts: { needsAttention: boolean; fixActionable: boolean }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeRendererProps {
|
||||||
|
nodes: ChildNode[]
|
||||||
|
state: Record<string, NodeState>
|
||||||
|
write: Writer
|
||||||
|
meta: ChecklistMeta
|
||||||
|
onImageUpload?: (file: File) => Promise<string>
|
||||||
|
flex?: boolean
|
||||||
|
titleDepth?: number
|
||||||
|
globalState?: Record<string, Record<string, NodeState>>
|
||||||
|
}
|
||||||
+293
@@ -0,0 +1,293 @@
|
|||||||
|
import type {
|
||||||
|
AnyNode,
|
||||||
|
ChildNode,
|
||||||
|
HasChildren,
|
||||||
|
HasValue,
|
||||||
|
Identified,
|
||||||
|
NodePropsContext,
|
||||||
|
NodeState,
|
||||||
|
OnChangeFn,
|
||||||
|
Reactive,
|
||||||
|
TweakDef,
|
||||||
|
Writer,
|
||||||
|
} from '@modrinth/moderation/src/types/node'
|
||||||
|
import {
|
||||||
|
childWriter,
|
||||||
|
getBooleanChildState,
|
||||||
|
getEffectiveValue,
|
||||||
|
hasCap,
|
||||||
|
hasChildrenCap,
|
||||||
|
hasIdCap,
|
||||||
|
hasOptionsCap,
|
||||||
|
hasValueCap,
|
||||||
|
isNodeActive,
|
||||||
|
isShown,
|
||||||
|
originScope,
|
||||||
|
resolve,
|
||||||
|
resolveChildren,
|
||||||
|
writeNodeValue,
|
||||||
|
withStateDefaults,
|
||||||
|
} from '@modrinth/moderation/src/types/node'
|
||||||
|
import type { Component } from 'vue'
|
||||||
|
import { computed, watchEffect } from 'vue'
|
||||||
|
|
||||||
|
import { getDropdownMinWidth } from './dropdown-width'
|
||||||
|
import { resolveNodeRenderer } from './renderers'
|
||||||
|
import type { NodeRendererProps, RenderableValueNode, RendererPropsContext } from './types'
|
||||||
|
|
||||||
|
const TOOLTIP_BASE = {
|
||||||
|
delay: { show: 500, hide: 0 },
|
||||||
|
triggers: ['hover', 'focus'],
|
||||||
|
placement: 'top',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNodeRenderer(props: NodeRendererProps) {
|
||||||
|
const wrappedState = computed(() => withStateDefaults(props.state, props.nodes, props.write))
|
||||||
|
|
||||||
|
function resolveComponent(node: RenderableValueNode): Component | undefined {
|
||||||
|
return resolveNodeRenderer(node)?.component
|
||||||
|
}
|
||||||
|
|
||||||
|
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 props.meta.attentionMap.get(node) ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFixActionable(node: object): boolean {
|
||||||
|
return props.meta.metaMap.get(node)?.isFixActionable ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEnabled(node: object): boolean {
|
||||||
|
if (!hasCap(node, '_enabled') || node._enabled === undefined) return true
|
||||||
|
if (typeof node._enabled === 'function') {
|
||||||
|
return (node._enabled as (state: Record<string, NodeState>) => boolean)(wrappedState.value)
|
||||||
|
}
|
||||||
|
return resolve(node._enabled as Reactive<boolean>)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTooltip(node: object): Record<string, unknown> | undefined {
|
||||||
|
if (hasCap(node, '_tooltip')) {
|
||||||
|
const tooltip = node._tooltip as
|
||||||
|
| Reactive<string>
|
||||||
|
| ((state: Record<string, NodeState>) => string)
|
||||||
|
| undefined
|
||||||
|
if (tooltip !== undefined) {
|
||||||
|
const content =
|
||||||
|
typeof tooltip === 'function' ? tooltip(wrappedState.value) : resolve(tooltip)
|
||||||
|
if (content) return { ...TOOLTIP_BASE, content }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const html = hasCap(node, '_segments') ? props.meta.tooltipHtml.get(node) : undefined
|
||||||
|
return html ? { ...TOOLTIP_BASE, content: html, html: true } : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function componentProps(node: RenderableValueNode): Record<string, unknown> {
|
||||||
|
const context: NodePropsContext = {
|
||||||
|
onImageUpload: props.onImageUpload,
|
||||||
|
toggleSetValue: (value) => toggleSetValue(node, value),
|
||||||
|
}
|
||||||
|
const rendererContext: RendererPropsContext = {
|
||||||
|
...context,
|
||||||
|
nodeFacts: {
|
||||||
|
needsAttention: needsAttention(node),
|
||||||
|
fixActionable: isFixActionable(node),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const dropdownStyle = hasOptionsCap(node)
|
||||||
|
? {
|
||||||
|
class: '!w-auto max-w-full',
|
||||||
|
style: {
|
||||||
|
minWidth: getDropdownMinWidth(
|
||||||
|
node._options as unknown as Array<{ label: string }>,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
return {
|
||||||
|
disabled: !isEnabled(node),
|
||||||
|
...dropdownStyle,
|
||||||
|
...resolveNodeRenderer(node)?.props?.(node, rendererContext),
|
||||||
|
...node._extraProps?.(context),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function clickButton(node: object): void {
|
||||||
|
if (!hasCap(node, '_onClick')) return
|
||||||
|
;(node._onClick as (state: Record<string, NodeState>) => void)?.(wrappedState.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buttonIcon(node: object): Component | undefined {
|
||||||
|
return hasCap(node, '_icon') ? (node._icon as Component | undefined) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function buttonLabel(node: object): string {
|
||||||
|
return hasCap(node, 'label') && typeof node.label === 'string' ? node.label : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function childLayout(node: object): 'flex' | 'column' | undefined {
|
||||||
|
if (!hasCap(node, '_layout')) return undefined
|
||||||
|
return node._layout === 'flex' || node._layout === 'column' ? node._layout : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
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, index: number): string {
|
||||||
|
return typeof item === 'object' && item !== null && hasIdCap(item)
|
||||||
|
? item.id
|
||||||
|
: `n-${index}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function modelProp(item: object): string {
|
||||||
|
return (item as RenderableValueNode)._modelProp
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateEvent(item: object): string {
|
||||||
|
return `update:${modelProp(item)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateValue(item: RenderableValueNode, value: unknown): void {
|
||||||
|
const onChange = hasCap(item, '_onChange')
|
||||||
|
? (item._onChange as OnChangeFn | undefined)
|
||||||
|
: undefined
|
||||||
|
if (onChange) {
|
||||||
|
const result = onChange(value as string, { override: (override) => ({ __override: override }) })
|
||||||
|
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, value 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._onChange) continue
|
||||||
|
if (!hasValueCap(node) || !hasIdCap(node) || !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: (override) => ({ __override: override }) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
applyTweak,
|
||||||
|
buttonIcon,
|
||||||
|
buttonLabel,
|
||||||
|
clickButton,
|
||||||
|
childLayout,
|
||||||
|
componentProps,
|
||||||
|
containerScope,
|
||||||
|
getEffectiveValue,
|
||||||
|
getTitle,
|
||||||
|
hasCap,
|
||||||
|
hasChildrenCap,
|
||||||
|
hasIdCap,
|
||||||
|
hasValueCap,
|
||||||
|
isEnabled,
|
||||||
|
isNodeActive,
|
||||||
|
isShown,
|
||||||
|
modelProp,
|
||||||
|
needsAttention,
|
||||||
|
nodeKey,
|
||||||
|
resolveChildren,
|
||||||
|
resolveComponent,
|
||||||
|
resolveTooltip,
|
||||||
|
titleClass,
|
||||||
|
tweakEnabled,
|
||||||
|
tweakLabel,
|
||||||
|
tweakTooltip,
|
||||||
|
updateEvent,
|
||||||
|
updateValue,
|
||||||
|
valueScope,
|
||||||
|
wrappedState,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { ActiveAction } from '@modrinth/moderation/src/types/node'
|
||||||
|
|
||||||
|
export interface LiveNode {
|
||||||
|
isActive: boolean
|
||||||
|
isVisible: boolean
|
||||||
|
isFixActionable: boolean
|
||||||
|
messageCount: number
|
||||||
|
fixCount: number
|
||||||
|
hasRequiredMissing: boolean
|
||||||
|
activeActions: ActiveAction[]
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import type { AbstractWebNotificationManager } from '@modrinth/ui'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
LockAcquireResponse,
|
||||||
|
ModerationQueueService,
|
||||||
|
} from '~/services/moderation/queue.ts'
|
||||||
|
|
||||||
|
interface ChecklistLockOptions {
|
||||||
|
projectId: string
|
||||||
|
queue: ModerationQueueService
|
||||||
|
addNotification: typeof AbstractWebNotificationManager.prototype.addNotification
|
||||||
|
refreshPrefetchQueue: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChecklistLockStatus {
|
||||||
|
locked: boolean
|
||||||
|
lockedBy?: { id: string; username: string; avatar_url?: string }
|
||||||
|
lockedAt?: Date
|
||||||
|
expiresAt?: Date
|
||||||
|
expired?: boolean
|
||||||
|
isOwnLock: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useChecklistLock({
|
||||||
|
projectId,
|
||||||
|
queue,
|
||||||
|
addNotification,
|
||||||
|
refreshPrefetchQueue,
|
||||||
|
}: ChecklistLockOptions) {
|
||||||
|
const status = ref<ChecklistLockStatus | null>(null)
|
||||||
|
const error = ref(false)
|
||||||
|
const timeRemaining = ref<string | null>(null)
|
||||||
|
let heartbeat: ReturnType<typeof setInterval> | null = null
|
||||||
|
let countdown: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
function clearCountdown() {
|
||||||
|
if (countdown) {
|
||||||
|
clearInterval(countdown)
|
||||||
|
countdown = null
|
||||||
|
}
|
||||||
|
timeRemaining.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCountdown() {
|
||||||
|
if (!status.value?.lockedAt || status.value.isOwnLock) {
|
||||||
|
timeRemaining.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const lockedAt = new Date(status.value.lockedAt)
|
||||||
|
const expiresAt = status.value.expiresAt
|
||||||
|
? new Date(status.value.expiresAt)
|
||||||
|
: new Date(lockedAt.getTime() + 15 * 60 * 1000)
|
||||||
|
const remainingMs = expiresAt.getTime() - Date.now()
|
||||||
|
|
||||||
|
if (remainingMs <= 0) {
|
||||||
|
timeRemaining.value = null
|
||||||
|
status.value.expired = true
|
||||||
|
clearCountdown()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const minutes = Math.floor(remainingMs / 60000)
|
||||||
|
const seconds = Math.floor((remainingMs % 60000) / 1000)
|
||||||
|
timeRemaining.value = `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCountdown() {
|
||||||
|
clearCountdown()
|
||||||
|
updateCountdown()
|
||||||
|
countdown = setInterval(updateCountdown, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLockedBy(result: LockAcquireResponse) {
|
||||||
|
status.value = {
|
||||||
|
locked: result.locked_by != null,
|
||||||
|
lockedBy: result.locked_by,
|
||||||
|
lockedAt: result.locked_at ? new Date(result.locked_at) : undefined,
|
||||||
|
expiresAt: result.expires_at ? new Date(result.expires_at) : undefined,
|
||||||
|
expired: result.expired,
|
||||||
|
isOwnLock: false,
|
||||||
|
}
|
||||||
|
error.value = false
|
||||||
|
if (result.locked_by) startCountdown()
|
||||||
|
else clearCountdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLost(result: LockAcquireResponse) {
|
||||||
|
if (heartbeat) {
|
||||||
|
clearInterval(heartbeat)
|
||||||
|
heartbeat = null
|
||||||
|
}
|
||||||
|
setLockedBy(result)
|
||||||
|
|
||||||
|
if (result.locked_by) {
|
||||||
|
addNotification({
|
||||||
|
title: 'Lock taken over',
|
||||||
|
text: `@${result.locked_by.username} is now moderating this project.`,
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
addNotification({
|
||||||
|
title: 'Moderation lock lost',
|
||||||
|
text: 'Your lock on this project has expired. Acquire the lock again to continue.',
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startHeartbeat() {
|
||||||
|
if (heartbeat) clearInterval(heartbeat)
|
||||||
|
heartbeat = setInterval(async () => {
|
||||||
|
const result = await queue.refreshLock()
|
||||||
|
if (!result.success) handleLost(result)
|
||||||
|
}, 5 * 60 * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAcquired() {
|
||||||
|
status.value = { locked: false, isOwnLock: true }
|
||||||
|
error.value = false
|
||||||
|
clearCountdown()
|
||||||
|
startHeartbeat()
|
||||||
|
refreshPrefetchQueue()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUnavailable() {
|
||||||
|
error.value = true
|
||||||
|
status.value = { locked: false, isOwnLock: false }
|
||||||
|
clearCountdown()
|
||||||
|
addNotification({
|
||||||
|
title: 'Lock unavailable',
|
||||||
|
text: 'Could not acquire moderation lock. Others may also be moderating this project.',
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function acquire() {
|
||||||
|
const result = await queue.acquireLock(projectId)
|
||||||
|
if (result.success) handleAcquired()
|
||||||
|
else if (result.locked_by) setLockedBy(result)
|
||||||
|
else handleUnavailable()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function override() {
|
||||||
|
const result = await queue.overrideLock(projectId)
|
||||||
|
if (result.success) {
|
||||||
|
addNotification({
|
||||||
|
title: 'Moderation lock overridden',
|
||||||
|
text: 'You are now moderating this project.',
|
||||||
|
type: 'success',
|
||||||
|
})
|
||||||
|
handleAcquired()
|
||||||
|
} else if (result.locked_by) {
|
||||||
|
setLockedBy(result)
|
||||||
|
} else {
|
||||||
|
handleUnavailable()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleVisibilityChange() {
|
||||||
|
if (document.visibilityState !== 'visible' || !status.value?.isOwnLock) return
|
||||||
|
const result = await queue.refreshLock()
|
||||||
|
if (!result.success) {
|
||||||
|
handleLost(result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
refreshPrefetchQueue()
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
if (heartbeat) {
|
||||||
|
clearInterval(heartbeat)
|
||||||
|
heartbeat = null
|
||||||
|
}
|
||||||
|
clearCountdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
return { acquire, error, handleVisibilityChange, override, status, stop, timeRemaining }
|
||||||
|
}
|
||||||
+88
@@ -0,0 +1,88 @@
|
|||||||
|
import type { Labrinth } from '@modrinth/api-client'
|
||||||
|
import { expandVariables } from '@modrinth/moderation'
|
||||||
|
import {
|
||||||
|
collectMessageNodes,
|
||||||
|
computeAttentionMap,
|
||||||
|
computeNodeMeta,
|
||||||
|
evalActiveAction,
|
||||||
|
resolveChildren,
|
||||||
|
} from '@modrinth/moderation/src/types/node'
|
||||||
|
import type {
|
||||||
|
FixBuilder,
|
||||||
|
NodeState,
|
||||||
|
StageNode,
|
||||||
|
Writer,
|
||||||
|
} from '@modrinth/moderation/src/types/node'
|
||||||
|
import { renderHighlightedString } from '@modrinth/utils'
|
||||||
|
import type { ComputedRef, Ref } from 'vue'
|
||||||
|
import { computed, ref, watchEffect } from 'vue'
|
||||||
|
|
||||||
|
interface NodeRendererStateOptions {
|
||||||
|
currentStage: ComputedRef<StageNode>
|
||||||
|
nodeStates: Ref<Record<string, Record<string, NodeState>>>
|
||||||
|
project: Ref<Labrinth.Projects.v3.Project>
|
||||||
|
projectV2: Ref<Labrinth.Projects.v2.Project>
|
||||||
|
isFixActionable: (fixes: FixBuilder[], state: Record<string, NodeState>) => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNodeRendererState({
|
||||||
|
currentStage,
|
||||||
|
nodeStates,
|
||||||
|
project,
|
||||||
|
projectV2,
|
||||||
|
isFixActionable,
|
||||||
|
}: NodeRendererStateOptions) {
|
||||||
|
const tooltipHtml = ref(new Map<object, string>())
|
||||||
|
const state = computed(
|
||||||
|
() => (nodeStates.value[currentStage.value.id] ?? {}) as Record<string, NodeState>,
|
||||||
|
)
|
||||||
|
const nodes = computed(() => resolveChildren(currentStage.value, state.value))
|
||||||
|
|
||||||
|
const write: Writer = (id, value) => {
|
||||||
|
const stageId = currentStage.value.id
|
||||||
|
const existing = nodeStates.value[stageId]
|
||||||
|
const next: Record<string, NodeState> = existing ? { ...existing } : {}
|
||||||
|
if (value === undefined) Reflect.deleteProperty(next, id)
|
||||||
|
else next[id] = value
|
||||||
|
if (Object.keys(next).length === 0) {
|
||||||
|
if (existing !== undefined) Reflect.deleteProperty(nodeStates.value, stageId)
|
||||||
|
} else {
|
||||||
|
nodeStates.value[stageId] = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watchEffect(async (onCleanup) => {
|
||||||
|
let cancelled = false
|
||||||
|
onCleanup(() => {
|
||||||
|
cancelled = true
|
||||||
|
})
|
||||||
|
const stage = currentStage.value
|
||||||
|
const actions = collectMessageNodes(nodes.value, state.value, [stage.id])
|
||||||
|
const next = new Map<object, string>()
|
||||||
|
await Promise.all(
|
||||||
|
actions.map(async (entry) => {
|
||||||
|
try {
|
||||||
|
const raw = await evalActiveAction(entry, actions, new Set())
|
||||||
|
const expanded = expandVariables(raw, projectV2.value, project.value).trim()
|
||||||
|
next.set(
|
||||||
|
entry.node,
|
||||||
|
expanded
|
||||||
|
? `<div class="markdown-body moderation-tooltip-markdown">${renderHighlightedString(expanded)}</div>`
|
||||||
|
: '',
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
next.set(entry.node, '')
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (!cancelled) tooltipHtml.value = next
|
||||||
|
})
|
||||||
|
|
||||||
|
const meta = computed(() => {
|
||||||
|
const metaMap = computeNodeMeta(nodes.value, state.value, isFixActionable)
|
||||||
|
const attentionMap = computeAttentionMap(nodes.value, state.value, metaMap)
|
||||||
|
return { metaMap, attentionMap, tooltipHtml: tooltipHtml.value }
|
||||||
|
})
|
||||||
|
|
||||||
|
return { meta, nodes, state, write }
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import type { NodeState, StageNode } from '@modrinth/moderation/src/types/node'
|
||||||
|
import type { ComputedRef, Ref } from 'vue'
|
||||||
|
import { ref, toRaw, watch } from 'vue'
|
||||||
|
|
||||||
|
import {
|
||||||
|
getSessionChecklistState,
|
||||||
|
patchSessionChecklistState,
|
||||||
|
} from '~/services/moderation/checklist-session-storage.ts'
|
||||||
|
import {
|
||||||
|
clearChecklistState,
|
||||||
|
loadChecklistState,
|
||||||
|
saveChecklistState,
|
||||||
|
} from '~/services/moderation/checklist-storage.ts'
|
||||||
|
|
||||||
|
export async function loadChecklistPersistence(projectId: string) {
|
||||||
|
const persistedState = import.meta.client ? await loadChecklistState(projectId) : null
|
||||||
|
const activatedStages = ref<Set<string>>(new Set(persistedState?.activatedStages ?? []))
|
||||||
|
const visitedStages = ref<Set<string>>(
|
||||||
|
new Set(
|
||||||
|
import.meta.client ? (getSessionChecklistState(projectId).visitedStages ?? []) : [],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const reviewedAnyway = ref(persistedState?.reviewAnyway ?? false)
|
||||||
|
const message = ref<string | null>(persistedState?.message ?? null)
|
||||||
|
|
||||||
|
function markStageVisited(stageId: string | undefined) {
|
||||||
|
if (!stageId || visitedStages.value.has(stageId)) return
|
||||||
|
visitedStages.value.add(stageId)
|
||||||
|
patchSessionChecklistState(projectId, {
|
||||||
|
visitedStages: [...visitedStages.value],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
activatedStages,
|
||||||
|
markStageVisited,
|
||||||
|
message,
|
||||||
|
persistedState,
|
||||||
|
reviewedAnyway,
|
||||||
|
visitedStages,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChecklistPersistenceOptions {
|
||||||
|
projectId: string
|
||||||
|
nodeStates: Ref<Record<string, Record<string, NodeState>>>
|
||||||
|
activatedStages: Ref<Set<string>>
|
||||||
|
reviewedAnyway: Ref<boolean>
|
||||||
|
message: Ref<string | null>
|
||||||
|
currentStage: Ref<number>
|
||||||
|
currentStageNode: ComputedRef<StageNode>
|
||||||
|
firstVisibleStage: () => number
|
||||||
|
markStageVisited: (stageId: string | undefined) => void
|
||||||
|
visitCurrentStageImmediately: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useChecklistPersistence({
|
||||||
|
projectId,
|
||||||
|
nodeStates,
|
||||||
|
activatedStages,
|
||||||
|
reviewedAnyway,
|
||||||
|
message,
|
||||||
|
currentStage,
|
||||||
|
currentStageNode,
|
||||||
|
firstVisibleStage,
|
||||||
|
markStageVisited,
|
||||||
|
visitCurrentStageImmediately,
|
||||||
|
}: ChecklistPersistenceOptions) {
|
||||||
|
let enabled = true
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
function cancelPendingSave() {
|
||||||
|
if (timer === null) return
|
||||||
|
clearTimeout(timer)
|
||||||
|
timer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function save(open: boolean, resetReviewAnyway = false) {
|
||||||
|
const rawState = toRaw(nodeStates.value)
|
||||||
|
const openValue = open || undefined
|
||||||
|
const reviewedAnywayValue = resetReviewAnyway ? undefined : reviewedAnyway.value || undefined
|
||||||
|
const stageValue =
|
||||||
|
currentStage.value !== firstVisibleStage() ? currentStageNode.value.id : undefined
|
||||||
|
const messageValue = message.value ?? undefined
|
||||||
|
const stateValue = Object.keys(rawState).length > 0 ? rawState : undefined
|
||||||
|
const activatedStagesValue =
|
||||||
|
activatedStages.value.size > 0 ? [...activatedStages.value] : undefined
|
||||||
|
|
||||||
|
if (
|
||||||
|
!openValue &&
|
||||||
|
!reviewedAnywayValue &&
|
||||||
|
!stageValue &&
|
||||||
|
!messageValue &&
|
||||||
|
!stateValue &&
|
||||||
|
!activatedStagesValue
|
||||||
|
) {
|
||||||
|
return clearChecklistState(projectId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return saveChecklistState(projectId, {
|
||||||
|
...(openValue && { open: openValue }),
|
||||||
|
...(reviewedAnywayValue && { reviewAnyway: reviewedAnywayValue }),
|
||||||
|
...(stageValue && { stage: stageValue }),
|
||||||
|
...(messageValue && { message: messageValue }),
|
||||||
|
...(stateValue && { state: stateValue }),
|
||||||
|
...(activatedStagesValue && { activatedStages: activatedStagesValue }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function persist() {
|
||||||
|
if (!enabled || !import.meta.client) return
|
||||||
|
cancelPendingSave()
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
timer = null
|
||||||
|
void save(true)
|
||||||
|
}, 150)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistImmediately(open: boolean, resetReviewAnyway = false) {
|
||||||
|
if (!import.meta.client) return
|
||||||
|
cancelPendingSave()
|
||||||
|
await save(open, resetReviewAnyway)
|
||||||
|
}
|
||||||
|
|
||||||
|
function disable() {
|
||||||
|
enabled = false
|
||||||
|
cancelPendingSave()
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispose() {
|
||||||
|
cancelPendingSave()
|
||||||
|
if (enabled) void save(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(currentStage, persist)
|
||||||
|
watch(nodeStates, persist, { deep: true })
|
||||||
|
watch(activatedStages, persist, { deep: true })
|
||||||
|
watch(message, persist)
|
||||||
|
watch(currentStageNode, (stage) => markStageVisited(stage.id), {
|
||||||
|
immediate: visitCurrentStageImmediately,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { disable, dispose, persist, persistImmediately }
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import type { Labrinth } from '@modrinth/api-client'
|
||||||
|
import type { ActiveAction } from '@modrinth/moderation/src/types/node'
|
||||||
|
import { createTrackedPatch, hasCap } from '@modrinth/moderation/src/types/node'
|
||||||
|
import type { FixBuilder } from '@modrinth/moderation/src/types/node/fix'
|
||||||
|
import { injectModrinthClient } from '@modrinth/ui'
|
||||||
|
import type { ProjectStatus } from '@modrinth/utils'
|
||||||
|
import { useMutation } from '@tanstack/vue-query'
|
||||||
|
import type { Ref } from 'vue'
|
||||||
|
|
||||||
|
interface ModerationSubmissionOptions {
|
||||||
|
project: Ref<Labrinth.Projects.v3.Project>
|
||||||
|
projectV2: Ref<Labrinth.Projects.v2.Project>
|
||||||
|
versions: Ref<Labrinth.Versions.v3.Version[] | null>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModerationSubmission {
|
||||||
|
status: ProjectStatus
|
||||||
|
message: string | null
|
||||||
|
activeActions: ActiveAction[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFixes(node: object): FixBuilder[] {
|
||||||
|
return hasCap(node, '_fixes') && Array.isArray(node._fixes)
|
||||||
|
? (node._fixes as FixBuilder[])
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldApplyFixes(actions: ActiveAction[]): boolean {
|
||||||
|
return actions.some(({ node }) => hasCap(node, '_applyFixes') && node._applyFixes === true)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useModerationSubmission({
|
||||||
|
project,
|
||||||
|
projectV2,
|
||||||
|
versions,
|
||||||
|
}: ModerationSubmissionOptions) {
|
||||||
|
const client = injectModrinthClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ status, message, activeActions }: ModerationSubmission) => {
|
||||||
|
const projectId = projectV2.value.id
|
||||||
|
const threadId = projectV2.value.thread_id
|
||||||
|
|
||||||
|
await client.labrinth.projects_v2.edit(projectId, { status })
|
||||||
|
|
||||||
|
if (message && threadId) {
|
||||||
|
await client.labrinth.threads_v3.sendMessage(threadId, {
|
||||||
|
body: { type: 'text', body: message },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let projectFixChanges: Labrinth.Projects.v3.EditProjectRequest = {}
|
||||||
|
if (!shouldApplyFixes(activeActions)) return projectFixChanges
|
||||||
|
|
||||||
|
const { proxy: projectProxy, changes: projectChanges } = createTrackedPatch(
|
||||||
|
project.value as Labrinth.Projects.v3.EditProjectRequest,
|
||||||
|
)
|
||||||
|
for (const { node, state } of activeActions) {
|
||||||
|
for (const fix of getFixes(node)) fix._projectFn?.(projectProxy, state)
|
||||||
|
}
|
||||||
|
projectFixChanges = projectChanges()
|
||||||
|
if (Object.keys(projectFixChanges).length > 0) {
|
||||||
|
await client.labrinth.projects_v3.edit(projectId, projectFixChanges)
|
||||||
|
}
|
||||||
|
|
||||||
|
const versionFixes = activeActions.flatMap(({ node, state }) =>
|
||||||
|
getFixes(node)
|
||||||
|
.filter((fix) => fix._versionFn)
|
||||||
|
.map((fix) => ({ fix, state })),
|
||||||
|
)
|
||||||
|
if (versionFixes.length === 0 || !versions.value) return projectFixChanges
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
versions.value.map(async (version) => {
|
||||||
|
const { proxy, changes } = createTrackedPatch(
|
||||||
|
version as Labrinth.Versions.v3.ModifyVersionRequest,
|
||||||
|
)
|
||||||
|
for (const { fix, state } of versionFixes) {
|
||||||
|
fix._versionFn?.(proxy, state)
|
||||||
|
}
|
||||||
|
const changed = changes()
|
||||||
|
if (Object.keys(changed).length > 0) {
|
||||||
|
await client.labrinth.versions_v3.modifyVersion(version.id, changed)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return projectFixChanges
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
+1
-3
@@ -548,9 +548,7 @@ function getModpackFiles(): {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({ getModpackFiles })
|
||||||
getModpackFiles,
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -560,7 +560,7 @@ import { navigateTo } from '#app'
|
|||||||
import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
|
import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
|
||||||
import CollectionCreateModal from '~/components/ui/create/CollectionCreateModal.vue'
|
import CollectionCreateModal from '~/components/ui/create/CollectionCreateModal.vue'
|
||||||
import MessageBanner from '~/components/ui/MessageBanner.vue'
|
import MessageBanner from '~/components/ui/MessageBanner.vue'
|
||||||
import ModerationChecklist from '~/components/ui/moderation/checklist/ModerationChecklist.vue'
|
import ModerationChecklist from '~/components/ui/moderation/moderation-checklist/index.vue'
|
||||||
import ModerationProjectNags from '~/components/ui/moderation/ModerationProjectNags.vue'
|
import ModerationProjectNags from '~/components/ui/moderation/ModerationProjectNags.vue'
|
||||||
import ModpackScanModal from '~/components/ui/moderation/ModpackScanModal.vue'
|
import ModpackScanModal from '~/components/ui/moderation/ModpackScanModal.vue'
|
||||||
import ProjectCollectionSaveButton from '~/components/ui/ProjectCollectionSaveButton.vue'
|
import ProjectCollectionSaveButton from '~/components/ui/ProjectCollectionSaveButton.vue'
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Component, FunctionalComponent, SVGAttributes } from 'vue'
|
import type { FunctionalComponent, SVGAttributes } from 'vue'
|
||||||
import { markRaw } from 'vue'
|
import { markRaw } from 'vue'
|
||||||
|
|
||||||
import { Priority } from '../priority.ts'
|
import { Priority } from '../priority.ts'
|
||||||
@@ -254,39 +254,43 @@ export function withOnClick<T extends object>(node: T): T & Clickable {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ComponentNodePropsContext {
|
export interface NodePropsContext {
|
||||||
onImageUpload?: (file: File) => Promise<string>
|
onImageUpload?: (file: File) => Promise<string>
|
||||||
toggleSetValue?: (value: string) => void
|
toggleSetValue?: (value: string) => void
|
||||||
nodeFacts?: { needsAttention: boolean; fixActionable: boolean }
|
|
||||||
tooltip?: Record<string, unknown>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type BuiltinRendererKey =
|
||||||
|
| 'action'
|
||||||
|
| 'checkbox'
|
||||||
|
| 'toggle'
|
||||||
|
| 'dropdown'
|
||||||
|
| 'text'
|
||||||
|
| 'markdown'
|
||||||
|
|
||||||
|
export type NodeRendererDescriptor =
|
||||||
|
| { type: BuiltinRendererKey }
|
||||||
|
| { type: 'custom'; key: string }
|
||||||
|
|
||||||
export interface Renderable {
|
export interface Renderable {
|
||||||
_component: Component | undefined
|
_renderer: NodeRendererDescriptor
|
||||||
_rendererKey: string | undefined
|
|
||||||
_componentProps?: (ctx: ComponentNodePropsContext) => Record<string, unknown>
|
|
||||||
_modelProp: string
|
_modelProp: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function withComponent<T extends object>(
|
export function withRenderer<T extends object>(
|
||||||
node: T,
|
node: T,
|
||||||
opts: {
|
opts: {
|
||||||
component?: Component
|
renderer: NodeRendererDescriptor
|
||||||
rendererKey?: string
|
|
||||||
modelProp?: string
|
modelProp?: string
|
||||||
componentProps?: Renderable['_componentProps']
|
|
||||||
},
|
},
|
||||||
): T & Renderable {
|
): T & Renderable {
|
||||||
return Object.assign(node, {
|
return Object.assign(node, {
|
||||||
_component: opts.component,
|
_renderer: opts.renderer,
|
||||||
_rendererKey: opts.rendererKey,
|
|
||||||
_componentProps: opts.componentProps,
|
|
||||||
_modelProp: opts.modelProp ?? 'modelValue',
|
_modelProp: opts.modelProp ?? 'modelValue',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Configurable {
|
export interface Configurable {
|
||||||
_extraProps: ((ctx: ComponentNodePropsContext) => Record<string, unknown>) | undefined
|
_extraProps: ((ctx: NodePropsContext) => Record<string, unknown>) | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export function withExtraProps<T extends object>(node: T): T & Configurable {
|
export function withExtraProps<T extends object>(node: T): T & Configurable {
|
||||||
|
|||||||
@@ -1,401 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
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')
|
|
||||||
@@ -1,10 +1,6 @@
|
|||||||
import { Checkbox, Combobox, MarkdownEditor, StyledInput, Toggle } from '@modrinth/ui'
|
|
||||||
import { markRaw } from 'vue'
|
|
||||||
|
|
||||||
import { withAutoProps, withChildren } from './builder'
|
import { withAutoProps, withChildren } from './builder'
|
||||||
import type { ComponentNodePropsContext, Configurable } from './capabilities'
|
import type { Configurable, NodePropsContext } from './capabilities'
|
||||||
import {
|
import {
|
||||||
withComponent,
|
|
||||||
withEditable,
|
withEditable,
|
||||||
withEnabled,
|
withEnabled,
|
||||||
withExtraProps,
|
withExtraProps,
|
||||||
@@ -17,6 +13,7 @@ import {
|
|||||||
withOnClick,
|
withOnClick,
|
||||||
withPriority,
|
withPriority,
|
||||||
withRequired,
|
withRequired,
|
||||||
|
withRenderer,
|
||||||
withSelectable,
|
withSelectable,
|
||||||
withShown,
|
withShown,
|
||||||
withStageMeta,
|
withStageMeta,
|
||||||
@@ -26,7 +23,6 @@ import {
|
|||||||
withTweak,
|
withTweak,
|
||||||
withValue,
|
withValue,
|
||||||
} from './capabilities'
|
} from './capabilities'
|
||||||
import ActionButton from './components/ActionButton.vue'
|
|
||||||
import { pipe } from './pipe'
|
import { pipe } from './pipe'
|
||||||
import type { NodeState, NodeStateWithChildren } from './state'
|
import type { NodeState, NodeStateWithChildren } from './state'
|
||||||
|
|
||||||
@@ -68,17 +64,7 @@ export function toggle(id: string, label: string) {
|
|||||||
withFix,
|
withFix,
|
||||||
withEnabled,
|
withEnabled,
|
||||||
(n) => withValue(n, booleanValue),
|
(n) => withValue(n, booleanValue),
|
||||||
(n) =>
|
(n) => withRenderer(n, { renderer: { type: 'action' } }),
|
||||||
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,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,11 +94,7 @@ export function check(id: string, label: string) {
|
|||||||
withFix,
|
withFix,
|
||||||
withEnabled,
|
withEnabled,
|
||||||
(n) => withValue(n, booleanValue),
|
(n) => withValue(n, booleanValue),
|
||||||
(n) =>
|
(n) => withRenderer(n, { renderer: { type: 'checkbox' } }),
|
||||||
withComponent(n, {
|
|
||||||
component: markRaw(Checkbox),
|
|
||||||
componentProps: () => ({ label }),
|
|
||||||
}),
|
|
||||||
withExtraProps,
|
withExtraProps,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -133,7 +115,7 @@ export function toggleSwitch(id: string, label: string) {
|
|||||||
withFix,
|
withFix,
|
||||||
withEnabled,
|
withEnabled,
|
||||||
(n) => withValue(n, booleanValue),
|
(n) => withValue(n, booleanValue),
|
||||||
(n) => withComponent(n, { component: markRaw(Toggle) }),
|
(n) => withRenderer(n, { renderer: { type: 'toggle' } }),
|
||||||
withExtraProps,
|
withExtraProps,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -227,19 +209,7 @@ export function dropdown(id: string) {
|
|||||||
(n) => withValue(n, dropdownValue),
|
(n) => withValue(n, dropdownValue),
|
||||||
withNoneLabel,
|
withNoneLabel,
|
||||||
withOptions,
|
withOptions,
|
||||||
(n) =>
|
(n) => withRenderer(n, { renderer: { type: 'dropdown' } }),
|
||||||
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',
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,11 +248,7 @@ export function text(id: string) {
|
|||||||
(n) => Object.assign(n, { _showTooltip: true, _imperativeSync: true }),
|
(n) => Object.assign(n, { _showTooltip: true, _imperativeSync: true }),
|
||||||
(n) => withValue(n, stringValue),
|
(n) => withValue(n, stringValue),
|
||||||
withTweak,
|
withTweak,
|
||||||
(n) =>
|
(n) => withRenderer(n, { renderer: { type: 'text' } }),
|
||||||
withComponent(n, {
|
|
||||||
component: markRaw(StyledInput),
|
|
||||||
componentProps: () => ({ class: 'min-w-40 flex-1', autocomplete: 'off' }),
|
|
||||||
}),
|
|
||||||
withExtraProps,
|
withExtraProps,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -304,16 +270,7 @@ export function markdown(id: string) {
|
|||||||
withEnabled,
|
withEnabled,
|
||||||
withEditable,
|
withEditable,
|
||||||
(n) => withValue(n, stringValue),
|
(n) => withValue(n, stringValue),
|
||||||
(n) =>
|
(n) => withRenderer(n, { renderer: { type: 'markdown' } }),
|
||||||
withComponent(n, {
|
|
||||||
component: markRaw(MarkdownEditor),
|
|
||||||
componentProps: (ctx) => ({
|
|
||||||
maxHeight: 300,
|
|
||||||
disabled: false,
|
|
||||||
headingButtons: false,
|
|
||||||
onImageUpload: ctx.onImageUpload,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
withExtraProps,
|
withExtraProps,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -353,7 +310,7 @@ export function appComponent(id: string, rendererKey: string) {
|
|||||||
withFix,
|
withFix,
|
||||||
withEnabled,
|
withEnabled,
|
||||||
(n) => withValue(n, stringValueBehavior),
|
(n) => withValue(n, stringValueBehavior),
|
||||||
(n) => withComponent(n, { rendererKey }),
|
(n) => withRenderer(n, { renderer: { type: 'custom', key: rendererKey } }),
|
||||||
withExtraProps,
|
withExtraProps,
|
||||||
)
|
)
|
||||||
return Object.assign(node, {
|
return Object.assign(node, {
|
||||||
@@ -364,7 +321,7 @@ export function appComponent(id: string, rendererKey: string) {
|
|||||||
)
|
)
|
||||||
return this
|
return this
|
||||||
},
|
},
|
||||||
props(this: Configurable, fn: (ctx: ComponentNodePropsContext) => Record<string, unknown>) {
|
props(this: Configurable, fn: (ctx: NodePropsContext) => Record<string, unknown>) {
|
||||||
this._extraProps = fn
|
this._extraProps = fn
|
||||||
return this
|
return this
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
export * from './builder'
|
export * from './builder'
|
||||||
export * from './capabilities'
|
export * from './capabilities'
|
||||||
export * from './collect'
|
export * from './collect'
|
||||||
export * from './context'
|
|
||||||
export * from './factories'
|
export * from './factories'
|
||||||
export * from './fix'
|
export * from './fix'
|
||||||
export * from './messages'
|
export * from './messages'
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ Prefer giving non-trivial components their own folder:
|
|||||||
components/
|
components/
|
||||||
└── analytics-chart/
|
└── analytics-chart/
|
||||||
├── index.vue
|
├── index.vue
|
||||||
├── analytics-chart-header.vue
|
├── header.vue
|
||||||
├── analytics-chart-plot.vue
|
├── plot.vue
|
||||||
├── analytics-chart-data.ts
|
├── data.ts
|
||||||
└── use-analytics-chart.ts
|
└── use-analytics-chart.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -37,9 +37,9 @@ Keep files that only exist to support one component inside that component's fold
|
|||||||
```
|
```
|
||||||
analytics-chart/
|
analytics-chart/
|
||||||
├── index.vue
|
├── index.vue
|
||||||
├── analytics-chart-header.vue
|
├── header.vue
|
||||||
├── analytics-chart-plot.vue
|
├── plot.vue
|
||||||
├── analytics-chart-tooltip.vue
|
├── tooltip.vue
|
||||||
├── chart-ranges.ts
|
├── chart-ranges.ts
|
||||||
└── use-chart-hover-state.ts
|
└── use-chart-hover-state.ts
|
||||||
```
|
```
|
||||||
@@ -60,21 +60,10 @@ Local subcomponents should still have clear names that explain their relationshi
|
|||||||
```
|
```
|
||||||
analytics-chart/
|
analytics-chart/
|
||||||
├── index.vue
|
├── index.vue
|
||||||
├── analytics-chart-header.vue
|
├── header.vue
|
||||||
└── analytics-chart-plot.vue
|
└── plot.vue
|
||||||
```
|
```
|
||||||
|
|
||||||
Avoid vague names that make a local component look like a standalone public component:
|
|
||||||
|
|
||||||
```
|
|
||||||
analytics-chart/
|
|
||||||
├── index.vue
|
|
||||||
├── events.vue
|
|
||||||
└── header.vue
|
|
||||||
```
|
|
||||||
|
|
||||||
If a file is local to `analytics-chart`, prefixing it with `analytics-chart-` makes that relationship clear when it appears in search results, editor tabs, and imports.
|
|
||||||
|
|
||||||
## Nesting
|
## Nesting
|
||||||
|
|
||||||
One level of nesting is usually enough.
|
One level of nesting is usually enough.
|
||||||
@@ -84,22 +73,22 @@ Prefer this:
|
|||||||
```
|
```
|
||||||
analytics-chart/
|
analytics-chart/
|
||||||
├── index.vue
|
├── index.vue
|
||||||
├── analytics-chart-header.vue
|
├── header.vue
|
||||||
├── analytics-chart-plot.vue
|
├── plot.vue
|
||||||
├── use-chart-hover-state.ts
|
├── use-chart-hover-state.ts
|
||||||
└── use-chart-selection.ts
|
└── use-chart-selection.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
Avoid this unless a local area has become large enough to justify its own module boundary:
|
Avoid this unless a local area has become large enough to justify its own module boundary, or for example it makes sense: e.g: for subpages
|
||||||
|
|
||||||
```
|
```
|
||||||
analytics-chart/
|
page-thing/
|
||||||
├── index.vue
|
├── index.vue
|
||||||
├── header/
|
├── subpage-1/
|
||||||
│ └── index.vue
|
│ └── index.vue
|
||||||
└── plot/
|
└── subpage-2/
|
||||||
├── index.vue
|
├── index.vue
|
||||||
└── use-plot-state.ts
|
└── use-something.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
Subfolders are fine when they reduce real complexity, but do not create a folder for every small subcomponent by default. Deep nesting makes the file tree harder to scan and often adds duplicated names without improving ownership.
|
Subfolders are fine when they reduce real complexity, but do not create a folder for every small subcomponent by default. Deep nesting makes the file tree harder to scan and often adds duplicated names without improving ownership.
|
||||||
|
|||||||
Reference in New Issue
Block a user