Compare commits

...
Author SHA1 Message Date
Calum H. (IMB11) 7deb7e9ef1 fix: prepr 2026-08-10 12:58:02 +01:00
Calum H. (IMB11) eb5897b553 refactor: clean up checklist structure + node renderer 2026-08-10 11:58:16 +01:00
21 changed files with 1351 additions and 1049 deletions
@@ -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')
@@ -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<{
@@ -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
}
@@ -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>
@@ -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]
}
@@ -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>>
}
@@ -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 }
}
@@ -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
},
})
}
@@ -548,9 +548,7 @@ function getModpackFiles(): {
}
}
defineExpose({
getModpackFiles,
})
defineExpose({ getModpackFiles })
</script>
<style scoped>
+1 -1
View File
@@ -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')
+10 -53
View File
@@ -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'
+15 -26
View File
@@ -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.