Feat: Many moderation checklist changes (#6730)

* Rule 1.x, 3.2 and 3.3x support

* reorganize

* Kinda Jank dynamic list stuff that should probably be implemented better but its 1:30 am

* Checklist Stage Title

* Make Stage Title Good

* Rule 1.x, 3.2 and 3.3x support

* reorganize

* Kinda Jank dynamic list stuff that should probably be implemented better but its 1:30 am

* Checklist Stage Title

* Make Stage Title Good

* oops i forgot 3.1

* Insufficient description improvement test

* "i forgor" checklist tooltips tooltips (kinda jank but they work)
update to latest

* only use indexedDb for moderation storage, localStorage forces a bunch of cringe

* store moderation stuff separately + compact checklist stuff

* give stages actual titles + rename what was previously called titles to hints

* Title & Slug, Summary Description, Links, License, Gallery, Versions and Reupload migrated to new system™️

* put stageoptions before nodes

* do state and variablse in tooltips

* checklist fully migrated to new system, not much new has been added (yet)

* forgot to only do modpack permissions for modpacks

* Idk why I wasn't just mutating the builder

* actual node type hierarchy + action abstraction + improve message/text handling

* dropdown + "functional" quick fixes

* dropdown + "functional" quick fixes

* Allow moderators to suggest correct environment.

* remove vestigial function

* Prioritization™️

* quickest fixes in the west

* start of metadata stage + optimize checklist parsing + a couple of qol features

* futureproof™️ environment metadata + just give me project context everywhere

* tidy up after today's refactoring

* tidy up after tidying up after today's refactoring

* move loaders to metadata (from versions)

* better required display + vueify + reorganize messages + tag quick fixes + todo

* fix log spam + concat md

* more bugs

* nested state access

* update description stage, add showcase clarity

* Fix links stage mostly, and tweak metadata stage.

* fix links lag + actions on stages

* setup links bonus buttons

* Change stage order

* Update rules stage

* Move conditional stages to the top where they belong.

* fix some metadata stuff

* fix stage selector

* don't show non-english buttons on servers without english support.

* prepr

* stop linter from trying to break my markdown

* fix: some issues

* fix: lint

* not sure why but link's links weren't links

* fix quick fixes (again) (idk how they keep breaking)

* make messages only actually count as messages if they actually have content

* rename horror button to spoilers button

* Update links name to match the links settings

* re-review stage

* unbreak stage selector

* passing off version stage fixing.

* actually fix the stage selector this time

* fix showcase clarity msg

* redirect when checklist updates slug
fix initial stage not being first if first stage is only shown under certain conditions
move to a consistent naming scheme

* redirect when checklist updates slug
fix initial stage not being first if first stage is only shown under certain conditions
move to a consistent naming scheme

* ok maybe lets use kebab

* move custom description advice stage to first button.

* temp priority on corrections applied

* make metadata page go to non setting versions page

* links stage bonus buttons, src required flag, show link value in msgs, fix msg groupings.

* update re-review button

* reupload stage navigates to project Description.

* fix: link stage using page context too late

* chore: run prepr

* fix(ci): tell typo check to ignore moderation utils

* fix: pr comment if storybook fails

* make complex nodes less complex (for the most part)
fix re-review stage shown condition
new fancy message collection system
all buttons that contribute to messages now have relevant tooltips
don't store default values in checklist db

* make complex nodes less complex (for the most part)
fix re-review stage shown condition
new fancy message collection system
all buttons that contribute to messages now have relevant tooltips
don't store default values in checklist db

* maybe fixed markdown escaping?

* tweak re-review wording.

* Update license stage handling and info

* add mixed option back for env info

* fix: formatEnvironments
fix: formatEnvironments

* update metadata environment group shown

* fix: status alert priority

* fix: display suggested slug in slug/misused message

* actually navigate when we change project slug

* ok like redirect but like don't lose state when slug changes :smart:

* make checklist reset button reset only current stage if there's modifications to it instead of full checklist

* fix versions stage messages not working

* fix donation links not being in a column

* I think slug stuff is working?
also like actually 100% garunteed markdown escaping works now

* prepr

* pseudo stages no longer interact with non-pseudo stages at all
also query projects better I think?
fix repetitive message spam in links stage

* apply quick fixes before refreshing page

* differentiate reset and return to start

* remove random empty lines in some situations

* better inaccurate slug message

* slug correction input reset button resets to current slug again instead of auto

* better keybind navigation

* incorrect environment message tweak

* use environment name(s) in checklist info

* prepr

---------

Signed-off-by: Prospector <6166773+Prospector@users.noreply.github.com>
Co-authored-by: chyzman <chyzalt@gmail.com>
Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
Co-authored-by: Gravy Boat <gravy@thatgravyboat.tech>
Co-authored-by: Michael H. <michael@iptables.sh>
Co-authored-by: chyz <32403637+chyzman@users.noreply.github.com>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
coolbot
2026-07-20 20:52:46 +00:00
committed by GitHub
co-authored by chyzman Calum H. Gravy Boat Michael H. chyz Prospector
parent 30e23e5cc2
commit e2085c43c5
205 changed files with 4689 additions and 3574 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,780 @@
<script lang="ts" setup>
import type {
BooleanNodeBuilder,
ButtonNodeBuilder,
ChildNode,
DropdownNodeBuilder,
GroupNodeBuilder,
IdentifiedNodeBuilder,
InputNodeBuilder,
LabeledNodeBuilder,
NodeState,
NodeStateWithChildren,
OverrideValue,
ValueNodeBuilder,
} from '@modrinth/moderation'
import {
evalSegment,
expandVariables,
flattenProjectV3Variables,
flattenProjectVariables,
flattenStaticVariables,
getBooleanChildState,
NodeBuilder,
resolve,
resolveChildren,
setMessageProject,
} from '@modrinth/moderation'
import {
ButtonStyled,
Checkbox,
Combobox,
injectProjectPageContext,
MarkdownEditor,
StyledInput,
} from '@modrinth/ui'
import { renderHighlightedString, renderString } from '@modrinth/utils'
import { inject, nextTick, onMounted, reactive, watchEffect } from 'vue'
import { NODE_META_KEY, STATE_KEY } from './checklist-context'
const nodeMetaMap = inject(NODE_META_KEY)
const injectedGlobalState = inject(STATE_KEY)
const { projectV3: project, projectV2 } = injectProjectPageContext()
setMessageProject(project, projectV2)
const props = defineProps<{
nodes: ChildNode[]
showContext: Record<string, NodeState>
onImageUpload?: (file: File) => Promise<string>
flex?: boolean
titleDepth?: number
parentStatePath?: string[]
}>()
function titleClass(depth: number): string {
if (depth === 0) return 'text-lg font-extrabold text-contrast'
if (depth === 1) return 'text-base font-semibold'
if (depth === 2) return 'text-sm font-semibold'
return ''
}
function isVisible(node: NodeBuilder): boolean {
if (node._shown !== undefined) return resolve(node._shown)
if (node.type === 'group') {
const children = getChildren(node as IdentifiedNodeBuilder)
return children.some((c) => !(c instanceof NodeBuilder) || isVisible(c))
}
return true
}
function isEnabled(node: IdentifiedNodeBuilder): boolean {
const e = node._enabled
if (e === undefined) return true
if (typeof e === 'function') return e(props.showContext)
return resolve(e)
}
function isButtonEnabled(node: ButtonNodeBuilder): boolean {
const enabled = node._enabled
if (enabled === undefined) return true
if (typeof enabled === 'function') return enabled(props.showContext)
return resolve(enabled)
}
function asBool(node: NodeBuilder): BooleanNodeBuilder {
return node as BooleanNodeBuilder
}
function asButton(node: NodeBuilder): ButtonNodeBuilder {
return node as ButtonNodeBuilder
}
function asIdentified(node: NodeBuilder): IdentifiedNodeBuilder {
return node as IdentifiedNodeBuilder
}
function asLabeled(node: NodeBuilder): LabeledNodeBuilder {
return node as LabeledNodeBuilder
}
function asGroup(node: NodeBuilder): GroupNodeBuilder {
return node as GroupNodeBuilder
}
function asDropdown(node: NodeBuilder): DropdownNodeBuilder {
return node as DropdownNodeBuilder
}
function asInput(node: NodeBuilder): InputNodeBuilder {
return node as InputNodeBuilder
}
function getAtPath(path: string[]): NodeState {
let current: unknown = injectedGlobalState!.value
for (const key of path) {
if (current == null || typeof current !== 'object' || current instanceof Set) return undefined
current = (current as Record<string, unknown>)[key]
}
return current as NodeState
}
function setAtPath(path: string[], value: NodeState): void {
if (path.length === 0) return
const global = injectedGlobalState!.value as unknown as Record<string, unknown>
let current = global
const stack: [Record<string, unknown>, string][] = []
for (let i = 0; i < path.length - 1; i++) {
const key = path[i]
const next = current[key]
if (!next || typeof next !== 'object' || next instanceof Set) {
if (value === undefined) return
current[key] = next !== null && next !== undefined ? { value: next } : {}
current = current[key] as Record<string, unknown>
} else {
stack.push([current, key])
current = next as Record<string, unknown>
}
}
const lastKey = path[path.length - 1]
if (value === undefined) {
Reflect.deleteProperty(current, lastKey)
for (let i = stack.length - 1; i >= 0; i--) {
const [parent, key] = stack[i]
const child = parent[key]
if (
child &&
typeof child === 'object' &&
!(child instanceof Set) &&
Object.keys(child as object).length === 0
) {
Reflect.deleteProperty(parent, key)
} else {
break
}
}
} else {
current[lastKey] = value as unknown
}
}
function getNodeState(node: IdentifiedNodeBuilder): NodeState {
return node._statePath ? getAtPath(node._statePath) : undefined
}
function setNodeState(node: IdentifiedNodeBuilder, value: NodeState): void {
if (node._statePath) setAtPath(node._statePath, value)
}
function getBooleanState(node: BooleanNodeBuilder): boolean {
const state = getNodeState(node)
if (typeof state === 'boolean') return state
if (state && typeof state === 'object' && !(state instanceof Set)) {
const v = (state as NodeStateWithChildren).value
if (typeof v === 'boolean') return v
}
const def = resolveDefault(node)
return (def as boolean | undefined) ?? false
}
function getMultiSelectState(node: IdentifiedNodeBuilder): Set<string> {
const state = getNodeState(node)
return state instanceof Set ? state : new Set<string>()
}
function getSelectState(node: IdentifiedNodeBuilder): string | undefined {
const state = getNodeState(node)
if (typeof state === 'string') return state
const def = resolveDefault(node as ValueNodeBuilder)
return typeof def === 'string' ? def : undefined
}
function getDropdownOptions(node: DropdownNodeBuilder) {
return [
...(node._none !== undefined ? [{ value: '', label: node._none }] : []),
...visibleChildren(node).map((c) => ({
value: asIdentified(c).id!,
label: asLabeled(c).label,
})),
]
}
function getDropdownModelValue(node: DropdownNodeBuilder) {
return getSelectState(node) ?? (node._none !== undefined ? '' : undefined)
}
function toggleSelect(parent: IdentifiedNodeBuilder, child: IdentifiedNodeBuilder) {
const current = getSelectState(parent)
setNodeState(parent, current === child.id ? undefined : child.id)
}
function resolveDefault(node: ValueNodeBuilder): NodeState {
const d = node._defaultValue
return typeof d === 'function' ? d(props.showContext) : d
}
function getTextState(node: IdentifiedNodeBuilder): string {
const state = getNodeState(node)
if (typeof state === 'string') return state
const def = resolveDefault(node as ValueNodeBuilder)
return typeof def === 'string' ? def : ''
}
function getNodeTitle(node: NodeBuilder): string | undefined {
if (node._title === undefined) return undefined
return resolve(node._title) || undefined
}
function getPlaceholder(node: InputNodeBuilder): string | undefined {
if (node._placeholder !== undefined) return resolve(node._placeholder)
const def = resolveDefault(node)
if (typeof def === 'string') return def
return undefined
}
function hasActionableFixes(node: IdentifiedNodeBuilder): boolean {
return nodeMetaMap?.value.get(node)?.isFixActionable ?? false
}
function hasRequiredMissingDescendants(node: IdentifiedNodeBuilder): boolean {
for (const child of getChildren(node)) {
if (!(child instanceof NodeBuilder)) continue
const identified = child as IdentifiedNodeBuilder
if (nodeMetaMap?.value.get(identified)?.hasRequiredMissing) return true
if (identified.id !== undefined && hasRequiredMissingDescendants(identified)) return true
}
return false
}
function nodeHasRequiredMissing(node: IdentifiedNodeBuilder): boolean {
return !!nodeMetaMap?.value.get(node)?.hasRequiredMissing || hasRequiredMissingDescendants(node)
}
function getBooleanColor(node: BooleanNodeBuilder): string {
if (!getBooleanState(node)) return 'standard'
if (hasRequiredMissingDescendants(node)) return 'orange'
return hasActionableFixes(node) ? 'blue' : 'brand'
}
function setTextState(node: IdentifiedNodeBuilder, v: string): void {
const inputNode = node as InputNodeBuilder
const result = inputNode._onChange?.(v, overrideHelpers)
if (isOverrideValue(result)) {
const ov = result.__override
const def = resolveDefault(node as ValueNodeBuilder)
const defStr = typeof def === 'string' ? def : ''
setNodeState(node, ov === defStr ? undefined : ov || undefined)
nextTick(() => textInputRefs.get(node)?.setValue(ov))
return
}
const def = resolveDefault(node as ValueNodeBuilder)
const defStr = typeof def === 'string' ? def : ''
setNodeState(node, v === defStr ? undefined : defStr ? v : v || undefined)
}
function handleButtonClick(node: ButtonNodeBuilder): void {
const before = new Map<NodeBuilder, string>()
for (const inputNode of textInputRefs.keys()) {
before.set(inputNode, getTextState(asIdentified(inputNode)))
}
node._onClick?.(props.showContext)
for (const [inputNode, beforeVal] of before) {
const after = getTextState(asIdentified(inputNode))
if (after !== beforeVal) setTextState(asIdentified(inputNode), after)
}
}
onMounted(() => {
for (const inputNode of textInputRefs.keys()) {
setTextState(asIdentified(inputNode), getTextState(asIdentified(inputNode)))
}
})
function toggleBoolean(node: BooleanNodeBuilder) {
const raw = getNodeState(node)
const next = !getBooleanState(node)
const defaultVal = (resolveDefault(node) as boolean | undefined) ?? false
const isDefault = next === defaultVal
if (raw && typeof raw === 'object' && !(raw instanceof Set)) {
const { value: _v, ...children } = raw as NodeStateWithChildren & Record<string, NodeState>
const hasChildren = Object.keys(children).length > 0
setNodeState(
node,
isDefault && !hasChildren
? undefined
: ({ ...children, ...(isDefault ? {} : { value: next }) } as NodeState),
)
} else {
setNodeState(node, isDefault ? undefined : next)
}
}
function toggleChip(parent: IdentifiedNodeBuilder, child: IdentifiedNodeBuilder) {
const selected = new Set(getMultiSelectState(parent))
if (selected.has(child.id!)) {
selected.delete(child.id!)
if (child._statePath) {
setAtPath(child._statePath, undefined)
}
} else {
selected.add(child.id!)
}
setNodeState(parent, selected.size > 0 ? selected : undefined)
}
const textInputRefs = new Map<NodeBuilder, { setValue: (v: string) => void }>()
const overrideHelpers = { override: (v: string): OverrideValue => ({ __override: v }) }
function isOverrideValue(v: unknown): v is OverrideValue {
return v !== null && typeof v === 'object' && '__override' in v
}
const scopedContextFallbacks = new WeakMap<IdentifiedNodeBuilder, Record<string, NodeState>>()
function childScopedContext(child: IdentifiedNodeBuilder): Record<string, NodeState> {
if (!child._statePath) return props.showContext
const basePath = child._statePath
const state = getAtPath(basePath)
if (state && typeof state === 'object' && !(state instanceof Set)) {
return state as Record<string, NodeState>
}
const existing = scopedContextFallbacks.get(child)
if (existing) return existing
const fallback = new Proxy({} as Record<string, NodeState>, {
set(_target, key, value) {
setAtPath([...basePath, key as string], value as NodeState)
return true
},
})
scopedContextFallbacks.set(child, fallback)
return fallback
}
function getChildrenContext(node: IdentifiedNodeBuilder): Record<string, NodeState> {
if (node.type === 'dropdown') return props.showContext
if (node.type === 'group' && asGroup(node)._selectMode) return props.showContext
return childScopedContext(node)
}
function getChildren(node: IdentifiedNodeBuilder): ChildNode[] {
return resolveChildren(node, getChildrenContext(node))
}
function visibleChildren(node: IdentifiedNodeBuilder): NodeBuilder[] {
return getChildren(node).filter((c): c is NodeBuilder => c instanceof NodeBuilder && isVisible(c))
}
const tooltipHtml = reactive(new Map<NodeBuilder, string>())
function getTooltipConfig(node: NodeBuilder, state?: Record<string, NodeState>) {
const t = node._tooltip
const manual = t === undefined ? undefined : typeof t === 'function' ? t(state ?? {}) : resolve(t)
if (manual)
return {
content: manual,
delay: { show: 500, hide: 0 },
triggers: ['hover', 'focus'],
placement: 'top',
}
const html = tooltipHtml.get(node)
if (!html) return undefined
return {
content: html,
html: true,
delay: { show: 500, hide: 0 },
triggers: ['hover', 'focus'],
placement: 'top',
}
}
watchEffect(async () => {
// Read all reactive state synchronously before any await so Vue tracks dependencies
const buttonTasks: Array<{ node: BooleanNodeBuilder; state: Record<string, NodeState> }> = []
for (const node of props.nodes) {
if (!(node instanceof NodeBuilder)) continue
if (node.type === 'toggle' && isVisible(node)) {
const boolNode = asBool(node)
if (boolNode._segments.some((s) => s.type !== 'collect')) {
const nodeState = getNodeState(boolNode)
const childState =
nodeState && typeof nodeState === 'object' && !(nodeState instanceof Set)
? (() => {
const { value: _v, ...rest } = nodeState as NodeStateWithChildren &
Record<string, NodeState>
return rest
})()
: {}
buttonTasks.push({ node: boolNode, state: childState })
}
}
if (node.type === 'group' && asGroup(node)._selectMode === 'multi' && isVisible(node)) {
for (const child of visibleChildren(asIdentified(node))) {
const opt = child as IdentifiedNodeBuilder
if (opt._segments.some((s) => s.type !== 'collect')) {
const childState = getBooleanChildState(getNodeState(opt)) as Record<string, NodeState>
buttonTasks.push({ node: opt as BooleanNodeBuilder, state: childState })
}
}
}
}
async function evalCollectedChildren(node: IdentifiedNodeBuilder): Promise<string> {
let result = ''
for (const child of getChildren(node)) {
if (!(child instanceof NodeBuilder)) continue
if (!isVisible(child)) continue
const childNode = asIdentified(child)
if (child.type === 'group') {
const grp = asGroup(childNode)
if (grp._selectMode === 'multi') {
const selected = getMultiSelectState(childNode)
for (const opt of getChildren(childNode)) {
if (!(opt instanceof NodeBuilder) || !isVisible(opt)) continue
const optNode = asIdentified(opt)
if (!optNode.id || !selected.has(optNode.id)) continue
result += await evalNodeTooltip(
optNode,
getBooleanChildState(getNodeState(optNode)) as Record<string, NodeState>,
)
}
} else if (grp._selectMode === 'single') {
const selected = getSelectState(childNode)
for (const opt of getChildren(childNode)) {
if (!(opt instanceof NodeBuilder) || !isVisible(opt)) continue
const optNode = asIdentified(opt)
if (optNode.id !== selected) continue
result += await evalNodeTooltip(
optNode,
getBooleanChildState(getNodeState(optNode)) as Record<string, NodeState>,
)
}
} else {
result += await evalCollectedChildren(childNode)
}
} else if (child.type === 'dropdown') {
const selected = getSelectState(childNode)
for (const opt of getChildren(childNode)) {
if (!(opt instanceof NodeBuilder) || !isVisible(opt)) continue
const optNode = asIdentified(opt)
if (optNode.id !== selected) continue
result += await evalNodeTooltip(
optNode,
getBooleanChildState(getNodeState(optNode)) as Record<string, NodeState>,
)
}
} else if (child.type === 'toggle' || child.type === 'check') {
if (!getBooleanState(asBool(childNode))) continue
result += await evalNodeTooltip(
childNode,
getBooleanChildState(getNodeState(childNode)) as Record<string, NodeState>,
)
}
}
return result
}
async function evalNodeTooltip(
node: IdentifiedNodeBuilder,
state: Record<string, NodeState>,
): Promise<string> {
let result = ''
for (const seg of node._segments) {
if (seg.type === 'collect') {
let collected = await evalCollectedChildren(node)
if (!collected.trim() && seg.fallback) {
collected = await evalSegment(seg.fallback, state, node._statePath ?? [])
}
result += collected
} else {
result += await evalSegment(seg, state, node._statePath ?? [])
}
}
return result
}
for (const { node, state } of buttonTasks) {
try {
const raw = await evalNodeTooltip(node as unknown as IdentifiedNodeBuilder, state)
const expanded = expandVariables(raw, projectV2.value, project.value, {
...flattenStaticVariables(),
...flattenProjectVariables(projectV2.value),
...flattenProjectV3Variables(project.value),
})
const trimmed = expanded.trim()
tooltipHtml.set(
node,
trimmed
? `<div class="markdown-body moderation-tooltip-markdown">${renderHighlightedString(trimmed)}</div>`
: '',
)
} catch {
tooltipHtml.set(node, '')
}
}
})
</script>
<template>
<div :class="flex ? 'flex flex-wrap gap-2' : 'space-y-4'">
<template
v-for="(item, idx) in nodes"
:key="
item instanceof NodeBuilder
? item.type === 'button'
? `button-${asButton(item).label}`
: (asIdentified(item)._statePath?.join('/') ?? asIdentified(item).id ?? item.type)
: typeof item === 'string'
? `s-${item}`
: `display-${idx}`
"
>
<!-- Display items: plain strings or zero-arg render functions -->
<template v-if="!(item instanceof NodeBuilder)">
<template v-if="typeof item === 'string'">{{ item }}</template>
<component :is="item" v-else />
</template>
<template v-else-if="isVisible(item)">
<div :class="item.type !== 'group' && !getNodeTitle(item) ? 'contents' : undefined">
<div v-if="getNodeTitle(item)" class="mb-2" :class="titleClass(titleDepth ?? 0)">
<span
v-html="renderString(getNodeTitle(item)!).replace(/^<p>([\s\S]*)<\/p>\n?$/, '$1')"
/><span v-if="nodeHasRequiredMissing(asIdentified(item))" class="text-red">*</span>
</div>
<!-- group -->
<template v-if="item.type === 'group'">
<!-- multi-select (chips) mode -->
<template v-if="asGroup(item)._selectMode === 'multi'">
<div class="flex flex-wrap gap-2">
<template
v-for="child in visibleChildren(asIdentified(item))"
:key="asIdentified(child).id"
>
<ButtonStyled
:color="
getMultiSelectState(asIdentified(item)).has(asIdentified(child).id!)
? hasActionableFixes(asIdentified(child))
? 'blue'
: 'brand'
: 'standard'
"
:circular="!!asIdentified(child)._icon"
@click="toggleChip(asIdentified(item), asIdentified(child))"
>
<button
v-tooltip="getTooltipConfig(asIdentified(child))"
:aria-label="asIdentified(child)._icon ? asLabeled(child).label : undefined"
>
<component :is="asIdentified(child)._icon" v-if="asIdentified(child)._icon" />
<template v-else>{{ asLabeled(child).label }}</template>
</button>
</ButtonStyled>
</template>
</div>
<template
v-for="child in visibleChildren(asIdentified(item))"
:key="`sub-${asIdentified(child).id}`"
>
<NodeRenderer
v-if="
getMultiSelectState(asIdentified(item)).has(asIdentified(child).id!) &&
getChildren(asIdentified(child)).length
"
:nodes="getChildren(asIdentified(child))"
:show-context="getChildrenContext(asIdentified(child))"
:on-image-upload="onImageUpload"
:title-depth="titleDepth"
:parent-state-path="asIdentified(child)._statePath ?? props.parentStatePath ?? []"
class="mt-2"
/>
</template>
</template>
<!-- single-select (button-style) mode -->
<template v-else-if="asGroup(item)._selectMode === 'single'">
<div class="flex flex-wrap gap-2">
<template
v-for="child in visibleChildren(asIdentified(item))"
:key="asIdentified(child).id"
>
<ButtonStyled
:color="
getSelectState(asIdentified(item)) === asIdentified(child).id
? 'brand'
: 'standard'
"
:circular="!!asIdentified(child)._icon"
>
<button
:aria-label="asIdentified(child)._icon ? asLabeled(child).label : undefined"
@click="toggleSelect(asIdentified(item), asIdentified(child))"
>
<component :is="asIdentified(child)._icon" v-if="asIdentified(child)._icon" />
<template v-else>{{ asLabeled(child).label }}</template>
</button>
</ButtonStyled>
</template>
</div>
<template
v-for="child in visibleChildren(asIdentified(item))"
:key="`sub-${asIdentified(child).id}`"
>
<NodeRenderer
v-if="
getSelectState(asIdentified(item)) === asIdentified(child).id &&
getChildren(asIdentified(child)).length
"
:nodes="getChildren(asIdentified(child))"
:show-context="getChildrenContext(asIdentified(child))"
:on-image-upload="onImageUpload"
:title-depth="titleDepth"
:parent-state-path="asIdentified(child)._statePath ?? props.parentStatePath ?? []"
class="mt-2"
/>
</template>
</template>
<!-- plain container mode -->
<NodeRenderer
v-else
:nodes="getChildren(asIdentified(item))"
:show-context="getChildrenContext(asIdentified(item))"
:on-image-upload="onImageUpload"
:flex="asGroup(item)._layout !== 'column'"
:title-depth="item._title !== undefined ? (titleDepth ?? 0) + 1 : titleDepth"
:parent-state-path="asIdentified(item)._statePath ?? props.parentStatePath ?? []"
/>
</template>
<!-- dropdown -->
<template v-else-if="item.type === 'dropdown'">
<Combobox
class="!w-80"
:options="getDropdownOptions(asDropdown(item))"
:model-value="getDropdownModelValue(asDropdown(item))"
trigger-class="!bg-[var(--color-button-bg)] !rounded-[var(--radius-md)] !shadow-[var(--shadow-inset-sm),0_0_0_0_transparent]"
dropdown-class="!rounded-[var(--radius-md)] !bg-[var(--color-button-bg)] !border-0"
@update:model-value="(v) => setNodeState(asIdentified(item), v || undefined)"
/>
<template
v-for="child in visibleChildren(asIdentified(item))"
:key="`sub-${asIdentified(child).id}`"
>
<NodeRenderer
v-if="
getSelectState(asIdentified(item)) === asIdentified(child).id &&
getChildren(asIdentified(child)).length
"
:nodes="getChildren(asIdentified(child))"
:show-context="getChildrenContext(asIdentified(child))"
:on-image-upload="onImageUpload"
:title-depth="titleDepth"
:parent-state-path="asIdentified(child)._statePath ?? props.parentStatePath ?? []"
class="mt-2"
/>
</template>
</template>
<!-- button -->
<template v-else-if="item.type === 'button'">
<ButtonStyled :circular="!!item._icon && !asButton(item).label">
<button
v-tooltip="getTooltipConfig(item, showContext)"
:disabled="!isButtonEnabled(asButton(item))"
:aria-label="item._icon && !asButton(item).label ? asButton(item).label : undefined"
@click="handleButtonClick(asButton(item))"
>
<component :is="item._icon" v-if="item._icon" />
{{ asButton(item).label }}
</button>
</ButtonStyled>
</template>
<!-- toggle -->
<template v-else-if="item.type === 'toggle'">
<ButtonStyled :color="getBooleanColor(asBool(item))" :circular="!!item._icon">
<button
v-tooltip="getTooltipConfig(asBool(item))"
:disabled="!isEnabled(asIdentified(item))"
:aria-label="item._icon ? asLabeled(item).label : undefined"
@click="toggleBoolean(asBool(item))"
>
<component :is="item._icon" v-if="item._icon" />
<template v-else>{{ asLabeled(item).label }}</template>
</button>
</ButtonStyled>
</template>
<!-- check -->
<template v-else-if="item.type === 'check'">
<Checkbox
:model-value="getBooleanState(asBool(item))"
:label="asLabeled(item).label"
:disabled="!isEnabled(asIdentified(item))"
@update:model-value="isEnabled(asIdentified(item)) && toggleBoolean(asBool(item))"
/>
</template>
<!-- text -->
<template v-else-if="item.type === 'text'">
<StyledInput
:id="`node-${asIdentified(item).id}`"
:ref="(el: any) => (el ? textInputRefs.set(item, el) : textInputRefs.delete(item))"
v-tooltip="getTooltipConfig(item, showContext)"
:model-value="getTextState(asIdentified(item))"
:placeholder="getPlaceholder(asInput(item))"
autocomplete="off"
@update:model-value="(v: string) => setTextState(asIdentified(item), v)"
/>
</template>
<!-- markdown -->
<template v-else-if="item.type === 'markdown'">
<MarkdownEditor
:id="`node-${asIdentified(item).id}`"
:aria-label="asLabeled(item).label || undefined"
:model-value="getTextState(asIdentified(item))"
:placeholder="getPlaceholder(asInput(item))"
:max-height="300"
:disabled="false"
:heading-buttons="false"
:on-image-upload="onImageUpload"
@update:model-value="(v: string) => setTextState(asIdentified(item), v)"
/>
</template>
</div>
</template>
</template>
<!-- children of active boolean nodes, rendered after all siblings -->
<template
v-for="(item, idx) in nodes"
:key="
item instanceof NodeBuilder
? item.type === 'button'
? `children-button-${asButton(item).label}`
: `children-${asIdentified(item)._statePath?.join('/') ?? asIdentified(item).id ?? item.type}`
: `children-display-${idx}`
"
>
<NodeRenderer
v-if="
item instanceof NodeBuilder &&
isVisible(item) &&
(item.type === 'toggle' || item.type === 'check') &&
getBooleanState(asBool(item)) &&
getChildren(asIdentified(item)).length
"
:nodes="getChildren(asIdentified(item))"
:show-context="getChildrenContext(asIdentified(item))"
:on-image-upload="onImageUpload"
:title-depth="item._title !== undefined ? (titleDepth ?? 0) + 1 : titleDepth"
:parent-state-path="asIdentified(item)._statePath ?? props.parentStatePath ?? []"
class="w-full"
/>
</template>
</div>
</template>
@@ -0,0 +1,23 @@
import type { IdentifiedNodeBuilder, NodeState } from '@modrinth/moderation'
import type { ComputedRef, InjectionKey, Ref } from 'vue'
export interface ActiveAction {
node: IdentifiedNodeBuilder
state: Record<string, NodeState>
statePath: string[]
}
export interface LiveNode {
isActive: boolean
isVisible: boolean
isFixActionable: boolean
messageCount: number
fixCount: number
hasRequiredMissing: boolean
activeActions: ActiveAction[]
}
export const NODE_META_KEY: InjectionKey<ComputedRef<Map<IdentifiedNodeBuilder, LiveNode>>> =
Symbol('nodeMeta')
export const STATE_KEY: InjectionKey<Ref<Record<string, Record<string, NodeState>>>> =
Symbol('checklistState')
+13 -15
View File
@@ -665,10 +665,7 @@ import { STALE_TIME, STALE_TIME_LONG } from '~/composables/queries/project'
import { versionQueryOptions } from '~/composables/queries/version'
import { userCollectProject, userFollowProject } from '~/composables/user.js'
import { injectCurrentProjectId } from '~/providers/current-project.ts'
import {
loadChecklistOpenState,
saveChecklistOpenState,
} from '~/services/moderation-checklist-storage.ts'
import { loadChecklistState } from '~/services/moderation-checklist-storage.ts'
import { useModerationQueue } from '~/services/moderation-queue.ts'
import { getReportPath, reportProject } from '~/utils/report-helpers.ts'
@@ -1212,6 +1209,12 @@ const { data: organizationRaw } = useQuery({
// Return null when the project no longer belongs to an organization.
const organization = computed(() => (projectRaw.value?.organization ? organizationRaw.value : null))
const { data: thread } = useQuery({
queryKey: computed(() => ['thread', projectRaw.value?.thread_id]),
queryFn: () => client.labrinth.threads_v3.getThread(projectRaw.value.thread_id),
enabled: computed(() => !!projectRaw.value?.thread_id),
})
const isSettings = computed(() => route.name.startsWith('type-project-settings'))
// Transform versionsV3 to be same shape as versionsV2 for compatibility in project pages
@@ -2001,11 +2004,8 @@ function consumeShowChecklistHistoryState() {
return true
}
function setModerationChecklistOpen(open, projectId = project.value?.id) {
function setModerationChecklistOpen(open) {
showModerationChecklist.value = open
if (projectId) {
void saveChecklistOpenState(projectId, open)
}
}
function isProjectInActiveModerationQueue(projectId = project.value?.id) {
@@ -2047,21 +2047,17 @@ watch(
return
}
const storedOpen = await loadChecklistOpenState(projectId)
const storedState = await loadChecklistState(projectId)
if (cancelled) return
if (storedOpen !== null) {
showModerationChecklist.value = storedOpen
if (storedState !== null) {
showModerationChecklist.value = storedState.open ?? false
return
}
const shouldRecoverFromQueue =
moderationQueue.isQueueMode && moderationQueue.getCurrentProjectId() === projectId
showModerationChecklist.value = shouldRecoverFromQueue
if (shouldRecoverFromQueue) {
void saveChecklistOpenState(projectId, true)
}
},
{ immediate: true },
)
@@ -2145,6 +2141,8 @@ provideProjectPageContext({
dependenciesLoading: computed(() => dependenciesLoading.value),
cdnDownloadReason: readonly(downloadReason),
thread,
// Invalidate all project queries (auto-refetches active ones)
invalidate: invalidateProject,
@@ -150,12 +150,14 @@ import {
useVIntl,
} from '@modrinth/ui'
import { isStaff } from '@modrinth/utils'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { useQueryClient } from '@tanstack/vue-query'
import { computed, watch } from 'vue'
import ConversationThread from '~/components/ui/thread/ConversationThread.vue'
import { getProjectLink, isApproved, isRejected, isUnderReview } from '~/helpers/projects.js'
defineEmits(['on-download', 'delete-version'])
const { formatMessage } = useVIntl()
const flags = useFeatureFlags()
@@ -207,7 +209,13 @@ const messages = defineMessages({
})
const { addNotification } = injectNotificationManager()
const { projectV2: project, currentMember, invalidate, allMembers } = injectProjectPageContext()
const {
projectV2: project,
currentMember,
invalidate,
allMembers,
thread,
} = injectProjectPageContext()
const canAccess = computed(() => !!currentMember.value)
const userFacingUiVisible = computed(() => !!currentMember.value && moderatorSeeUserUi.value)
@@ -424,12 +432,7 @@ watch(
const auth = await useAuth()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const { data: thread, isPending: pending } = useQuery({
queryKey: computed(() => ['thread', project.value?.thread_id]),
queryFn: () => client.labrinth.threads_v3.getThread(project.value.thread_id),
enabled: computed(() => !!project.value?.thread_id),
})
const pending = computed(() => thread.value === undefined)
function updateThread(newThread: Labrinth.Threads.v3.Thread | null | undefined) {
const threadId = newThread?.id ?? project.value?.thread_id
@@ -1,596 +1,118 @@
import {
type ActionState,
deserializeActionStates,
serializeActionStates,
} from '@modrinth/moderation'
import type { NodeState } from '@modrinth/moderation'
interface PersistedChecklistValue<T> {
version: 1
import { dbDelete, dbGet, dbPut, dbScan } from './moderation-db.ts'
export interface PersistedChecklistState {
savedAt: string
value: T
open?: boolean
reviewAnyway?: boolean
stage?: string
message?: string
state?: Record<string, Record<string, NodeState>>
}
export interface ModerationChecklistGeneratedMessageState {
generated: boolean
message: string
}
const DB_NAME = 'modrinth-moderation'
const DB_VERSION = 1
const STORE_NAME = 'kv'
const CHECKLIST_OPEN_KEY_PREFIX = 'show-moderation-checklist-'
const STAGE_KEY_PREFIX = 'moderation-stage-'
const ACTION_STATES_KEY_PREFIX = 'moderation-actions-'
const TEXT_INPUTS_KEY_PREFIX = 'moderation-inputs-'
const GENERATED_MESSAGE_KEY_PREFIX = 'moderation-generated-message-'
const STORE = 'checklist'
const CHECKLIST_STATE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000
const CHECKLIST_CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000
const CHECKLIST_CLEANUP_LAST_RUN_KEY = 'moderation-checklist-cleanup:last-run'
const CHECKLIST_STATE_KEY_PREFIXES = [
CHECKLIST_OPEN_KEY_PREFIX,
STAGE_KEY_PREFIX,
ACTION_STATES_KEY_PREFIX,
TEXT_INPUTS_KEY_PREFIX,
GENERATED_MESSAGE_KEY_PREFIX,
]
const indexedDbSaveChains = new Map<string, Promise<void>>()
const saveChain = new Map<string, Promise<void>>()
let checklistCleanupPromise: Promise<void> | null = null
let checklistCleanupLastRunAt = 0
export function createEmptyGeneratedMessageState(): ModerationChecklistGeneratedMessageState {
return {
generated: false,
message: '',
}
function isPersistedChecklistState(value: unknown): value is PersistedChecklistState {
if (!value || typeof value !== 'object') return false
const v = value as PersistedChecklistState
if (typeof v.savedAt !== 'string') return false
if (v.stage !== undefined && typeof v.stage !== 'string') return false
if (v.message !== undefined && typeof v.message !== 'string') return false
if (v.state !== undefined && typeof v.state !== 'object') return false
return true
}
function hasIndexedDb(): boolean {
return typeof window !== 'undefined' && typeof indexedDB !== 'undefined'
function isStale(savedAt: string, now = Date.now()): boolean {
const time = Date.parse(savedAt)
return !Number.isNaN(time) && now - time > CHECKLIST_STATE_MAX_AGE_MS
}
function getLocalStorage(): Storage | null {
if (typeof window === 'undefined') return null
try {
return window.localStorage
} catch {
return null
}
}
function openDatabase(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION)
request.onupgradeneeded = () => {
const db = request.result
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME)
}
}
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error ?? new Error('Failed to open IndexedDB'))
request.onblocked = () => reject(new Error('IndexedDB open request blocked'))
})
}
function requestToPromise<T>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed'))
})
}
function wrapValue<T>(value: T, savedAt = new Date().toISOString()): PersistedChecklistValue<T> {
return {
version: 1,
savedAt,
value,
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object'
}
function isPersistedValue<T>(
value: unknown,
isValue: (value: unknown) => value is T,
): value is PersistedChecklistValue<T> {
if (!isRecord(value)) return false
if (value.version !== 1) return false
if (typeof value.savedAt !== 'string') return false
return isValue(value.value)
}
function isBoolean(value: unknown): value is boolean {
return typeof value === 'boolean'
}
function isNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value)
}
function isString(value: unknown): value is string {
return typeof value === 'string'
}
function isGeneratedMessageState(
value: unknown,
): value is ModerationChecklistGeneratedMessageState {
if (!isRecord(value)) return false
return typeof value.generated === 'boolean' && typeof value.message === 'string'
}
function sanitizeStage(value: number): number {
return Math.max(0, Math.trunc(value))
}
function sanitizeTextInputs(value: unknown): Record<string, string> | null {
if (!isRecord(value)) return null
const result: Record<string, string> = {}
for (const [key, entry] of Object.entries(value)) {
if (typeof entry === 'string') {
result[key] = entry
}
}
return result
}
function normalizeChecklistOpen(value: unknown): PersistedChecklistValue<boolean> | null {
if (isPersistedValue(value, isBoolean)) return value
if (isBoolean(value)) return wrapValue(value, '')
return null
}
function normalizeStage(value: unknown): PersistedChecklistValue<number> | null {
if (isPersistedValue(value, isNumber)) {
return {
...value,
value: sanitizeStage(value.value),
}
}
if (isNumber(value)) return wrapValue(sanitizeStage(value), '')
return null
}
function normalizeActionStates(
value: unknown,
): PersistedChecklistValue<Record<string, ActionState>> | null {
if (isRecord(value) && value.version === 1 && typeof value.savedAt === 'string') {
if (isString(value.value)) {
return {
version: 1,
savedAt: value.savedAt,
value: deserializeActionStates(value.value),
}
}
if (isRecord(value.value)) {
return {
version: 1,
savedAt: value.savedAt,
value: deserializeActionStates(JSON.stringify(value.value)),
}
}
}
if (isString(value)) return wrapValue(deserializeActionStates(value), '')
if (isRecord(value)) return wrapValue(deserializeActionStates(JSON.stringify(value)), '')
return null
}
function normalizeTextInputs(
value: unknown,
): PersistedChecklistValue<Record<string, string>> | null {
if (isRecord(value) && value.version === 1 && typeof value.savedAt === 'string') {
const textInputs = sanitizeTextInputs(value.value)
if (textInputs) {
return {
version: 1,
savedAt: value.savedAt,
value: textInputs,
}
}
}
const textInputs = sanitizeTextInputs(value)
return textInputs ? wrapValue(textInputs, '') : null
}
function normalizeGeneratedMessage(
value: unknown,
): PersistedChecklistValue<ModerationChecklistGeneratedMessageState> | null {
if (isPersistedValue(value, isGeneratedMessageState)) return value
if (isGeneratedMessageState(value)) return wrapValue(value, '')
return null
}
function savedAtTime<T>(state: PersistedChecklistValue<T>): number {
const time = Date.parse(state.savedAt)
return Number.isNaN(time) ? 0 : time
}
function newestState<T>(
first: PersistedChecklistValue<T> | null,
second: PersistedChecklistValue<T> | null,
): PersistedChecklistValue<T> | null {
if (!first) return second
if (!second) return first
return savedAtTime(second) > savedAtTime(first) ? second : first
}
function isChecklistStateKey(key: string): boolean {
return CHECKLIST_STATE_KEY_PREFIXES.some((prefix) => key.startsWith(prefix))
}
function isStaleState<T>(
state: PersistedChecklistValue<T>,
now = Date.now(),
maxAgeMs = CHECKLIST_STATE_MAX_AGE_MS,
): boolean {
const savedAt = savedAtTime(state)
if (savedAt === 0) return false
return now - savedAt > maxAgeMs
}
function isStaleRawState(value: unknown, now = Date.now()): boolean {
if (!isRecord(value)) return false
if (value.version !== 1 || typeof value.savedAt !== 'string') return false
const savedAt = Date.parse(value.savedAt)
if (Number.isNaN(savedAt)) return false
return now - savedAt > CHECKLIST_STATE_MAX_AGE_MS
}
async function loadFromIndexedDb<T>(
key: string,
normalize: (value: unknown) => PersistedChecklistValue<T> | null,
): Promise<PersistedChecklistValue<T> | null> {
if (!hasIndexedDb()) return null
const db = await openDatabase()
try {
const tx = db.transaction(STORE_NAME, 'readonly')
const store = tx.objectStore(STORE_NAME)
return normalize(await requestToPromise(store.get(key)))
} finally {
db.close()
}
}
async function cleanupIndexedDb(now = Date.now()): Promise<void> {
if (!hasIndexedDb()) return
const db = await openDatabase()
try {
const tx = db.transaction(STORE_NAME, 'readwrite')
const store = tx.objectStore(STORE_NAME)
const request = store.openCursor()
await new Promise<void>((resolve, reject) => {
request.onsuccess = () => {
const cursor = request.result
if (!cursor) return
const key = typeof cursor.key === 'string' ? cursor.key : null
if (key && isChecklistStateKey(key) && isStaleRawState(cursor.value, now)) {
cursor.delete()
}
cursor.continue()
}
request.onerror = () => reject(request.error ?? new Error('IndexedDB cursor failed'))
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error ?? new Error('IndexedDB transaction failed'))
async function cleanupStaleStates(now = Date.now()): Promise<void> {
const entries = await dbScan<unknown>(STORE)
const staleKeys = entries
.filter(({ value }) => {
if (!isPersistedChecklistState(value)) return false
return isStale(value.savedAt, now)
})
} finally {
db.close()
}
}
async function saveToIndexedDb<T>(key: string, state: PersistedChecklistValue<T>): Promise<void> {
if (!hasIndexedDb()) return
const db = await openDatabase()
try {
const tx = db.transaction(STORE_NAME, 'readwrite')
tx.objectStore(STORE_NAME).put(state, key)
await new Promise<void>((resolve, reject) => {
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error ?? new Error('IndexedDB transaction failed'))
})
} finally {
db.close()
}
}
async function clearIndexedDbKey(key: string): Promise<void> {
if (!hasIndexedDb()) return
const db = await openDatabase()
try {
const tx = db.transaction(STORE_NAME, 'readwrite')
tx.objectStore(STORE_NAME).delete(key)
await new Promise<void>((resolve, reject) => {
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error ?? new Error('IndexedDB transaction failed'))
})
} finally {
db.close()
}
}
async function saveToIndexedDbInOrder<T>(
key: string,
state: PersistedChecklistValue<T>,
): Promise<void> {
const run = () => saveToIndexedDb(key, state)
const result = (indexedDbSaveChains.get(key) ?? Promise.resolve()).then(run, run)
indexedDbSaveChains.set(
key,
result.then(
() => undefined,
() => undefined,
),
)
return result
}
async function clearIndexedDbKeyInOrder(key: string): Promise<void> {
const run = () => clearIndexedDbKey(key)
const result = (indexedDbSaveChains.get(key) ?? Promise.resolve()).then(run, run)
indexedDbSaveChains.set(
key,
result.then(
() => undefined,
() => undefined,
),
)
return result
}
function loadFromLocalStorage<T>(
key: string,
normalize: (value: unknown) => PersistedChecklistValue<T> | null,
): PersistedChecklistValue<T> | null {
const storage = getLocalStorage()
if (!storage) return null
const raw = storage.getItem(key)
if (!raw) return null
try {
const parsed: unknown = JSON.parse(raw)
const state = normalize(parsed)
if (state) return state
} catch (error) {
console.debug('Failed to parse moderation checklist state from localStorage:', error)
}
try {
storage.removeItem(key)
} catch (error) {
console.debug('Failed to clear moderation checklist state from localStorage:', error)
}
return null
}
function safeSaveLocalStorage<T>(key: string, state: PersistedChecklistValue<T>): void {
try {
getLocalStorage()?.setItem(key, JSON.stringify(state))
} catch (error) {
console.debug('Failed to save moderation checklist state to localStorage:', error)
}
}
function safeClearLocalStorage(key: string): void {
try {
getLocalStorage()?.removeItem(key)
} catch (error) {
console.debug('Failed to clear moderation checklist state from localStorage:', error)
}
}
function cleanupLocalStorage(now = Date.now()): void {
const storage = getLocalStorage()
if (!storage) return
const keysToRemove: string[] = []
for (let index = 0; index < storage.length; index++) {
const key = storage.key(index)
if (!key || !isChecklistStateKey(key)) continue
const raw = storage.getItem(key)
if (!raw) continue
try {
if (isStaleRawState(JSON.parse(raw), now)) {
keysToRemove.push(key)
}
} catch {
keysToRemove.push(key)
}
}
keysToRemove.forEach((key) => safeClearLocalStorage(key))
.map(({ key }) => key)
await Promise.all(staleKeys.map((key) => dbDelete(STORE, key)))
}
function scheduleStaleChecklistCleanup(): void {
if (!import.meta.client || checklistCleanupPromise) return
const storage = getLocalStorage()
const now = Date.now()
const persistedLastRun = Number(storage?.getItem(CHECKLIST_CLEANUP_LAST_RUN_KEY) ?? 0)
const lastRun = Math.max(
checklistCleanupLastRunAt,
Number.isFinite(persistedLastRun) ? persistedLastRun : 0,
)
if (Number.isFinite(lastRun) && now - lastRun < CHECKLIST_CLEANUP_INTERVAL_MS) return
if (now - checklistCleanupLastRunAt < CHECKLIST_CLEANUP_INTERVAL_MS) return
checklistCleanupLastRunAt = now
try {
storage?.setItem(CHECKLIST_CLEANUP_LAST_RUN_KEY, String(now))
} catch (error) {
console.debug('Failed to save moderation checklist cleanup timestamp:', error)
}
checklistCleanupPromise = (async () => {
cleanupLocalStorage(now)
try {
await cleanupIndexedDb(now)
} catch (error) {
console.debug('Failed to cleanup stale moderation checklist state from IndexedDB:', error)
}
})().finally(() => {
checklistCleanupPromise = null
})
checklistCleanupPromise = cleanupStaleStates(now)
.catch((error) => {
console.debug('Failed to cleanup stale moderation checklist states from IndexedDB:', error)
})
.finally(() => {
checklistCleanupPromise = null
})
}
async function loadState<T>(
key: string,
normalize: (value: unknown) => PersistedChecklistValue<T> | null,
touch = true,
): Promise<T | null> {
if (!import.meta.client) return null
async function enqueueOp(projectId: string, op: () => Promise<void>): Promise<void> {
const result = (saveChain.get(projectId) ?? Promise.resolve()).then(op, op)
saveChain.set(
projectId,
result.then(
() => undefined,
() => undefined,
),
)
return result
}
export async function loadChecklistState(
projectId: string,
): Promise<PersistedChecklistState | null> {
if (!import.meta.client) return null
scheduleStaleChecklistCleanup()
let indexedDbState: PersistedChecklistValue<T> | null = null
try {
indexedDbState = await loadFromIndexedDb(key, normalize)
const raw = await dbGet<unknown>(STORE, projectId)
if (!isPersistedChecklistState(raw)) return null
if (isStale(raw.savedAt)) {
await clearChecklistState(projectId)
return null
}
return raw
} catch (error) {
console.debug('Failed to load moderation checklist state from IndexedDB:', error)
}
let localStorageState: PersistedChecklistValue<T> | null = null
try {
localStorageState = loadFromLocalStorage(key, normalize)
} catch (error) {
console.debug('Failed to load moderation checklist state from localStorage:', error)
}
const state = newestState(indexedDbState, localStorageState)
if (!state) return null
if (isStaleState(state)) {
await clearState(key)
console.debug('Failed to load checklist state from IndexedDB:', error)
return null
}
if (touch) {
void saveState(key, state.value)
}
return state.value
}
async function saveState<T>(key: string, value: T): Promise<void> {
export async function saveChecklistState(
projectId: string,
state: Omit<PersistedChecklistState, 'savedAt'>,
): Promise<void> {
if (!import.meta.client) return
scheduleStaleChecklistCleanup()
const state = wrapValue(value)
safeSaveLocalStorage(key, state)
if (hasIndexedDb()) {
try {
await saveToIndexedDbInOrder(key, state)
} catch (error) {
console.debug('Failed to save moderation checklist state to IndexedDB:', error)
}
const record: PersistedChecklistState = { ...state, savedAt: new Date().toISOString() }
try {
await enqueueOp(projectId, () => dbPut(STORE, projectId, record))
} catch (error) {
console.debug('Failed to save checklist state to IndexedDB:', error)
}
}
async function clearState(key: string): Promise<void> {
export async function clearChecklistState(projectId: string): Promise<void> {
if (!import.meta.client) return
safeClearLocalStorage(key)
if (hasIndexedDb()) {
try {
await clearIndexedDbKeyInOrder(key)
} catch (error) {
console.debug('Failed to clear moderation checklist state from IndexedDB:', error)
}
try {
await enqueueOp(projectId, () => dbDelete(STORE, projectId))
} catch (error) {
console.debug('Failed to clear checklist state from IndexedDB:', error)
}
}
export async function loadChecklistOpenState(projectId: string): Promise<boolean | null> {
return loadState(`${CHECKLIST_OPEN_KEY_PREFIX}${projectId}`, normalizeChecklistOpen, false)
}
export async function saveChecklistOpenState(projectId: string, open: boolean): Promise<void> {
await saveState(`${CHECKLIST_OPEN_KEY_PREFIX}${projectId}`, open)
}
export async function loadChecklistStage(projectSlug: string): Promise<number | null> {
return loadState(`${STAGE_KEY_PREFIX}${projectSlug}`, normalizeStage)
}
export async function saveChecklistStage(projectSlug: string, stage: number): Promise<void> {
await saveState(`${STAGE_KEY_PREFIX}${projectSlug}`, sanitizeStage(stage))
}
export async function loadChecklistActionStates(
projectSlug: string,
): Promise<Record<string, ActionState>> {
const actionStates =
(await loadState(`${ACTION_STATES_KEY_PREFIX}${projectSlug}`, normalizeActionStates, false)) ??
{}
if (Object.keys(actionStates).length > 0) {
void saveChecklistActionStates(projectSlug, actionStates)
}
return actionStates
}
export async function saveChecklistActionStates(
projectSlug: string,
actionStates: Record<string, ActionState>,
): Promise<void> {
await saveState(`${ACTION_STATES_KEY_PREFIX}${projectSlug}`, serializeActionStates(actionStates))
}
export async function loadChecklistTextInputs(
projectSlug: string,
): Promise<Record<string, string>> {
return (await loadState(`${TEXT_INPUTS_KEY_PREFIX}${projectSlug}`, normalizeTextInputs)) ?? {}
}
export async function saveChecklistTextInputs(
projectSlug: string,
textInputs: Record<string, string>,
): Promise<void> {
await saveState(`${TEXT_INPUTS_KEY_PREFIX}${projectSlug}`, textInputs)
}
export async function clearChecklistProgressState(projectSlug: string): Promise<void> {
await Promise.all([
clearState(`${STAGE_KEY_PREFIX}${projectSlug}`),
clearState(`${ACTION_STATES_KEY_PREFIX}${projectSlug}`),
clearState(`${TEXT_INPUTS_KEY_PREFIX}${projectSlug}`),
])
}
export async function loadGeneratedMessageState(
projectSlug: string,
): Promise<ModerationChecklistGeneratedMessageState> {
return (
(await loadState(`${GENERATED_MESSAGE_KEY_PREFIX}${projectSlug}`, normalizeGeneratedMessage)) ??
createEmptyGeneratedMessageState()
)
}
export async function saveGeneratedMessageState(
projectSlug: string,
state: ModerationChecklistGeneratedMessageState,
): Promise<void> {
await saveState(`${GENERATED_MESSAGE_KEY_PREFIX}${projectSlug}`, state)
}
export async function clearGeneratedMessageState(projectSlug: string): Promise<void> {
await clearState(`${GENERATED_MESSAGE_KEY_PREFIX}${projectSlug}`)
}
@@ -0,0 +1,94 @@
const DB_NAME = 'modrinth-moderation'
const DB_VERSION = 2
function hasIndexedDb(): boolean {
return typeof window !== 'undefined' && typeof indexedDB !== 'undefined'
}
function openDatabase(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION)
request.onupgradeneeded = (event) => {
const db = request.result
if (event.oldVersion < 2 && db.objectStoreNames.contains('kv')) {
db.deleteObjectStore('kv')
}
if (!db.objectStoreNames.contains('checklist')) {
db.createObjectStore('checklist')
}
if (!db.objectStoreNames.contains('queue')) {
db.createObjectStore('queue')
}
}
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error ?? new Error('Failed to open IndexedDB'))
request.onblocked = () => reject(new Error('IndexedDB open request blocked'))
})
}
function requestToPromise<T>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed'))
})
}
export async function dbGet<T>(store: string, key: IDBValidKey): Promise<T | null> {
if (!hasIndexedDb()) return null
const db = await openDatabase()
try {
const tx = db.transaction(store, 'readonly')
const result = await requestToPromise<T | undefined>(tx.objectStore(store).get(key))
return result ?? null
} finally {
db.close()
}
}
export async function dbPut<T>(store: string, key: IDBValidKey, value: T): Promise<void> {
if (!hasIndexedDb()) return
const db = await openDatabase()
try {
const tx = db.transaction(store, 'readwrite')
tx.objectStore(store).put(value, key)
await new Promise<void>((resolve, reject) => {
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error ?? new Error('IndexedDB transaction failed'))
})
} finally {
db.close()
}
}
export async function dbDelete(store: string, key: IDBValidKey): Promise<void> {
if (!hasIndexedDb()) return
const db = await openDatabase()
try {
const tx = db.transaction(store, 'readwrite')
tx.objectStore(store).delete(key)
await new Promise<void>((resolve, reject) => {
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error ?? new Error('IndexedDB transaction failed'))
})
} finally {
db.close()
}
}
export async function dbScan<T>(store: string): Promise<{ key: IDBValidKey; value: T }[]> {
if (!hasIndexedDb()) return []
const db = await openDatabase()
try {
const tx = db.transaction(store, 'readonly')
const s = tx.objectStore(store)
const [keys, values] = await Promise.all([
requestToPromise(s.getAllKeys()),
requestToPromise(s.getAll()),
])
return keys.map((key, i) => ({ key, value: values[i] as T }))
} finally {
db.close()
}
}
@@ -1,3 +1,5 @@
import { dbDelete, dbGet, dbPut } from './moderation-db.ts'
export interface PersistedModerationQueueState {
version: 1
savedAt: string
@@ -11,25 +13,9 @@ export interface PersistedModerationQueueState {
isQueueMode: boolean
}
const DB_NAME = 'modrinth-moderation'
const DB_VERSION = 1
const STORE_NAME = 'kv'
const STORE = 'queue'
export const MODERATION_QUEUE_KEY = 'moderation-queue:v1'
function hasIndexedDb(): boolean {
return typeof window !== 'undefined' && typeof indexedDB !== 'undefined'
}
function getLocalStorage(): Storage | null {
if (typeof window === 'undefined') return null
try {
return window.localStorage
} catch {
return null
}
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((entry) => typeof entry === 'string')
}
@@ -53,189 +39,35 @@ function isPersistedStateCandidate(value: unknown): value is PersistedModeration
return true
}
function savedAtTime(state: PersistedModerationQueueState): number {
const time = Date.parse(state.savedAt)
return Number.isNaN(time) ? 0 : time
}
function newestState(
first: PersistedModerationQueueState | null,
second: PersistedModerationQueueState | null,
): PersistedModerationQueueState | null {
if (!first) return second
if (!second) return first
return savedAtTime(second) > savedAtTime(first) ? second : first
}
function openDatabase(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION)
request.onupgradeneeded = () => {
const db = request.result
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME)
}
}
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error ?? new Error('Failed to open IndexedDB'))
request.onblocked = () => reject(new Error('IndexedDB open request blocked'))
})
}
function requestToPromise<T>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed'))
})
}
async function loadFromIndexedDb(): Promise<PersistedModerationQueueState | null> {
if (!hasIndexedDb()) return null
const db = await openDatabase()
try {
const tx = db.transaction(STORE_NAME, 'readonly')
const store = tx.objectStore(STORE_NAME)
const raw = await requestToPromise(store.get(MODERATION_QUEUE_KEY))
if (!isPersistedStateCandidate(raw)) return null
return raw
} finally {
db.close()
}
}
async function saveToIndexedDb(state: PersistedModerationQueueState): Promise<void> {
if (!hasIndexedDb()) return
const db = await openDatabase()
try {
const tx = db.transaction(STORE_NAME, 'readwrite')
tx.objectStore(STORE_NAME).put(state, MODERATION_QUEUE_KEY)
await new Promise<void>((resolve, reject) => {
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error ?? new Error('IndexedDB transaction failed'))
})
} finally {
db.close()
}
}
async function clearIndexedDb(): Promise<void> {
if (!hasIndexedDb()) return
const db = await openDatabase()
try {
const tx = db.transaction(STORE_NAME, 'readwrite')
tx.objectStore(STORE_NAME).delete(MODERATION_QUEUE_KEY)
await new Promise<void>((resolve, reject) => {
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error ?? new Error('IndexedDB transaction failed'))
})
} finally {
db.close()
}
}
function loadFromLocalStorage(): PersistedModerationQueueState | null {
const storage = getLocalStorage()
if (!storage) return null
const raw = storage.getItem(MODERATION_QUEUE_KEY)
if (!raw) return null
try {
const parsed: unknown = JSON.parse(raw)
if (isPersistedStateCandidate(parsed)) return parsed
} catch (error) {
console.debug('Failed to parse moderation queue from localStorage:', error)
}
safeClearLocalStorage()
return null
}
function saveToLocalStorage(state: PersistedModerationQueueState): void {
const storage = getLocalStorage()
if (!storage) return
storage.setItem(MODERATION_QUEUE_KEY, JSON.stringify(state))
}
function clearLocalStorage(): void {
const storage = getLocalStorage()
if (!storage) return
storage.removeItem(MODERATION_QUEUE_KEY)
}
function safeClearLocalStorage(): void {
try {
clearLocalStorage()
} catch (error) {
console.debug('Failed to clear moderation queue from localStorage:', error)
}
}
function safeSaveLocalStorage(state: PersistedModerationQueueState): void {
try {
saveToLocalStorage(state)
} catch (error) {
console.debug('Failed to save moderation queue to localStorage:', error)
}
}
export async function loadQueueState(): Promise<PersistedModerationQueueState | null> {
if (!import.meta.client) return null
let indexedDbState: PersistedModerationQueueState | null = null
try {
indexedDbState = await loadFromIndexedDb()
const raw = await dbGet<unknown>(STORE, MODERATION_QUEUE_KEY)
if (!isPersistedStateCandidate(raw)) return null
return raw
} catch (error) {
console.debug('Failed to load moderation queue from IndexedDB:', error)
return null
}
let localStorageState: PersistedModerationQueueState | null = null
try {
localStorageState = loadFromLocalStorage()
} catch (error) {
console.debug('Failed to load moderation queue from localStorage:', error)
}
return newestState(indexedDbState, localStorageState)
}
export async function saveQueueState(state: PersistedModerationQueueState): Promise<void> {
if (!import.meta.client) return
if (hasIndexedDb()) {
try {
await saveToIndexedDb(state)
safeSaveLocalStorage(state)
return
} catch (error) {
console.debug(
'Failed to save moderation queue to IndexedDB, using localStorage fallback:',
error,
)
}
try {
await dbPut(STORE, MODERATION_QUEUE_KEY, state)
} catch (error) {
console.debug('Failed to save moderation queue to IndexedDB:', error)
}
safeSaveLocalStorage(state)
}
export async function clearQueueState(): Promise<void> {
if (!import.meta.client) return
if (hasIndexedDb()) {
try {
await clearIndexedDb()
} catch (error) {
console.debug('Failed to clear moderation queue from IndexedDB:', error)
}
try {
await dbDelete(STORE, MODERATION_QUEUE_KEY)
} catch (error) {
console.debug('Failed to clear moderation queue from IndexedDB:', error)
}
safeClearLocalStorage()
}