+
Title: {project.value.name}
+
+
Slug:{' '}
+
{project.value.slug}
+
+
Slug Taken:{' '}
+ {autoSlugStatus.value === 'loading' ? (
+ '...'
+ ) : by ? (
+ <>
+
+ {by.name}
+
+ {` (${by.status})`}
+ >
+ ) : (
+ 'No'
+ )}
+
+ )
+ },
+
+ group('title')
+ .title('Title Issues?')
+ .children(
+ toggle('useless-info', 'Contains Useless Info')
+ .suggestedStatus('flagged')
+ .severity('low')
+ .message(),
+
+ toggle('minecraft-branding', 'Minecraft Title')
+ .suggestedStatus('flagged')
+ .severity('medium')
+ .message(),
+
+ toggle('similarities', 'Title Similarities')
+ .suggestedStatus('flagged')
+ .severity('medium')
+ .message()
+ .children(
+ group()
+ .title('Similarities Additional Info')
+ .multiSelect('options')
+ .children(
+ option('modpack', 'Modpack Named After Mod')
+ .shown(computed(() => project.value.project_types.includes('modpack')))
+ .message(),
+
+ option('fork', 'Forked Project')
+ .shown(computed(() => !project.value?.minecraft_server))
+ .message(),
+ ),
+ )
+ .collect(),
+ ),
+
+ group('slug')
+ .title('Slug Issues')
+ .shown(computed(() => hasCustomSlug(project.value)))
+ .children(
+ group()
+ .multiSelect('issues')
+ .children(
+ toggle('misused', 'Misused')
+ .children(
+ group()
+ .title('Correct Slug')
+ .children(
+ text('correct-slug')
+ .initial(project.value.slug)
+ .onChange((value, { override }) => {
+ if (!value) return override(project.value.slug ?? '')
+ clearTimeout(slugDebounceTimer)
+ if (value === project.value.slug) {
+ slugValidation.value = 'unchanged'
+ return
+ }
+ slugValidation.value = 'checking'
+ slugDebounceTimer = setTimeout(async () => {
+ const conflict = await checkSlugTaken(value).catch(() => null)
+ if (conflict !== null && conflict.id !== project.value.id) {
+ takenBy.value = conflict
+ slugValidation.value = 'taken'
+ } else {
+ takenBy.value = null
+ slugValidation.value = 'available'
+ }
+ }, 400)
+ }),
+
+ button()
+ .icon(TagCategoryWandSparklesIcon)
+ .tooltip(computed(() => resolvedAutoSlug.value ?? ''))
+ .enabled(
+ (state) =>
+ autoSlugStatus.value === 'available' &&
+ resolvedAutoSlug.value !== null &&
+ resolvedAutoSlug.value !== currentSlug(state),
+ )
+ .onClick((state) => {
+ if (resolvedAutoSlug.value) state['correct-slug'] = resolvedAutoSlug.value
+ }),
+
+ button()
+ .icon(UserPlusIcon)
+ .tooltip((state) => {
+ const current = currentSlug(state)
+ if (!ownerUsername.value || current?.includes(ownerUsername.value))
+ return current ?? ''
+ return `${current}-${ownerUsername.value}`
+ })
+ .enabled(
+ (state) =>
+ ownerUsername.value !== null &&
+ !currentSlug(state)?.includes(ownerUsername.value),
+ )
+ .onClick((state) => {
+ state['correct-slug'] = `${currentSlug(state)}-${ownerUsername.value}`
+ }),
+
+ button()
+ .icon(TagCategoryRefreshCcwIcon)
+ .tooltip(computed(() => project.value.slug ?? ''))
+ .enabled((state) => currentSlug(state) !== project.value.slug)
+ .onClick((state) => {
+ state['correct-slug'] = undefined
+ }),
+
+ SlugStatus,
+ ),
+ )
+ .message(undefined, (state) => ({
+ SUGGESTED_SLUG: state['correct-slug'],
+ }))
+ .fix(
+ fix().project((patch, state) => {
+ const slug = state['correct-slug'] as string
+ if (!slug) return
+ patch.slug = slug
+ }),
+ ),
+ ),
+ ),
+ )
+}
diff --git a/packages/moderation/src/data/stages/undefined-project.ts b/packages/moderation/src/data/stages/undefined-project.ts
deleted file mode 100644
index a996da0f2c..0000000000
--- a/packages/moderation/src/data/stages/undefined-project.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import { XIcon } from '@modrinth/assets'
-
-import type { Stage } from '../../types/stage'
-
-const undefinedProjectStage: Stage = {
- title: 'This project is undefined!',
- id: 'undefined-project',
- icon: XIcon,
- guidance_url:
- 'https://www.notion.so/2e15ee711bf080e4a41df61bbab49892#3475ee711bf080018bf3d822a2f51a35',
- navigate: '/versions',
- shouldShow: (project, projectV3) => project.versions.length === 0 && !projectV3?.minecraft_server,
- actions: [
- {
- id: 'undefined_no_versions',
- type: 'button',
- label: 'No Versions',
- weight: -100,
- suggestedStatus: 'rejected',
- message: async () =>
- (await import('../messages/checklist-messages/undefined-project/no_versions.md?raw'))
- .default,
- },
- ],
-}
-
-export default undefinedProjectStage
diff --git a/packages/moderation/src/data/stages/undefined-project.tsx b/packages/moderation/src/data/stages/undefined-project.tsx
new file mode 100644
index 0000000000..41d9d288e0
--- /dev/null
+++ b/packages/moderation/src/data/stages/undefined-project.tsx
@@ -0,0 +1,23 @@
+import { XIcon } from '@modrinth/assets'
+import { injectProjectPageContext } from '@modrinth/ui'
+import { computed } from 'vue'
+
+import { group, stage, toggle } from '../../types/node'
+
+export default function () {
+ const { projectV3: project, projectV2 } = injectProjectPageContext()
+
+ return stage('undefined-project', 'Undefined Project')
+ .hint('This project is undefined!')
+ .guidance(
+ 'https://www.notion.so/2e15ee711bf080e4a41df61bbab49892#3475ee711bf080018bf3d822a2f51a35',
+ )
+ .icon(XIcon)
+ .navigate('/versions')
+ .shown(
+ computed(() => projectV2.value.versions.length === 0 && !project.value?.minecraft_server),
+ )
+ .children(
+ group().children(toggle('no-versions', 'No Versions').suggestedStatus('rejected').message()),
+ )
+}
diff --git a/packages/moderation/src/data/stages/versions.ts b/packages/moderation/src/data/stages/versions.ts
deleted file mode 100644
index 2a9d14041e..0000000000
--- a/packages/moderation/src/data/stages/versions.ts
+++ /dev/null
@@ -1,197 +0,0 @@
-import { VersionIcon } from '@modrinth/assets'
-
-import type { ButtonAction, DropdownAction, DropdownActionOption } from '../../types/actions'
-import type { Stage } from '../../types/stage'
-
-const versions: Stage = {
- title: "Are this project's files correct?",
- id: 'versions',
- icon: VersionIcon,
- guidance_url:
- 'https://www.notion.so/2e15ee711bf080e4a41df61bbab49892#2e25ee711bf0804bad38e9055951ff31',
- navigate: '/versions',
- shouldShow: (project, projectV3) => !projectV3?.minecraft_server,
- actions: [
- {
- id: 'versions_incorrect_additional',
- type: 'button',
- label: 'Incorrect additional files',
- weight: 1000,
- suggestedStatus: 'flagged',
- severity: 'medium',
- message: async () =>
- (await import('../messages/checklist-messages/versions/incorrect_additional_files.md?raw'))
- .default,
- } as ButtonAction,
- {
- id: 'versions_incorrect_project_type',
- type: 'button',
- label: 'Incorrect Project Type',
- suggestedStatus: 'rejected',
- severity: 'medium',
- weight: -999999,
- message: async () => '',
- enablesActions: [
- {
- id: 'versions_incorrect_project_type_options',
- type: 'dropdown',
- label: 'What type should this project be?',
- options: [
- {
- label: 'Modpack',
- weight: 1001,
- shouldShow: (project) => project.project_type !== 'modpack',
- message: async () =>
- (await import('../messages/checklist-messages/versions/invalid-modpacks.md?raw'))
- .default,
- } as DropdownActionOption,
- {
- label: 'Resource Pack',
- weight: 1001,
- shouldShow: (project) => project.project_type !== 'resourcepack',
- message: async () =>
- (
- await import('../messages/checklist-messages/versions/invalid-resourcepacks.md?raw')
- ).default,
- } as DropdownActionOption,
- {
- label: 'Data Pack',
- weight: 1001,
- shouldShow: (project) => !project.loaders.includes('datapack'),
- message: async () =>
- (await import('../messages/checklist-messages/versions/invalid-datapacks.md?raw'))
- .default,
- } as DropdownActionOption,
- ],
- } as DropdownAction,
- ],
- } as ButtonAction,
- {
- id: 'versions_alternate_versions',
- type: 'button',
- label: 'Alternate Versions',
- suggestedStatus: 'flagged',
- severity: 'medium',
- weight: -999999,
- message: async () => '',
- enablesActions: [
- {
- id: 'versions_alternate_versions_options',
- type: 'dropdown',
- label: 'How are the alternate versions distributed?',
- options: [
- {
- label: 'Primary Files',
- weight: 1002,
- message: async () =>
- (
- await import('../messages/checklist-messages/versions/alternate_versions-primary.md?raw')
- ).default,
- } as DropdownActionOption,
- {
- label: 'Additional Files',
- weight: 1002,
- message: async () =>
- (
- await import('../messages/checklist-messages/versions/alternate_versions-additional.md?raw')
- ).default,
- } as DropdownActionOption,
- {
- label: 'Monofile',
- weight: 1002,
- shouldShow: (project) =>
- project.project_type === 'resourcepack' || project.loaders.includes('datapack'),
- message: async () =>
- (
- await import('../messages/checklist-messages/versions/alternate_versions-mono.md?raw')
- ).default,
- } as DropdownActionOption,
- {
- label: 'Server Files (Primary Files)',
- weight: 1002,
- shouldShow: (project) => project.project_type === 'modpack',
- message: async () =>
- (
- await import('../messages/checklist-messages/versions/alternate_versions-server.md?raw')
- ).default,
- } as DropdownActionOption,
- {
- label: 'Server Files (Additional Files)',
- weight: 1002,
- suggestedStatus: 'rejected',
- severity: 'high',
- shouldShow: (project) => project.project_type === 'modpack',
- message: async () =>
- (
- await import('../messages/checklist-messages/versions/alternate_versions-server-additional.md?raw')
- ).default,
- } as DropdownActionOption,
- {
- label: 'mods.zip',
- weight: 1002,
- suggestedStatus: 'rejected',
- severity: 'high',
- shouldShow: (project) => project.project_type === 'modpack',
- message: async () =>
- (
- await import('../messages/checklist-messages/versions/alternate_versions-zip.md?raw')
- ).default,
- } as DropdownActionOption,
- ],
- } as DropdownAction,
- ],
- } as ButtonAction,
- {
- id: 'versions_vanilla_assets',
- type: 'button',
- label: 'Vanilla Assets',
- suggestedStatus: `rejected`,
- severity: `medium`,
- weight: 1003,
- shouldShow: (project) => project.project_type === 'resourcepack',
- message: async () =>
- (await import('../messages/checklist-messages/versions/vanilla_assets.md?raw')).default,
- } as ButtonAction,
- {
- id: 'versions_redist_libs',
- type: 'button',
- label: 'Packed Libs',
- suggestedStatus: `rejected`,
- severity: `medium`,
- weight: 1003,
- shouldShow: (project) => project.project_type === 'mod' || project.project_type === 'plugin',
- message: async () =>
- (await import('../messages/checklist-messages/versions/redist_libs.md?raw')).default,
- } as ButtonAction,
- {
- id: 'versions_duplicate_primary_files',
- type: 'button',
- label: 'Duplicate Primary Files',
- suggestedStatus: 'flagged',
- severity: `medium`,
- weight: 1004,
- message: async () =>
- (await import('../messages/checklist-messages/versions/broken_version.md?raw')).default,
- } as ButtonAction,
- {
- id: 'unsupported_project_type',
- type: 'button',
- label: `Unsupported`,
- suggestedStatus: `rejected`,
- severity: `medium`,
- weight: 1005,
- message: async () =>
- (await import('../messages/checklist-messages/versions/unsupported_project.md?raw'))
- .default,
- relevantExtraInput: [
- {
- label: 'Project Type',
- required: true,
- variable: 'INVALID_TYPE',
- },
- ],
- } as ButtonAction,
- ],
-}
-
-export default versions
diff --git a/packages/moderation/src/data/stages/versions.tsx b/packages/moderation/src/data/stages/versions.tsx
new file mode 100644
index 0000000000..3e18ded1f5
--- /dev/null
+++ b/packages/moderation/src/data/stages/versions.tsx
@@ -0,0 +1,110 @@
+import { VersionIcon } from '@modrinth/assets'
+import { injectProjectPageContext } from '@modrinth/ui'
+import { computed } from 'vue'
+
+import { dropdown, group, option, stage, text, toggle } from '../../types/node'
+
+export default function () {
+ const { projectV3: project } = injectProjectPageContext()
+
+ return stage('versions', 'Versions')
+ .hint("Are this project's files correct?")
+ .guidance(
+ 'https://www.notion.so/2e15ee711bf080e4a41df61bbab49892#2e25ee711bf0804bad38e9055951ff31',
+ )
+ .icon(VersionIcon)
+ .navigate('/versions')
+ .shown(computed(() => !project.value?.minecraft_server))
+ .children(
+ group().children(
+ toggle('incorrect-additional-files', 'Incorrect additional files')
+ .suggestedStatus('flagged')
+ .severity('medium')
+ .message(),
+
+ toggle('incorrect-project-type', 'Incorrect Project Type')
+ .suggestedStatus('rejected')
+ .severity('medium')
+ .children(
+ dropdown('type')
+ .title('Correct Project Type')
+ .none('Unknown')
+ .children(
+ option('modpack', 'Modpack')
+ .shown(computed(() => !project.value.project_types.includes('modpack')))
+ .message(),
+ option('resourcepack', 'Resource Pack')
+ .shown(computed(() => !project.value.project_types.includes('resourcepack')))
+ .message(),
+ option('datapack', 'Data Pack')
+ .shown(computed(() => !project.value.loaders.includes('datapack')))
+ .message(),
+ ),
+ )
+ .collect(),
+
+ toggle('alternate-versions', 'Alternate Versions')
+ .suggestedStatus('rejected')
+ .severity('high')
+ .children(
+ dropdown('distribution')
+ .title('Distribution Type')
+ .none('Unknown')
+ .children(
+ option('primary', 'Primary Files').message(),
+ option('additional', 'Additional Files').message(),
+ option('mono', 'Monofile')
+ .shown(
+ computed(
+ () =>
+ project.value.project_types.includes('resourcepack') ||
+ project.value.loaders.includes('datapack'),
+ ),
+ )
+ .message(),
+ option('server', 'Server Files (Primary Files)')
+ .shown(computed(() => project.value.project_types.includes('modpack')))
+ .message(),
+ option('server-additional', 'Server Files (Additional Files)')
+ .shown(computed(() => project.value.project_types.includes('modpack')))
+ .message(),
+ option('zip', 'mods.zip')
+ .shown(computed(() => project.value.project_types.includes('modpack')))
+ .message(),
+ ),
+ )
+ .collect(),
+
+ toggle('vanilla-assets', 'Vanilla Assets')
+ .shown(computed(() => project.value.project_types.includes('resourcepack')))
+ .suggestedStatus('rejected')
+ .severity('medium')
+ .message(),
+
+ toggle('redist-libs', 'Packed Libs')
+ .shown(
+ computed(
+ () =>
+ project.value.project_types.includes('mod') ||
+ project.value.project_types.includes('plugin'),
+ ),
+ )
+ .suggestedStatus('rejected')
+ .severity('medium')
+ .message(),
+
+ toggle('duplicate-primary-files', 'Duplicate Primary Files')
+ .suggestedStatus('flagged')
+ .severity('medium')
+ .message(),
+
+ toggle('unsupported', 'Unsupported')
+ .suggestedStatus('rejected')
+ .severity('medium')
+ .message(undefined, (state) => ({
+ INVALID_TYPE: state['invalid-type'],
+ }))
+ .children(text('invalid-type').title('Unsupported Type').required()),
+ ),
+ )
+}
diff --git a/packages/moderation/src/index.ts b/packages/moderation/src/index.ts
index f4338f8aee..23ec61f2a7 100644
--- a/packages/moderation/src/index.ts
+++ b/packages/moderation/src/index.ts
@@ -1,19 +1,20 @@
-export { default as checklist } from './data/checklist'
+export { default as checklist, stages, useStages } from './data/checklist'
export { default as keybinds } from './data/keybinds'
-export { finalPermissionMessages } from './data/modpack-permissions-stage'
export { default as nags } from './data/nags'
export * from './data/nags/index'
+export { default as attributionQuickReplies } from './data/quick-replies/permissions-quick-replies'
export { default as reportQuickReplies } from './data/quick-replies/report-quick-replies'
export {
type TechReviewContext,
default as techReviewQuickReplies,
} from './data/quick-replies/tech-review-quick-replies'
-export { default as attributionQuickReplies } from './data/quick-replies/permissions-quick-replies'
export * from './locales'
export * from './types/actions'
export * from './types/keybinds'
export * from './types/messages'
export * from './types/nags'
+export * from './types/node'
+export * from './types/priority'
export * from './types/quick-reply'
export * from './types/reports'
export * from './types/stage'
diff --git a/packages/moderation/src/types/actions.ts b/packages/moderation/src/types/actions.ts
index 3c6877fd95..3332dabc61 100644
--- a/packages/moderation/src/types/actions.ts
+++ b/packages/moderation/src/types/actions.ts
@@ -19,6 +19,12 @@ export type Action =
export type ModerationStatus = 'approved' | 'rejected' | 'flagged'
export type ModerationSeverity = 'low' | 'medium' | 'high' | 'critical'
+export interface ChecklistActionContext {
+ project: Labrinth.Projects.v2.Project
+ projectV3: Labrinth.Projects.v3.Project
+ versions?: Labrinth.Versions.v2.Version[] | null
+}
+
export interface BaseAction {
/**
* The type of action, which determines how the action is presented to the moderator and what it does.
@@ -63,6 +69,7 @@ export interface BaseAction {
shouldShow?: (
project: Labrinth.Projects.v2.Project,
projectV3: Labrinth.Projects.v3.Project,
+ context?: ChecklistActionContext,
) => boolean
}
@@ -171,6 +178,7 @@ export interface DropdownActionOption extends WeightedMessage {
shouldShow?: (
project: Labrinth.Projects.v2.Project,
projectV3: Labrinth.Projects.v3.Project,
+ context?: ChecklistActionContext,
) => boolean
}
@@ -194,6 +202,11 @@ export interface DropdownAction extends BaseAction {
}
export interface MultiSelectChipsOption extends WeightedMessage {
+ /**
+ * Stable identifier for the option. If omitted, the label is used.
+ */
+ id?: string
+
/**
* The label of the chip, which is displayed to the moderator.
*/
@@ -207,9 +220,14 @@ export interface MultiSelectChipsOption extends WeightedMessage {
shouldShow?: (
project: Labrinth.Projects.v2.Project,
projectV3: Labrinth.Projects.v3.Project,
+ context?: ChecklistActionContext,
) => boolean
}
+export type MultiSelectChipsOptionsResolver = (
+ context: ChecklistActionContext,
+) => MultiSelectChipsOption[]
+
export interface MultiSelectChipsAction extends BaseAction {
type: 'multi-select-chips'
@@ -221,7 +239,13 @@ export interface MultiSelectChipsAction extends BaseAction {
/**
* The options available in the multi-select chips.
*/
- options: MultiSelectChipsOption[]
+ options: MultiSelectChipsOption[] | MultiSelectChipsOptionsResolver
+
+ /**
+ * If set, all selected option messages are joined with this string and emitted as a single
+ * message part rather than individual parts. Useful for building bullet lists under a shared header.
+ */
+ joinWith?: string
}
export interface AdditionalTextInput {
diff --git a/packages/moderation/src/types/node.ts b/packages/moderation/src/types/node.ts
new file mode 100644
index 0000000000..66f62bb42f
--- /dev/null
+++ b/packages/moderation/src/types/node.ts
@@ -0,0 +1,869 @@
+import type { Labrinth } from '@modrinth/api-client'
+import type { FunctionalComponent, InjectionKey, Ref, SVGAttributes } from 'vue'
+import { markRaw, toValue } from 'vue'
+
+import {
+ expandVariables,
+ flattenProjectV3Variables,
+ flattenProjectVariables,
+ flattenStaticVariables,
+} from '../utils'
+import type { ModerationSeverity, ModerationStatus } from './actions'
+import type { Priority } from './priority.ts'
+
+// ─── State ────────────────────────────────────────────────────────────────────
+
+export type NodeState =
+ | boolean
+ | string
+ | number
+ | Set