From eb5897b553b0c4ea9138608a8d63d6ca0bf190b5 Mon Sep 17 00:00:00 2001 From: "Calum H. (IMB11)" Date: Mon, 10 Aug 2026 11:58:16 +0100 Subject: [PATCH 1/2] refactor: clean up checklist structure + node renderer --- .../moderation/checklist/checklist-context.ts | 15 - .../moderation-checklist/action-button.vue | 21 +- .../index.vue} | 709 +++++------------- .../node-renderer/dropdown-width.ts | 31 + .../node-renderer/index.vue | 157 ++++ .../node-renderer/renderers.ts | 82 ++ .../node-renderer/types.ts | 43 ++ .../node-renderer/use-node-renderer.ts | 293 ++++++++ .../moderation/moderation-checklist/types.ts | 11 + .../moderation-checklist/use-lock.ts | 180 +++++ .../use-node-renderer-state.ts | 88 +++ .../moderation-checklist/use-persistence.ts | 144 ++++ .../moderation-checklist/use-submission.ts | 91 +++ .../index.vue} | 4 +- apps/frontend/src/pages/[type]/[project].vue | 2 +- .../moderation/src/types/node/capabilities.ts | 34 +- .../types/node/components/NodeRenderer.vue | 401 ---------- packages/moderation/src/types/node/context.ts | 11 - .../moderation/src/types/node/factories.ts | 63 +- packages/moderation/src/types/node/index.ts | 1 - standards/frontend/COMPONENT_STRUCTURE.md | 41 +- 21 files changed, 1372 insertions(+), 1050 deletions(-) delete mode 100644 apps/frontend/src/components/ui/moderation/checklist/checklist-context.ts rename packages/moderation/src/types/node/components/ActionButton.vue => apps/frontend/src/components/ui/moderation/moderation-checklist/action-button.vue (65%) rename apps/frontend/src/components/ui/moderation/{checklist/ModerationChecklist.vue => moderation-checklist/index.vue} (70%) create mode 100644 apps/frontend/src/components/ui/moderation/moderation-checklist/node-renderer/dropdown-width.ts create mode 100644 apps/frontend/src/components/ui/moderation/moderation-checklist/node-renderer/index.vue create mode 100644 apps/frontend/src/components/ui/moderation/moderation-checklist/node-renderer/renderers.ts create mode 100644 apps/frontend/src/components/ui/moderation/moderation-checklist/node-renderer/types.ts create mode 100644 apps/frontend/src/components/ui/moderation/moderation-checklist/node-renderer/use-node-renderer.ts create mode 100644 apps/frontend/src/components/ui/moderation/moderation-checklist/types.ts create mode 100644 apps/frontend/src/components/ui/moderation/moderation-checklist/use-lock.ts create mode 100644 apps/frontend/src/components/ui/moderation/moderation-checklist/use-node-renderer-state.ts create mode 100644 apps/frontend/src/components/ui/moderation/moderation-checklist/use-persistence.ts create mode 100644 apps/frontend/src/components/ui/moderation/moderation-checklist/use-submission.ts rename apps/frontend/src/components/ui/moderation/{checklist/ModpackPermissionsFlow.vue => modpack-permissions-flow/index.vue} (99%) delete mode 100644 packages/moderation/src/types/node/components/NodeRenderer.vue delete mode 100644 packages/moderation/src/types/node/context.ts diff --git a/apps/frontend/src/components/ui/moderation/checklist/checklist-context.ts b/apps/frontend/src/components/ui/moderation/checklist/checklist-context.ts deleted file mode 100644 index b3d40f8d62..0000000000 --- a/apps/frontend/src/components/ui/moderation/checklist/checklist-context.ts +++ /dev/null @@ -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>>> = - Symbol('checklistState') diff --git a/packages/moderation/src/types/node/components/ActionButton.vue b/apps/frontend/src/components/ui/moderation/moderation-checklist/action-button.vue similarity index 65% rename from packages/moderation/src/types/node/components/ActionButton.vue rename to apps/frontend/src/components/ui/moderation/moderation-checklist/action-button.vue index 63de16adc8..5d828c5e95 100644 --- a/packages/moderation/src/types/node/components/ActionButton.vue +++ b/apps/frontend/src/components/ui/moderation/moderation-checklist/action-button.vue @@ -1,30 +1,37 @@ + + diff --git a/apps/frontend/src/components/ui/moderation/moderation-checklist/node-renderer/renderers.ts b/apps/frontend/src/components/ui/moderation/moderation-checklist/node-renderer/renderers.ts new file mode 100644 index 0000000000..dd2bbc03ce --- /dev/null +++ b/apps/frontend/src/components/ui/moderation/moderation-checklist/node-renderer/renderers.ts @@ -0,0 +1,82 @@ +import type { BuiltinRendererKey } from '@modrinth/moderation/src/types/node' +import { Checkbox, Combobox, MarkdownEditor, StyledInput, Toggle } from '@modrinth/ui' +import type { Component } from 'vue' + +import LoaderPicker from '~/components/ui/create-project-version/components/LoaderPicker.vue' +import McVersionPicker from '~/components/ui/create-project-version/components/McVersionPicker.vue' + +import ActionButton from '../action-button.vue' +import type { RenderableValueNode, RendererPropsContext } from './types' + +interface RendererDefinition { + component: Component + props?: ( + node: RenderableValueNode, + context: RendererPropsContext, + ) => Record +} + +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 + +const customRenderers = { + 'loader-picker': LoaderPicker, + 'game-version-picker': McVersionPicker, +} satisfies Record + +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] +} diff --git a/apps/frontend/src/components/ui/moderation/moderation-checklist/node-renderer/types.ts b/apps/frontend/src/components/ui/moderation/moderation-checklist/node-renderer/types.ts new file mode 100644 index 0000000000..ca5d72e8b2 --- /dev/null +++ b/apps/frontend/src/components/ui/moderation/moderation-checklist/node-renderer/types.ts @@ -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 & + Renderable & + Partial & + Partial + +export interface ChecklistMeta { + metaMap: Map + attentionMap: Map + tooltipHtml: Map +} + +export interface RendererPropsContext extends NodePropsContext { + nodeFacts: { needsAttention: boolean; fixActionable: boolean } +} + +export interface NodeRendererProps { + nodes: ChildNode[] + state: Record + write: Writer + meta: ChecklistMeta + onImageUpload?: (file: File) => Promise + flex?: boolean + titleDepth?: number + globalState?: Record> +} diff --git a/apps/frontend/src/components/ui/moderation/moderation-checklist/node-renderer/use-node-renderer.ts b/apps/frontend/src/components/ui/moderation/moderation-checklist/node-renderer/use-node-renderer.ts new file mode 100644 index 0000000000..7a8aca911c --- /dev/null +++ b/apps/frontend/src/components/ui/moderation/moderation-checklist/node-renderer/use-node-renderer.ts @@ -0,0 +1,293 @@ +import type { + AnyNode, + ChildNode, + HasChildren, + HasValue, + Identified, + NodePropsContext, + NodeState, + OnChangeFn, + Reactive, + TweakDef, + Writer, +} from '@modrinth/moderation/src/types/node' +import { + childWriter, + getBooleanChildState, + getEffectiveValue, + hasCap, + hasChildrenCap, + hasIdCap, + hasOptionsCap, + hasValueCap, + isNodeActive, + isShown, + originScope, + resolve, + resolveChildren, + writeNodeValue, + withStateDefaults, +} from '@modrinth/moderation/src/types/node' +import type { Component } from 'vue' +import { computed, watchEffect } from 'vue' + +import { getDropdownMinWidth } from './dropdown-width' +import { resolveNodeRenderer } from './renderers' +import type { NodeRendererProps, RenderableValueNode, RendererPropsContext } from './types' + +const TOOLTIP_BASE = { + delay: { show: 500, hide: 0 }, + triggers: ['hover', 'focus'], + placement: 'top', +} + +export function useNodeRenderer(props: NodeRendererProps) { + const wrappedState = computed(() => withStateDefaults(props.state, props.nodes, props.write)) + + function resolveComponent(node: RenderableValueNode): Component | undefined { + return resolveNodeRenderer(node)?.component + } + + function titleClass(depth: number): string { + if (depth === 0) return 'text-lg font-extrabold text-contrast' + if (depth === 1) return 'text-base font-semibold' + if (depth === 2) return 'text-sm font-semibold' + return '' + } + + function getTitle(node: object): string | undefined { + if (!hasCap(node, '_title')) return undefined + const title = node._title as Reactive | 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) => boolean)(wrappedState.value) + } + return resolve(node._enabled as Reactive) + } + + 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 | undefined { + if (hasCap(node, '_tooltip')) { + const tooltip = node._tooltip as + | Reactive + | ((state: Record) => 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 { + 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): { + state: Record + 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) + : {} + return { state, write: childWriter(props.state, props.write, node.id) } + } + + function valueScope(node: HasValue & Identified): { + state: Record + 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) => 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 | 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() + 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, + } +} diff --git a/apps/frontend/src/components/ui/moderation/moderation-checklist/types.ts b/apps/frontend/src/components/ui/moderation/moderation-checklist/types.ts new file mode 100644 index 0000000000..73854f654b --- /dev/null +++ b/apps/frontend/src/components/ui/moderation/moderation-checklist/types.ts @@ -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[] +} diff --git a/apps/frontend/src/components/ui/moderation/moderation-checklist/use-lock.ts b/apps/frontend/src/components/ui/moderation/moderation-checklist/use-lock.ts new file mode 100644 index 0000000000..9e0b71333f --- /dev/null +++ b/apps/frontend/src/components/ui/moderation/moderation-checklist/use-lock.ts @@ -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(null) + const error = ref(false) + const timeRemaining = ref(null) + let heartbeat: ReturnType | null = null + let countdown: ReturnType | 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 } +} diff --git a/apps/frontend/src/components/ui/moderation/moderation-checklist/use-node-renderer-state.ts b/apps/frontend/src/components/ui/moderation/moderation-checklist/use-node-renderer-state.ts new file mode 100644 index 0000000000..19565ffce2 --- /dev/null +++ b/apps/frontend/src/components/ui/moderation/moderation-checklist/use-node-renderer-state.ts @@ -0,0 +1,88 @@ +import type { Labrinth } from '@modrinth/api-client' +import { expandVariables } from '@modrinth/moderation' +import { + collectMessageNodes, + computeAttentionMap, + computeNodeMeta, + evalActiveAction, + resolveChildren, +} from '@modrinth/moderation/src/types/node' +import type { + FixBuilder, + NodeState, + StageNode, + Writer, +} from '@modrinth/moderation/src/types/node' +import { renderHighlightedString } from '@modrinth/utils' +import type { ComputedRef, Ref } from 'vue' +import { computed, ref, watchEffect } from 'vue' + +interface NodeRendererStateOptions { + currentStage: ComputedRef + nodeStates: Ref>> + project: Ref + projectV2: Ref + isFixActionable: (fixes: FixBuilder[], state: Record) => boolean +} + +export function useNodeRendererState({ + currentStage, + nodeStates, + project, + projectV2, + isFixActionable, +}: NodeRendererStateOptions) { + const tooltipHtml = ref(new Map()) + const state = computed( + () => (nodeStates.value[currentStage.value.id] ?? {}) as Record, + ) + 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 = 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() + 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 + ? `
${renderHighlightedString(expanded)}
` + : '', + ) + } 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 } +} diff --git a/apps/frontend/src/components/ui/moderation/moderation-checklist/use-persistence.ts b/apps/frontend/src/components/ui/moderation/moderation-checklist/use-persistence.ts new file mode 100644 index 0000000000..396ebede4c --- /dev/null +++ b/apps/frontend/src/components/ui/moderation/moderation-checklist/use-persistence.ts @@ -0,0 +1,144 @@ +import type { NodeState, StageNode } from '@modrinth/moderation/src/types/node' +import type { ComputedRef, Ref } from 'vue' +import { ref, toRaw, watch } from 'vue' + +import { + getSessionChecklistState, + patchSessionChecklistState, +} from '~/services/moderation/checklist-session-storage.ts' +import { + clearChecklistState, + loadChecklistState, + saveChecklistState, +} from '~/services/moderation/checklist-storage.ts' + +export async function loadChecklistPersistence(projectId: string) { + const persistedState = import.meta.client ? await loadChecklistState(projectId) : null + const activatedStages = ref>(new Set(persistedState?.activatedStages ?? [])) + const visitedStages = ref>( + new Set( + import.meta.client ? (getSessionChecklistState(projectId).visitedStages ?? []) : [], + ), + ) + const reviewedAnyway = ref(persistedState?.reviewAnyway ?? false) + const message = ref(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>> + activatedStages: Ref> + reviewedAnyway: Ref + message: Ref + currentStage: Ref + currentStageNode: ComputedRef + 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 | 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 } +} diff --git a/apps/frontend/src/components/ui/moderation/moderation-checklist/use-submission.ts b/apps/frontend/src/components/ui/moderation/moderation-checklist/use-submission.ts new file mode 100644 index 0000000000..c6402056a2 --- /dev/null +++ b/apps/frontend/src/components/ui/moderation/moderation-checklist/use-submission.ts @@ -0,0 +1,91 @@ +import type { Labrinth } from '@modrinth/api-client' +import type { ActiveAction } from '@modrinth/moderation/src/types/node' +import { createTrackedPatch, hasCap } from '@modrinth/moderation/src/types/node' +import type { FixBuilder } from '@modrinth/moderation/src/types/node/fix' +import { injectModrinthClient } from '@modrinth/ui' +import type { ProjectStatus } from '@modrinth/utils' +import { useMutation } from '@tanstack/vue-query' +import type { Ref } from 'vue' + +interface ModerationSubmissionOptions { + project: Ref + projectV2: Ref + versions: Ref +} + +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 + }, + }) +} diff --git a/apps/frontend/src/components/ui/moderation/checklist/ModpackPermissionsFlow.vue b/apps/frontend/src/components/ui/moderation/modpack-permissions-flow/index.vue similarity index 99% rename from apps/frontend/src/components/ui/moderation/checklist/ModpackPermissionsFlow.vue rename to apps/frontend/src/components/ui/moderation/modpack-permissions-flow/index.vue index f99e3a2b12..19217a6e58 100644 --- a/apps/frontend/src/components/ui/moderation/checklist/ModpackPermissionsFlow.vue +++ b/apps/frontend/src/components/ui/moderation/modpack-permissions-flow/index.vue @@ -548,9 +548,7 @@ function getModpackFiles(): { } } -defineExpose({ - getModpackFiles, -}) +defineExpose({ getModpackFiles })