mirror of
https://github.com/modrinth/code.git
synced 2026-07-31 13:16:38 +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
@@ -87,7 +87,11 @@ jobs:
|
||||
command: versions upload --preview-alias git-${{ steps.meta.outputs.sha_short }}
|
||||
|
||||
comment:
|
||||
if: github.repository_owner == 'modrinth' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
if: |
|
||||
!cancelled()
|
||||
&& github.repository_owner == 'modrinth'
|
||||
&& github.event.pull_request.head.repo.full_name == github.repository
|
||||
&& needs.deploy.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
needs: [deploy, deploy-storybook]
|
||||
steps:
|
||||
@@ -101,16 +105,25 @@ jobs:
|
||||
id: urls
|
||||
env:
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
STORYBOOK_RESULT: ${{ needs.deploy-storybook.result }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
STAGING_PREVIEW_URL=$(cat deployment-url-staging-preview.txt)
|
||||
PRODUCTION_PREVIEW_URL=$(cat deployment-url-production-preview.txt)
|
||||
|
||||
if [ "$STORYBOOK_RESULT" = "success" ]; then
|
||||
STORYBOOK_PREVIEW_URL="https://git-${HEAD_SHA::8}-storybook.modrinth.workers.dev"
|
||||
else
|
||||
STORYBOOK_PREVIEW_URL="⚠️ Deployment $STORYBOOK_RESULT ([logs]($RUN_URL))"
|
||||
fi
|
||||
|
||||
echo "Production preview URL: $PRODUCTION_PREVIEW_URL"
|
||||
echo "Staging preview URL: $STAGING_PREVIEW_URL"
|
||||
echo "Storybook preview URL: $STORYBOOK_PREVIEW_URL"
|
||||
|
||||
echo "staging-preview-url=$STAGING_PREVIEW_URL" >> $GITHUB_OUTPUT
|
||||
echo "production-preview-url=$PRODUCTION_PREVIEW_URL" >> $GITHUB_OUTPUT
|
||||
echo "storybook-preview-url=https://git-${HEAD_SHA::8}-storybook.modrinth.workers.dev" >> $GITHUB_OUTPUT
|
||||
echo "storybook-preview-url=$STORYBOOK_PREVIEW_URL" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Find comment
|
||||
if: github.event_name == 'pull_request'
|
||||
|
||||
@@ -8,6 +8,7 @@ extend-exclude = [
|
||||
"packages/blog/",
|
||||
# contains licenses like `CC-BY-ND-4.0`
|
||||
"packages/moderation/src/data/stages/license.ts",
|
||||
"packages/moderation/src/utils.ts",
|
||||
# contains payment card IDs like `IY1VMST1MOXS` which are flagged
|
||||
"apps/labrinth/src/queue/payouts/mod.rs",
|
||||
# contains domain names with deliberate typos
|
||||
|
||||
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'))
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
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'))
|
||||
})
|
||||
} 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 = 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)
|
||||
} 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)
|
||||
const raw = await dbGet<unknown>(STORE, projectId)
|
||||
if (!isPersistedChecklistState(raw)) return null
|
||||
if (isStale(raw.savedAt)) {
|
||||
await clearChecklistState(projectId)
|
||||
return null
|
||||
}
|
||||
|
||||
if (touch) {
|
||||
void saveState(key, state.value)
|
||||
return raw
|
||||
} catch (error) {
|
||||
console.debug('Failed to load checklist state from IndexedDB:', error)
|
||||
return null
|
||||
}
|
||||
|
||||
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()) {
|
||||
const record: PersistedChecklistState = { ...state, savedAt: new Date().toISOString() }
|
||||
try {
|
||||
await saveToIndexedDbInOrder(key, state)
|
||||
await enqueueOp(projectId, () => dbPut(STORE, projectId, record))
|
||||
} catch (error) {
|
||||
console.debug('Failed to save moderation checklist state to IndexedDB:', 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)
|
||||
await enqueueOp(projectId, () => dbDelete(STORE, projectId))
|
||||
} catch (error) {
|
||||
console.debug('Failed to clear moderation checklist state from IndexedDB:', 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
|
||||
await dbPut(STORE, MODERATION_QUEUE_KEY, state)
|
||||
} catch (error) {
|
||||
console.debug(
|
||||
'Failed to save moderation queue to IndexedDB, using localStorage fallback:',
|
||||
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()
|
||||
await dbDelete(STORE, MODERATION_QUEUE_KEY)
|
||||
} catch (error) {
|
||||
console.debug('Failed to clear moderation queue from IndexedDB:', error)
|
||||
}
|
||||
}
|
||||
|
||||
safeClearLocalStorage()
|
||||
}
|
||||
|
||||
@@ -1,36 +1,49 @@
|
||||
import type { Stage } from '../types/stage'
|
||||
import categories from './stages/categories'
|
||||
import description from './stages/description'
|
||||
import environment from './stages/environment/environment'
|
||||
import environmentMultiple from './stages/environment/environment-multiple'
|
||||
import gallery from './stages/gallery'
|
||||
import license from './stages/license'
|
||||
import links from './stages/links'
|
||||
import permissions from './stages/permissions'
|
||||
import postApproval from './stages/post-approval'
|
||||
import reupload from './stages/reupload'
|
||||
import ruleFollowing from './stages/rule-following'
|
||||
import statusAlerts from './stages/status-alerts'
|
||||
import summary from './stages/summary'
|
||||
import titleSlug from './stages/title-slug'
|
||||
import undefinedProject from './stages/undefined-project'
|
||||
import versions from './stages/versions'
|
||||
import type { Ref } from 'vue'
|
||||
import { provide, ref } from 'vue'
|
||||
|
||||
export default [
|
||||
titleSlug,
|
||||
summary,
|
||||
description,
|
||||
links,
|
||||
license,
|
||||
categories,
|
||||
environment,
|
||||
environmentMultiple,
|
||||
gallery,
|
||||
versions,
|
||||
reupload,
|
||||
permissions,
|
||||
ruleFollowing,
|
||||
statusAlerts,
|
||||
undefinedProject,
|
||||
postApproval,
|
||||
] as ReadonlyArray<Stage>
|
||||
import type { NodeState, StageFn, StageNodeBuilder } from '../types/node'
|
||||
import { group, STAGES_KEY } from '../types/node'
|
||||
import useCategoriesStage from './stages/categories'
|
||||
import useDescriptionStage from './stages/description'
|
||||
import useGalleryStage from './stages/gallery'
|
||||
import useLicenseStage from './stages/license'
|
||||
import useLinksStage from './stages/links'
|
||||
import useMetadataStage from './stages/metadata'
|
||||
import usePermissionsStage from './stages/permissions'
|
||||
import usePostApprovalStage from './stages/post-approval'
|
||||
import useReReviewStage from './stages/re-review'
|
||||
import useReuploadsStage from './stages/reupload'
|
||||
import useOtherRulesStage from './stages/other-rules'
|
||||
import useStatusAlertsStage from './stages/status-alerts'
|
||||
import useSummaryStage from './stages/summary'
|
||||
import useTitleSlugStage from './stages/title-slug'
|
||||
import useUndefinedProjectStage from './stages/undefined-project'
|
||||
import useVersionsStage from './stages/versions'
|
||||
|
||||
export function useStages(
|
||||
globalState: Ref<Record<string, Record<string, NodeState>>>,
|
||||
): StageNodeBuilder[] {
|
||||
const mainStages: StageNodeBuilder[] = [
|
||||
usePostApprovalStage(),
|
||||
useUndefinedProjectStage(),
|
||||
useReReviewStage(),
|
||||
useTitleSlugStage(),
|
||||
useSummaryStage(),
|
||||
useDescriptionStage(),
|
||||
useGalleryStage(),
|
||||
useLinksStage(),
|
||||
useLicenseStage(),
|
||||
useCategoriesStage(),
|
||||
useMetadataStage(),
|
||||
useVersionsStage(),
|
||||
useReuploadsStage(),
|
||||
usePermissionsStage(),
|
||||
useOtherRulesStage(),
|
||||
]
|
||||
provide(STAGES_KEY, ref(mainStages))
|
||||
return [...mainStages, useStatusAlertsStage(mainStages, globalState)]
|
||||
}
|
||||
|
||||
export const stages: ReadonlyArray<StageFn> = []
|
||||
|
||||
export default group()
|
||||
|
||||
@@ -4,13 +4,13 @@ const keybinds: { [id: string]: KeybindListener } = {
|
||||
'next-stage': {
|
||||
keybind: 'ArrowRight',
|
||||
description: 'Go to next stage',
|
||||
enabled: (ctx) => !ctx.state.isDone && !ctx.state.hasGeneratedMessage,
|
||||
enabled: (ctx) => !ctx.state.isDone,
|
||||
action: (ctx) => ctx.actions.tryGoNext(),
|
||||
},
|
||||
'previous-stage': {
|
||||
keybind: 'ArrowLeft',
|
||||
description: 'Go to previous stage',
|
||||
enabled: (ctx) => !ctx.state.isDone && !ctx.state.hasGeneratedMessage,
|
||||
enabled: (ctx) => !ctx.state.isDone,
|
||||
action: (ctx) => ctx.actions.tryGoBack(),
|
||||
},
|
||||
'generate-message': {
|
||||
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
## Insufficient Description
|
||||
|
||||
Per section 2.1 of %RULES%, your project's %PROJECT_DESCRIPTION_FLINK% should clearly inform the reader of the content, purpose, and appeal of your project.
|
||||
|
||||
Currently, it looks like there are some missing details.
|
||||
|
||||
What does your modpack add? What features does it have? Why would a user want to download it?
|
||||
Check out descriptions like [What Is My FPS??](https://modrinth.com/modpack/WIMP) or [Aged](https://modrinth.com/modpack/aged) for inspiration when creating a quality and informative description.
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
## Insufficient Description
|
||||
|
||||
Per section 2.1 of %RULES%, your project's %PROJECT_DESCRIPTION_FLINK% should clearly inform the reader of the content, purpose, and appeal of your project.
|
||||
|
||||
Currently, it looks like there are some missing details.
|
||||
|
||||
What does your project add? What features does it have? Why would a user want to download it?
|
||||
Check out descriptions like [Sodium](https://modrinth.com/mod/sodium) or [The Aether](https://modrinth.com/mod/aether) for inspiration when creating a quality and informative description.
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
## Insufficient Description
|
||||
|
||||
Per section 2.1 of %RULES%, your project's Description should clearly inform the reader of the content, purpose, and appeal of your project.
|
||||
|
||||
Currently, it looks like there are some missing details.
|
||||
%EXPLAINER%
|
||||
@@ -1,4 +0,0 @@
|
||||
## Unfinished Description
|
||||
|
||||
It looks like your project Description is still a Work In Progress.
|
||||
Please remember to submit only when ready, as it is important your project meets the requirements of Section 2.1 of %RULES%.
|
||||
@@ -1,4 +0,0 @@
|
||||
## Misuse of Links
|
||||
|
||||
Per section 5.4 of %RULES%, all %PROJECT_LINKS_FLINK% must lead to correctly labeled publicly available resources that are directly related to your project.
|
||||
Currently it looks like your %MISUSED_LINKS% link(s) are misused or incorrectly labeled.
|
||||
-1
@@ -1 +0,0 @@
|
||||
Currently, your Discord link directs to an invalid invite, likely because your invite has expired. Make sure to set your invite link to permanent with unlimited uses before resubmitting your project.
|
||||
-1
@@ -1 +0,0 @@
|
||||
Currently, your Source link directs to a Page Not Found error, likely because your repository is private. Make sure to set your repository to public before resubmitting your project.
|
||||
@@ -1,5 +0,0 @@
|
||||
## Misuse of custom URL
|
||||
|
||||
We ask that you ensure your project's %PROJECT_SLUG_FLINK% accurately represents your project.
|
||||
Your current slug of `%PROJECT_SLUG%` may not accurately match your project's Name or contain excess information.
|
||||
A mismatched URL may make it more difficult for users to find your content. Abbreviations or similar are fine to use if applicable. If your preferred URL is not available, and you cannot find a matching public project, let us know in this moderation thread when you resubmit your project, and our moderation team may be able to free it up for your project.
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
## Warnings from AutoMod
|
||||
|
||||
Unfortunately, our AutoMod cannot read your project's Description or your messages to moderation.
|
||||
AutoMod will warn both you and our Moderation Staff about potential issues, but if you've already followed the necessary steps these warnings can safely be ignored.
|
||||
Note that if your project is being rejected by AutoMod this means your project has content that can not be included in your modpack and must be removed before resubmission, including deleting versions of your modpack that include the content.
|
||||
@@ -1,2 +0,0 @@
|
||||
**Featured Tags:** %PROJECT_CATEGORIES% \
|
||||
**Additional Tags:** %PROJECT_ADDITIONAL_CATEGORIES%
|
||||
-2
@@ -1,2 +0,0 @@
|
||||
**Unique environments:** %PROJECT_V3_ENVIRONMENT_COUNT% \
|
||||
**Environments:** `%PROJECT_V3_ALL_ENVIRONMENTS%`
|
||||
@@ -1,4 +0,0 @@
|
||||
**Environment:** `%PROJECT_V3_ENVIRONMENT_0%`
|
||||
|
||||
**Client:** `%PROJECT_CLIENT_SIDE%` \
|
||||
**Server:** `%PROJECT_SERVER_SIDE%`
|
||||
@@ -1,2 +0,0 @@
|
||||
**License id:** %PROJECT_LICENSE_ID% \
|
||||
**License Link:** %PROJECT_LICENSE_URL%
|
||||
@@ -1,4 +0,0 @@
|
||||
**Issues:** %PROJECT_ISSUES_URL% \
|
||||
**Source:** %PROJECT_SOURCE_URL% \
|
||||
**Wiki:** %PROJECT_WIKI_URL% \
|
||||
**Discord:** %PROJECT_DISCORD_URL%
|
||||
@@ -1 +0,0 @@
|
||||
> **{PLATFORM}:** {URL}<br />
|
||||
@@ -1,2 +0,0 @@
|
||||
<br />
|
||||
<u>**Donation Links:**</u><br />
|
||||
@@ -1,4 +0,0 @@
|
||||
**Website:** %PROJECT_SITE_URL% \
|
||||
**Store:** %PROJECT_STORE_URL% \
|
||||
**Wiki:** %PROJECT_WIKI_URL% \
|
||||
**Discord:** %PROJECT_DISCORD_URL%
|
||||
@@ -1 +0,0 @@
|
||||
**Applying for:** `%PROJECT_REQUESTED_STATUS%`
|
||||
@@ -1,2 +0,0 @@
|
||||
**Summary:**
|
||||
`%PROJECT_SUMMARY%`
|
||||
@@ -1,4 +0,0 @@
|
||||
**Title:** %PROJECT_TITLE% \
|
||||
**Slug:** `%PROJECT_SLUG%`
|
||||
|
||||
**Title issues?**
|
||||
@@ -1,3 +0,0 @@
|
||||
**Slug:** `%PROJECT_SLUG%` </br>
|
||||
|
||||
**Title issues?**
|
||||
@@ -1 +0,0 @@
|
||||
**Title:** %PROJECT_TITLE% </br>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
What does your modpack add? What features does it have? Why would a user want to download it?
|
||||
|
||||
Check out descriptions like [What Is My FPS??](https://modrinth.com/modpack/WIMP) or [Aged](https://modrinth.com/modpack/aged) for inspiration when creating a quality and informative description.
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
What does your project add? What features does it have? Why would a user want to download it?
|
||||
|
||||
Check out descriptions like [Sodium](https://modrinth.com/mod/sodium) or [The Aether](https://modrinth.com/mod/aether) for inspiration when creating a quality and informative description.
|
||||
+1
-6
@@ -1,8 +1,3 @@
|
||||
## Insufficient Description
|
||||
|
||||
Per section 2.1 of %RULES%, your server's %PROJECT_DESCRIPTION_FLINK% should clearly inform the reader of the content, purpose, and appeal of your server.
|
||||
|
||||
Currently, it looks like there are some missing details.
|
||||
|
||||
What is your server about? What features does it have? Why would a user want to join?
|
||||
|
||||
Check out descriptions like [Flatnet](https://modrinth.com/server/flatnet) or [Cobblemon Islands](https://modrinth.com/server/cobblemon-islands-server) for inspiration when creating a quality and informative description.
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
## Insufficient Description
|
||||
|
||||
Per section 2.1 of %RULES%, your %PROJECT_DESCRIPTION_FLINK% should clearly inform the reader of the content, purpose, and appeal of your %PROJECT_TYPE%.</br>
|
||||
Currently, it looks like there are some missing details.
|
||||
|
||||
%CUSTOM_ADVICE%
|
||||
+1
@@ -0,0 +1 @@
|
||||
- Since your project is a fork, you should briefly describe the function or features of the %PROJECT_TYPE% your project is forked from, in addition to what changes you've made in your version.</br>
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
</br>
|
||||
|
||||
**How to setup spoilers blocks:**
|
||||
|
||||
```md
|
||||
<details>
|
||||
<summary>Spoilers!</summary>
|
||||
|
||||
- Features!
|
||||
|
||||
</details>
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Spoilers!</summary>
|
||||
|
||||
- Features!
|
||||
|
||||
</details>
|
||||
+1
@@ -0,0 +1 @@
|
||||
- We understand you may not want to spoil some of the details of your projects contents, you can put important details in a spoiler!</br>
|
||||
+1
@@ -0,0 +1 @@
|
||||
- It looks like your project Description is still a work in progress, please remember to submit only when ready.
|
||||
@@ -0,0 +1,10 @@
|
||||
## Showcase Clarity
|
||||
|
||||
Per section 2 of %RULES%, it's important that your project page accurately and honestly represent the content of your project.
|
||||
|
||||
Currently, it looks like some images do not accurately represent the content of your %PROJECT_TYPE_FORMATTED_LOWER%.
|
||||
Please make sure you use authentic images such as in-game screenshots when showcasing the content or functionality of your work.
|
||||
|
||||
Images such as renders, AI generated or assisted images, or heavily modified images should not be used to demonstrate the functionality of your project in the %PROJECT_DESCRIPTION_FLINK% or %PROJECT_GALLERY_FLINK%.
|
||||
|
||||
You may still include these types of branding assets or unrelated images on your project page, so long as it is clear they are not authentic representations of the content.
|
||||
@@ -0,0 +1 @@
|
||||
The current Discord invite is set to expire, we strongly recommend setting your public discord invite to be permanent and unlimited.
|
||||
@@ -0,0 +1 @@
|
||||
The current Discord invite is invalid, likely because your invite has expired. Make sure to set your invite link to permanent with unlimited uses before resubmitting your project.
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
## Unreachable Links
|
||||
## Links
|
||||
|
||||
It looks like some of your %PROJECT_TYPE_FORMATTED_LOWER%'s %PROJECT_LINKS_FLINK% are misused or inaccessible.</br>
|
||||
Per section 5.4 of %RULES%, all %PROJECT_LINKS_FLINK% must lead to correctly labeled publicly available resources that are directly related to your project.
|
||||
@@ -0,0 +1,3 @@
|
||||
</br>
|
||||
|
||||
**These links appear to be inaccessible:**
|
||||
@@ -0,0 +1 @@
|
||||
The issues tab for your project's source repo appears to be disabled.
|
||||
@@ -0,0 +1,3 @@
|
||||
</br>
|
||||
|
||||
**These links appear to be misused or not related to your project:**
|
||||
@@ -0,0 +1 @@
|
||||
The current Source link directs to a repository that does not contain the source code for your %PROJECT_TYPE_FORMATTED_LOWER%.
|
||||
@@ -0,0 +1 @@
|
||||
The current Source link directs to a Page Not Found error, likely because your repository is private. Make sure to set your repository to public before resubmitting your project.
|
||||
@@ -0,0 +1 @@
|
||||
The wiki tab for your project's source repo appears to be disabled.
|
||||
+1
@@ -0,0 +1 @@
|
||||
It looks like this project is probably "%SUGGESTED_ENVIRONMENT%".
|
||||
+2
@@ -4,3 +4,5 @@ Per section 5.1 of %RULES%, it is important that the metadata of your projects i
|
||||
|
||||
We've recently overhauled how environment metadata works on Modrinth, you can now edit this in your project's [Version Settings](https://modrinth.com/project/%PROJECT_ID%/settings/versions).
|
||||
Please [read this blogpost](%NEW_ENVIRONMENTS_LINK%) for full details and information on how to ensure your project is labeled correctly.
|
||||
|
||||
%CORRECT%
|
||||
@@ -0,0 +1 @@
|
||||
It looks like some %PROJECT_VERSIONS_FLINK% of your project should have unique environments from other versions, please ensure each version is set correctly.
|
||||
@@ -0,0 +1 @@
|
||||
Please address all moderation concerns or correct all issues before resubmitting this project.
|
||||
@@ -0,0 +1 @@
|
||||
Continued resubmission without abiding by %RULES% or any other attempts at spam may result in account suspension.
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
## Prohibited Content
|
||||
|
||||
This project may violate section 3.1 of [Modrinth's Content Rules](https://modrinth.com/legal/rules).
|
||||
|
||||
We ask that you ensure your project does not endorse, promote, or enable hacks or the use of hacks. Additionally, your project page should not contain language that endorses or promotes hacks or the use of hacks.
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
## Prohibited Content
|
||||
|
||||
This project contains content which may violate section 1 of [Modrinth's Content Rules](https://modrinth.com/legal/rules):
|
||||
+1
@@ -0,0 +1 @@
|
||||
- 1.2: Sexually explicit or pornographic material, or content promoting violence or discrimination based on race, sex, gender, religion, nationality, disability, sexual orientation, or age
|
||||
+1
@@ -0,0 +1 @@
|
||||
- 1.9: Content giving a false impression of endorsement by Modrinth or any other person or entity
|
||||
+1
@@ -0,0 +1 @@
|
||||
- 1.6: Content likely to cause annoyance, harm, embarrassment, alarm, or deception to others
|
||||
+1
@@ -0,0 +1 @@
|
||||
- 1.5: Promotion of illegal activity, or advocacy of unlawful acts including real-life drugs or illicit substances
|
||||
+1
@@ -0,0 +1 @@
|
||||
- 1.8: Impersonation of a person, or misrepresentation of identity or affiliation with any person or organization
|
||||
+1
@@ -0,0 +1 @@
|
||||
- 1.3: Content infringing patents, trademarks, trade secrets, copyright, or other intellectual property rights
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user