mirror of
https://github.com/modrinth/code.git
synced 2026-08-28 18:45:15 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7deb7e9ef1 | ||
|
|
eb5897b553 |
@@ -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>
|
||||
<Button
|
||||
v-tooltip="tooltip"
|
||||
<IconButton
|
||||
v-if="icon"
|
||||
:type="color === 'standard' ? 'base' : 'colored'"
|
||||
:color="color === 'standard' ? undefined : color"
|
||||
:disabled="disabled"
|
||||
:aria-label="icon ? label : undefined"
|
||||
:label="label"
|
||||
@click="emit('update:modelValue', !modelValue)"
|
||||
>
|
||||
<component :is="icon" v-if="icon" />
|
||||
<template v-else>{{ label }}</template>
|
||||
<component :is="icon" aria-hidden="true" />
|
||||
</IconButton>
|
||||
<Button
|
||||
v-else
|
||||
:type="color === 'standard' ? 'base' : 'colored'"
|
||||
:color="color === 'standard' ? undefined : color"
|
||||
:disabled="disabled"
|
||||
@click="emit('update:modelValue', !modelValue)"
|
||||
>
|
||||
{{ label }}
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Button } from '@modrinth/ui'
|
||||
import { Button, IconButton } from '@modrinth/ui'
|
||||
import type { Component } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
label?: string
|
||||
label: string
|
||||
icon?: Component
|
||||
disabled?: boolean
|
||||
needsAttention?: boolean
|
||||
fixActionable?: boolean
|
||||
tooltip?: Record<string, unknown>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
+188
-515
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
|
||||
}
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
<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)">
|
||||
<!-- eslint-disable-next-line vue/no-undef-components -- recursive component named via defineOptions -->
|
||||
<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)}`">
|
||||
<!-- eslint-disable-next-line vue/no-undef-components -- recursive component named via defineOptions -->
|
||||
<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>
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
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>>
|
||||
}
|
||||
+291
@@ -0,0 +1,291 @@
|
||||
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,
|
||||
withStateDefaults,
|
||||
writeNodeValue,
|
||||
} 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 }
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { expandVariables } from '@modrinth/moderation'
|
||||
import type { FixBuilder, NodeState, StageNode, Writer } from '@modrinth/moderation/src/types/node'
|
||||
import {
|
||||
collectMessageNodes,
|
||||
computeAttentionMap,
|
||||
computeNodeMeta,
|
||||
evalActiveAction,
|
||||
resolveChildren,
|
||||
} 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,142 @@
|
||||
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,89 @@
|
||||
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({
|
||||
getModpackFiles,
|
||||
})
|
||||
defineExpose({ getModpackFiles })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -560,7 +560,7 @@ import { navigateTo } from '#app'
|
||||
import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
|
||||
import CollectionCreateModal from '~/components/ui/create/CollectionCreateModal.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 ModpackScanModal from '~/components/ui/moderation/ModpackScanModal.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 { Priority } from '../priority.ts'
|
||||
@@ -254,39 +254,35 @@ export function withOnClick<T extends object>(node: T): T & Clickable {
|
||||
})
|
||||
}
|
||||
|
||||
export interface ComponentNodePropsContext {
|
||||
export interface NodePropsContext {
|
||||
onImageUpload?: (file: File) => Promise<string>
|
||||
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 {
|
||||
_component: Component | undefined
|
||||
_rendererKey: string | undefined
|
||||
_componentProps?: (ctx: ComponentNodePropsContext) => Record<string, unknown>
|
||||
_renderer: NodeRendererDescriptor
|
||||
_modelProp: string
|
||||
}
|
||||
|
||||
export function withComponent<T extends object>(
|
||||
export function withRenderer<T extends object>(
|
||||
node: T,
|
||||
opts: {
|
||||
component?: Component
|
||||
rendererKey?: string
|
||||
renderer: NodeRendererDescriptor
|
||||
modelProp?: string
|
||||
componentProps?: Renderable['_componentProps']
|
||||
},
|
||||
): T & Renderable {
|
||||
return Object.assign(node, {
|
||||
_component: opts.component,
|
||||
_rendererKey: opts.rendererKey,
|
||||
_componentProps: opts.componentProps,
|
||||
_renderer: opts.renderer,
|
||||
_modelProp: opts.modelProp ?? 'modelValue',
|
||||
})
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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 type { ComponentNodePropsContext, Configurable } from './capabilities'
|
||||
import type { Configurable, NodePropsContext } from './capabilities'
|
||||
import {
|
||||
withComponent,
|
||||
withEditable,
|
||||
withEnabled,
|
||||
withExtraProps,
|
||||
@@ -16,6 +12,7 @@ import {
|
||||
withNoneLabel,
|
||||
withOnClick,
|
||||
withPriority,
|
||||
withRenderer,
|
||||
withRequired,
|
||||
withSelectable,
|
||||
withShown,
|
||||
@@ -26,7 +23,6 @@ import {
|
||||
withTweak,
|
||||
withValue,
|
||||
} from './capabilities'
|
||||
import ActionButton from './components/ActionButton.vue'
|
||||
import { pipe } from './pipe'
|
||||
import type { NodeState, NodeStateWithChildren } from './state'
|
||||
|
||||
@@ -68,17 +64,7 @@ export function toggle(id: string, label: string) {
|
||||
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,
|
||||
}),
|
||||
}),
|
||||
(n) => withRenderer(n, { renderer: { type: 'action' } }),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -108,11 +94,7 @@ export function check(id: string, label: string) {
|
||||
withFix,
|
||||
withEnabled,
|
||||
(n) => withValue(n, booleanValue),
|
||||
(n) =>
|
||||
withComponent(n, {
|
||||
component: markRaw(Checkbox),
|
||||
componentProps: () => ({ label }),
|
||||
}),
|
||||
(n) => withRenderer(n, { renderer: { type: 'checkbox' } }),
|
||||
withExtraProps,
|
||||
),
|
||||
)
|
||||
@@ -133,7 +115,7 @@ export function toggleSwitch(id: string, label: string) {
|
||||
withFix,
|
||||
withEnabled,
|
||||
(n) => withValue(n, booleanValue),
|
||||
(n) => withComponent(n, { component: markRaw(Toggle) }),
|
||||
(n) => withRenderer(n, { renderer: { type: 'toggle' } }),
|
||||
withExtraProps,
|
||||
),
|
||||
)
|
||||
@@ -227,19 +209,7 @@ export function dropdown(id: string) {
|
||||
(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',
|
||||
}),
|
||||
}),
|
||||
(n) => withRenderer(n, { renderer: { type: 'dropdown' } }),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -278,11 +248,7 @@ export function text(id: string) {
|
||||
(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' }),
|
||||
}),
|
||||
(n) => withRenderer(n, { renderer: { type: 'text' } }),
|
||||
withExtraProps,
|
||||
),
|
||||
)
|
||||
@@ -304,16 +270,7 @@ export function markdown(id: string) {
|
||||
withEnabled,
|
||||
withEditable,
|
||||
(n) => withValue(n, stringValue),
|
||||
(n) =>
|
||||
withComponent(n, {
|
||||
component: markRaw(MarkdownEditor),
|
||||
componentProps: (ctx) => ({
|
||||
maxHeight: 300,
|
||||
disabled: false,
|
||||
headingButtons: false,
|
||||
onImageUpload: ctx.onImageUpload,
|
||||
}),
|
||||
}),
|
||||
(n) => withRenderer(n, { renderer: { type: 'markdown' } }),
|
||||
withExtraProps,
|
||||
),
|
||||
)
|
||||
@@ -353,7 +310,7 @@ export function appComponent(id: string, rendererKey: string) {
|
||||
withFix,
|
||||
withEnabled,
|
||||
(n) => withValue(n, stringValueBehavior),
|
||||
(n) => withComponent(n, { rendererKey }),
|
||||
(n) => withRenderer(n, { renderer: { type: 'custom', key: rendererKey } }),
|
||||
withExtraProps,
|
||||
)
|
||||
return Object.assign(node, {
|
||||
@@ -364,7 +321,7 @@ export function appComponent(id: string, rendererKey: string) {
|
||||
)
|
||||
return this
|
||||
},
|
||||
props(this: Configurable, fn: (ctx: ComponentNodePropsContext) => Record<string, unknown>) {
|
||||
props(this: Configurable, fn: (ctx: NodePropsContext) => Record<string, unknown>) {
|
||||
this._extraProps = fn
|
||||
return this
|
||||
},
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export * from './builder'
|
||||
export * from './capabilities'
|
||||
export * from './collect'
|
||||
export * from './context'
|
||||
export * from './factories'
|
||||
export * from './fix'
|
||||
export * from './messages'
|
||||
|
||||
@@ -8,9 +8,9 @@ Prefer giving non-trivial components their own folder:
|
||||
components/
|
||||
└── analytics-chart/
|
||||
├── index.vue
|
||||
├── analytics-chart-header.vue
|
||||
├── analytics-chart-plot.vue
|
||||
├── analytics-chart-data.ts
|
||||
├── header.vue
|
||||
├── plot.vue
|
||||
├── data.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/
|
||||
├── index.vue
|
||||
├── analytics-chart-header.vue
|
||||
├── analytics-chart-plot.vue
|
||||
├── analytics-chart-tooltip.vue
|
||||
├── header.vue
|
||||
├── plot.vue
|
||||
├── tooltip.vue
|
||||
├── chart-ranges.ts
|
||||
└── use-chart-hover-state.ts
|
||||
```
|
||||
@@ -60,21 +60,10 @@ Local subcomponents should still have clear names that explain their relationshi
|
||||
```
|
||||
analytics-chart/
|
||||
├── index.vue
|
||||
├── analytics-chart-header.vue
|
||||
└── analytics-chart-plot.vue
|
||||
├── header.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
|
||||
|
||||
One level of nesting is usually enough.
|
||||
@@ -84,22 +73,22 @@ Prefer this:
|
||||
```
|
||||
analytics-chart/
|
||||
├── index.vue
|
||||
├── analytics-chart-header.vue
|
||||
├── analytics-chart-plot.vue
|
||||
├── header.vue
|
||||
├── plot.vue
|
||||
├── use-chart-hover-state.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
|
||||
├── header/
|
||||
├── subpage-1/
|
||||
│ └── index.vue
|
||||
└── plot/
|
||||
└── subpage-2/
|
||||
├── 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.
|
||||
|
||||
Reference in New Issue
Block a user