mirror of
https://github.com/modrinth/code.git
synced 2026-08-28 18:45:15 +00:00
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:
co-authored by
chyzman
Calum H.
Gravy Boat
Michael H.
chyz
Prospector
parent
30e23e5cc2
commit
e2085c43c5
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')
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user