@@ -100,7 +103,7 @@
@@ -111,6 +114,7 @@
+ allMembers.value.find((member) => member.is_owner)?.user ??
+ organization.value?.members.find((member) => member.is_owner)?.user,
+)
+
const prefixedThread = computed(() => {
+ if (!thread.value) return thread.value
+ const owner = projectOwner.value
+ const ownerThread = {
+ ...thread.value,
+ members: owner
+ ? [...thread.value.members.filter((member) => member.id !== owner.id), owner]
+ : thread.value.members,
+ }
const projectDate = project.value?.queued ?? project.value?.approved ?? project.value?.published
- if (thread.value && projectDate && dayjs(projectDate).isBefore(dayjs(THREADS_RELEASE_DATE))) {
- const newThread = JSON.parse(JSON.stringify(thread.value))
+ if (projectDate && dayjs(projectDate).isBefore(dayjs(THREADS_RELEASE_DATE))) {
+ const newThread = { ...ownerThread, messages: [...ownerThread.messages] }
newThread.messages.unshift({
id: '69',
author_id: null,
@@ -249,9 +278,14 @@ const prefixedThread = computed(() => {
})
return newThread
}
- return thread.value
+ return ownerThread
})
+const visibleIssues = computed(() =>
+ (thread.value?.issues ?? []).filter((issue) => !isThreadIssueVerified(issue)),
+)
+
+const projectApproved = computed(() => isApproved(project.value))
const canAccess = computed(() => !!currentMember.value)
const staff = computed(() => isStaff(currentMember.value?.user))
const userFacingUiVisible = computed(
@@ -319,7 +353,7 @@ const moderationAdmonition = computed<{
}
}
- if (isApproved(currentProject) && approvedAdmonitionMessage.value) {
+ if (projectApproved.value && approvedAdmonitionMessage.value) {
return {
type: 'success',
header: defineMessage({
@@ -492,26 +526,32 @@ function updateThread(newThread: Labrinth.Threads.v3.Thread | null | undefined)
async function setStatus(status: Labrinth.Projects.v2.ProjectStatus) {
if (status === 'processing') {
- await setProcessing()
- return
+ return await setProcessing()
}
startLoading()
+ const projectId = project.value.id
+ const threadId = project.value.thread_id
try {
- await client.labrinth.projects_v2.edit(project.value.id, { status })
-
- project.value.status = status
+ if (['approved', 'unlisted', 'private'].includes(status))
+ await verifyThreadIssuesForApproval(threadId, client, () => {
+ if (project.value.id !== projectId) throw new Error(formatMessage(messages.projectChanged))
+ })
+ await client.labrinth.projects_v3.edit(projectId, { status })
await invalidate()
- await queryClient.invalidateQueries({ queryKey: ['thread', project.value?.thread_id] })
+ await queryClient.invalidateQueries({ queryKey: ['thread', threadId] })
+ return true
} catch (err) {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: getErrorDescription(err),
type: 'error',
})
+ return false
+ } finally {
+ await queryClient.invalidateQueries({ queryKey: ['thread', threadId] })
+ stopLoading()
}
-
- stopLoading()
}
function getErrorDescription(err: unknown): string {
diff --git a/apps/frontend/src/pages/[type]/[project]/settings.vue b/apps/frontend/src/pages/[type]/[project]/settings.vue
index 0f684160da6..752413304fe 100644
--- a/apps/frontend/src/pages/[type]/[project]/settings.vue
+++ b/apps/frontend/src/pages/[type]/[project]/settings.vue
@@ -44,13 +44,14 @@ const {
const flags = useFeatureFlags()
const isServerProject = computed(() => projectV3.value?.minecraft_server != null)
+const isStaffMember = computed(() => isStaff(currentMember.value?.user))
const navItems = computed(() => {
const base = `${project.value.project_type}/${project.value.slug ? project.value.slug : project.value.id}`
const showEnvironment =
projectV3.value?.project_types?.some((type) => ['mod', 'modpack'].includes(type)) &&
- isStaff(currentMember.value?.user)
+ isStaffMember.value
const hasPermissionsPage = computed(() =>
projectV3.value?.project_types?.some((type) => ['modpack'].includes(type)),
@@ -188,7 +189,7 @@ const moderatorSeeUserUi = computed({
-
+
Show member UI
diff --git a/apps/frontend/src/pages/[type]/[project]/settings/description.vue b/apps/frontend/src/pages/[type]/[project]/settings/description.vue
index 36e912d523b..64e8a819078 100644
--- a/apps/frontend/src/pages/[type]/[project]/settings/description.vue
+++ b/apps/frontend/src/pages/[type]/[project]/settings/description.vue
@@ -15,18 +15,22 @@
+
+
+
-
-
message.severity === 'error'),
)
+const descriptionIssueAction = useProjectIssueFieldAction({
+ draft: () => ({ description: current.value.description }),
+ canSave: () => canSave.value,
+ saving,
+ validation: saveValidation,
+ save: async () => {
+ const description = current.value.description
+ if (description !== saved.value.description) {
+ await patchProjectV3({ description }, true, true)
+ }
+ },
+})
+
async function save() {
if (!canSave.value || saving.value) return
const submittedState = saveValidation.snapshot()
diff --git a/apps/frontend/src/pages/[type]/[project]/settings/disclosures.vue b/apps/frontend/src/pages/[type]/[project]/settings/disclosures.vue
index cf0e3d20baa..0170abaa6b4 100644
--- a/apps/frontend/src/pages/[type]/[project]/settings/disclosures.vue
+++ b/apps/frontend/src/pages/[type]/[project]/settings/disclosures.vue
@@ -1,310 +1,77 @@
-
- {{ formatMessage(messages.title) }}
-
+
+
{{ formatMessage(messages.title) }}
@@ -328,103 +95,38 @@ const { confirmLeaveModal } = usePageLeaveSafety(hasChanges)
+
{{ formatMessage(statusMessages.loading) }}
+
+
{{ formatMessage(statusMessages.loadError) }}
+
{{ formatMessage(statusMessages.retry) }}
+
-
-
setDisclosureLockStatus('ai_content', status)
- "
- />
- setDisclosureLockStatus('ai_functionality', status)
- "
- />
- setDisclosureLockStatus('advertisements', status)
- "
- />
- setDisclosureLockStatus('paid_features', status)
- "
- />
- setDisclosureLockStatus('telemetry', status)
- "
- />
- setDisclosureLockStatus('derivative_work', status)
- "
- />
- setDisclosureLockStatus('epilepsy_triggers', status)
- "
- />
- setDisclosureLockStatus('system_interactions', status)
- "
- />
- setDisclosureLockStatus('archived', status)
- "
+
+
+
+ {{ formatMessage(statusMessages.saveError) }}
+
+
-
-
diff --git a/apps/frontend/src/pages/[type]/[project]/settings/gallery.vue b/apps/frontend/src/pages/[type]/[project]/settings/gallery.vue
index 319f7e21418..e91ab0840f4 100644
--- a/apps/frontend/src/pages/[type]/[project]/settings/gallery.vue
+++ b/apps/frontend/src/pages/[type]/[project]/settings/gallery.vue
@@ -1,6 +1,7 @@
+
+
Description
@@ -245,6 +252,12 @@
{{ item.description }}
+
@@ -322,6 +335,7 @@ import {
} from '@modrinth/ui'
import AiImageWarningModal from '~/components/ui/AiImageWarningModal.vue'
+import ProjectIssueCard from '~/components/ui/project-issue-card/index.vue'
import ValidationMessage from '~/components/ValidationMessage.vue'
import { useProjectNagMessages } from '~/composables/project-nag-validation'
import { useProjectSaveValidation } from '~/composables/project-save-validation'
@@ -335,6 +349,7 @@ const formatDate = useFormatDateTime({
})
const {
projectV2: project,
+ projectV3,
currentMember,
createGalleryItem: createGalleryItemMutation,
editGalleryItem: editGalleryItemMutation,
@@ -354,6 +369,10 @@ const zoomedIn = ref(false)
const deleteIndex = ref(-1)
const editIndex = ref(-1)
+function galleryImageId(url) {
+ return projectV3.value?.gallery.find((image) => image.url === url)?.id
+}
+const editingImageId = computed(() => galleryImageId(filteredGallery.value[editIndex.value]?.url))
const editTitle = ref('')
const editDescription = ref('')
const editFeatured = ref(false)
diff --git a/apps/frontend/src/pages/[type]/[project]/settings/index.vue b/apps/frontend/src/pages/[type]/[project]/settings/index.vue
index ea4a55d118a..a71af88f3b4 100644
--- a/apps/frontend/src/pages/[type]/[project]/settings/index.vue
+++ b/apps/frontend/src/pages/[type]/[project]/settings/index.vue
@@ -36,9 +36,10 @@
class="mt-2"
/>
+
-
+
URL
@@ -49,6 +50,8 @@
autocomplete="off"
:disabled="!hasPermission"
wrapper-class="w-full"
+ @focusin="onSlugSuggestionFocusIn"
+ @focusout="onSlugSuggestionFocusOut"
>
@@ -57,6 +60,7 @@
+
+
@@ -129,6 +138,7 @@
class="mt-2"
/>
+
@@ -355,9 +365,11 @@ import {
import { fileIsValid, formatProjectStatus, isAdmin } from '@modrinth/utils'
import AiImageWarningModal from '~/components/ui/AiImageWarningModal.vue'
+import ProjectIssueCard from '~/components/ui/project-issue-card/index.vue'
import SlugSuggestions from '~/components/ui/SlugSuggestions.vue'
import ValidationMessage from '~/components/ValidationMessage.vue'
import { useAuth } from '~/composables/auth.js'
+import { useProjectIssueFieldAction } from '~/composables/project-issue-field-action'
import { useProjectNagMessages } from '~/composables/project-nag-validation'
import { useProjectSaveValidation } from '~/composables/project-save-validation'
import {
@@ -580,6 +592,34 @@ async function updateMonetizationStatus(status) {
}
const saveValidation = useProjectSaveValidation(() => modified.value)
+const issueClient = injectModrinthClient()
+
+function useTextIssueAction(field, input) {
+ return useProjectIssueFieldAction({
+ draft: () => ({ [field]: input.value.trim() }),
+ canSave: () =>
+ hasPermission.value &&
+ !saveValidation.forField(field).some((message) => message.severity === 'error'),
+ saving,
+ validation: saveValidation,
+ save: async () => {
+ const submitted = input.value
+ const value = submitted.trim()
+ if (value !== (project.value[field] ?? '')) {
+ if (field === 'slug') {
+ await issueClient.labrinth.projects_v3.edit(project.value.id, { slug: value })
+ } else {
+ await patchProjectV3({ [field]: value }, true, true)
+ }
+ }
+ if (input.value === submitted) input.value = value
+ },
+ })
+}
+
+const nameIssueAction = useTextIssueAction('name', name)
+const slugIssueAction = useTextIssueAction('slug', slug)
+const summaryIssueAction = useTextIssueAction('summary', summary)
async function handleSave() {
if (!canSave.value || saving.value) return
diff --git a/apps/frontend/src/pages/[type]/[project]/settings/license.vue b/apps/frontend/src/pages/[type]/[project]/settings/license.vue
index 7f355f523aa..cfd9defa90b 100644
--- a/apps/frontend/src/pages/[type]/[project]/settings/license.vue
+++ b/apps/frontend/src/pages/[type]/[project]/settings/license.vue
@@ -9,7 +9,9 @@
+
+
@@ -112,7 +120,7 @@
- {{ formatMessage(messages.url) }}
+ {{ formatMessage(licenseUrlMessages.url) }}
({{ formatMessage(messages.optionalLabel) }})
@@ -124,8 +132,13 @@
v-model="current.licenseUrl"
type="url"
:maxlength="2048"
- :placeholder="formatMessage(messages.urlPlaceholder)"
- :required="current.license.friendly === 'Custom'"
+ :placeholder="
+ formatMessage(
+ current.license.friendly === 'Custom'
+ ? licenseUrlMessages.url
+ : licenseUrlMessages.optionalUrl,
+ )
+ "
:disabled="saving || !hasPermission || licenseId === 'LicenseRef-Unknown'"
wrapper-class="w-full"
/>
@@ -133,8 +146,8 @@
{{
formatMessage(
current.license.friendly === 'Custom'
- ? messages.customUrlDescription
- : messages.urlDescription,
+ ? licenseUrlMessages.customUrlDescription
+ : licenseUrlMessages.urlDescription,
)
}}
@@ -162,18 +175,19 @@
/>
+
-
+
message.severity === 'error'),
)
+const licenseIssueAction = useProjectIssueFieldAction({
+ draft: () => ({
+ license: {
+ ...project.value.license,
+ id: licenseId.value,
+ url: normalizeProjectUrl(current.value.licenseUrl) || undefined,
+ },
+ }),
+ canSave: () => canSave.value,
+ saving,
+ validation: saveValidation,
+ save: async () => {
+ const license_id = licenseId.value
+ const submittedUrl = current.value.licenseUrl
+ const license_url = normalizeProjectUrl(submittedUrl) || null
+ if (
+ license_id !== project.value.license.id ||
+ (license_url ?? '') !== (project.value.license.url ?? '')
+ ) {
+ await patchProjectV3({ license_id, license_url }, true, true)
+ }
+ if (current.value.licenseUrl === submittedUrl) current.value.licenseUrl = license_url ?? ''
+ },
+})
+
async function save() {
if (!canSave.value || saving.value) return
const submittedState = saveValidation.snapshot()
diff --git a/apps/frontend/src/pages/[type]/[project]/settings/links.vue b/apps/frontend/src/pages/[type]/[project]/settings/links.vue
index b2c2098375c..183c43c6992 100644
--- a/apps/frontend/src/pages/[type]/[project]/settings/links.vue
+++ b/apps/frontend/src/pages/[type]/[project]/settings/links.vue
@@ -1,5 +1,11 @@
+
@@ -117,7 +123,9 @@
/>
-
+
+
+
@@ -160,6 +168,8 @@ import {
import { isAdmin } from '@modrinth/utils'
import ValidationMessage from '@/components/ValidationMessage.vue'
+import ProjectIssueCard from '~/components/ui/project-issue-card/index.vue'
+import { useProjectIssueFieldAction } from '~/composables/project-issue-field-action'
import { useProjectNagMessages } from '~/composables/project-nag-validation'
import { useProjectSaveValidation } from '~/composables/project-save-validation'
import {
@@ -539,6 +549,35 @@ const canSave = computed(
)
const saving = ref(false)
+const linksIssueAction = useProjectIssueFieldAction({
+ draft: () => {
+ const linkUrls = Object.fromEntries(
+ Object.entries(project.value.link_urls).filter(
+ ([platform]) => patchData.value[platform] !== null,
+ ),
+ )
+ for (const [platform, url] of Object.entries(patchData.value)) {
+ if (url !== null) {
+ linkUrls[platform] = {
+ platform,
+ donation: tags.value.donationPlatforms.some((tag) => tag.short === platform),
+ url,
+ }
+ }
+ }
+ return { link_urls: linkUrls }
+ },
+ canSave: () => hasPermission.value && !saveValidation.hasErrors.value,
+ saving,
+ validation: saveValidation,
+ save: async () => {
+ if (Object.keys(patchData.value).length > 0) {
+ await patchProjectV3({ link_urls: patchData.value }, true, true)
+ }
+ reset()
+ },
+})
+
async function save() {
if (!canSave.value || saving.value) return
const submittedState = saveValidation.snapshot()
diff --git a/apps/frontend/src/pages/[type]/[project]/settings/members.vue b/apps/frontend/src/pages/[type]/[project]/settings/members.vue
index c25c6a527ee..a856a31891c 100644
--- a/apps/frontend/src/pages/[type]/[project]/settings/members.vue
+++ b/apps/frontend/src/pages/[type]/[project]/settings/members.vue
@@ -1,5 +1,6 @@
+
+
+
@@ -234,6 +242,7 @@ import {
} from '@modrinth/ui'
import { isAdmin } from '@modrinth/utils'
+import ProjectIssueCard from '~/components/ui/project-issue-card/index.vue'
import CompatibilityCard from '~/components/ui/project-settings/CompatibilityCard.vue'
import ValidationMessage from '~/components/ValidationMessage.vue'
import { useProjectNagMessages } from '~/composables/project-nag-validation'
diff --git a/apps/frontend/src/pages/[type]/[project]/settings/tags.vue b/apps/frontend/src/pages/[type]/[project]/settings/tags.vue
index 4051859c9f4..6d329cdce58 100644
--- a/apps/frontend/src/pages/[type]/[project]/settings/tags.vue
+++ b/apps/frontend/src/pages/[type]/[project]/settings/tags.vue
@@ -18,10 +18,12 @@ import {
useSavable,
useVIntl,
} from '@modrinth/ui'
-import { capitalizeString, sortedCategories } from '@modrinth/utils'
+import { capitalizeString, isAdmin, sortedCategories } from '@modrinth/utils'
import { computed } from 'vue'
+import ProjectIssueCard from '~/components/ui/project-issue-card/index.vue'
import ValidationMessage from '~/components/ValidationMessage.vue'
+import { useProjectIssueFieldAction } from '~/composables/project-issue-field-action'
import { useProjectNagMessages } from '~/composables/project-nag-validation'
import { useProjectSaveValidation } from '~/composables/project-save-validation'
@@ -146,7 +148,7 @@ const groupDescriptionMessages: Record
= {
'performance impact': messages.performanceImpactDescription,
}
-const { projectV2: project, projectV3, patchProject } = injectProjectPageContext()
+const { projectV2: project, projectV3, patchProject, currentMember } = injectProjectPageContext()
useProjectSettingsHeadTitle(messages.title)
@@ -313,6 +315,33 @@ const { confirmLeaveModal } = usePageLeaveSafety(hasChanges)
const saveValidation = useProjectSaveValidation(() => current.value)
const canSave = computed(() => !saveValidation.hasErrors.value)
+const tagsIssueAction = useProjectIssueFieldAction({
+ draft: () => ({
+ categories: [...current.value.featuredTags],
+ additional_categories: current.value.selectedTags.filter(
+ (tag) => !current.value.featuredTags.includes(tag),
+ ),
+ }),
+ canSave: () =>
+ canSave.value &&
+ (isAdmin(currentMember.value?.user) ||
+ ((currentMember.value?.permissions ?? 0) & (1 << 2)) !== 0),
+ saving,
+ validation: saveValidation,
+ save: async () => {
+ const categories = [...current.value.featuredTags]
+ const additional_categories = current.value.selectedTags.filter(
+ (tag) => !categories.includes(tag),
+ )
+ if (
+ !hasSameTags(categories, project.value.categories) ||
+ !hasSameTags(additional_categories, project.value.additional_categories)
+ ) {
+ await patchProject({ categories, additional_categories }, true, true)
+ }
+ },
+})
+
async function save() {
if (!canSave.value || saving.value) return
const submittedState = saveValidation.snapshot()
@@ -429,6 +458,7 @@ const toggleFeatured = (tag: string) => {
:current-field="JSON.stringify(current)"
/>
+
+
+
+
+
-
-
-
-
- {{ formatMessage(messages.editFilesOption) }}
-
-
-
- {{ formatMessage(messages.editDetailsOption) }}
-
-
-
- {{ formatMessage(messages.editMetadataOption) }}
-
-
+
+
diff --git a/apps/frontend/src/pages/moderation/index.vue b/apps/frontend/src/pages/moderation/index.vue
index 3d94d37bc05..c1d73404cf9 100644
--- a/apps/frontend/src/pages/moderation/index.vue
+++ b/apps/frontend/src/pages/moderation/index.vue
@@ -153,6 +153,7 @@ import {
commonMessages,
defineMessages,
EmptyState,
+ injectLoadingState,
injectModrinthClient,
injectNotificationManager,
Pagination,
@@ -170,8 +171,7 @@ import ModerationQueueCard from '~/components/ui/moderation/ModerationQueueCard.
import ModerationQueueSkeleton from '~/components/ui/moderation/ModerationQueueSkeleton.vue'
import ModerationQueueToolbar from '~/components/ui/moderation/ModerationQueueToolbar.vue'
import QueueSummaryModal from '~/components/ui/moderation/QueueSummaryModal.vue'
-import { type ModerationProject, toModerationProjects } from '~/helpers/moderation.ts'
-import { getProjectTypeForUrlShorthand } from '~/helpers/projects.js'
+import { toModerationProjects } from '~/helpers/moderation.ts'
import { useModerationQueue } from '~/services/moderation/queue.ts'
import { findNextEligibleQueueProject } from '~/services/moderation/queue-eligibility.ts'
@@ -185,6 +185,7 @@ const moderationQueue = useModerationQueue()
const route = useRoute()
const router = useRouter()
const client = injectModrinthClient()
+const loadingState = injectLoadingState()
const queueSummaryModal = ref()
const moderateByIdsModal = ref
>()
@@ -478,14 +479,6 @@ const pageEnd = computed(() =>
totalProjects.value,
),
)
-const projectsById = computed(() => {
- const projects = new Map()
- for (const project of filteredProjects.value) {
- projects.set(project.project.id, project)
- }
-
- return projects
-})
watch(totalPages, (pages) => {
if (pages === 0 && currentPage.value !== 1) {
@@ -545,44 +538,33 @@ async function findFirstEligibleProject(): Promise {
return next.project
}
-function getProjectRouteParam(projectId: string): string {
- return projectsById.value.get(projectId)?.project.slug || projectId
-}
-
-function getProjectRouteType(projectId: string): string {
- const projectType = projectsById.value.get(projectId)?.project.project_types[0]
- if (!projectType) return 'project'
- return getProjectTypeForUrlShorthand(projectType, [])
-}
-
async function navigateToModerationProject(projectId: string) {
await navigateTo({
- name: 'type-project',
- params: {
- type: getProjectRouteType(projectId),
- project: getProjectRouteParam(projectId),
- },
- state: {
- showChecklist: true,
- },
+ path: '/moderation/project-review',
+ query: { project: projectId },
})
}
async function startModeratingByIds(projectIds: string[]) {
- await moderationQueue.setQueue(projectIds)
-
- const targetProjectId = await findFirstEligibleProject()
+ const token = loadingState.begin()
+ try {
+ await moderationQueue.setQueue(projectIds)
+
+ const targetProjectId = await findFirstEligibleProject()
+
+ if (!targetProjectId) {
+ addNotification({
+ title: 'No projects available',
+ text: 'None of the provided projects are awaiting moderation or available to review.',
+ type: 'warning',
+ })
+ return
+ }
- if (!targetProjectId) {
- addNotification({
- title: 'No projects available',
- text: 'None of the provided projects are awaiting moderation or available to review.',
- type: 'warning',
- })
- return
+ await navigateToModerationProject(targetProjectId)
+ } finally {
+ loadingState.end(token)
}
-
- await navigateToModerationProject(targetProjectId)
}
async function getFilteredProjectIds(): Promise {
@@ -597,58 +579,73 @@ async function getFilteredProjectIds(): Promise {
}
async function moderateAllInFilter() {
- const startIndex = (currentPage.value - 1) * itemsPerPage.value
- const projectIds = (await getFilteredProjectIds()).slice(startIndex)
- await moderationQueue.setQueue(projectIds)
-
- const targetProjectId = await findFirstEligibleProject()
+ const token = loadingState.begin()
+ try {
+ const startIndex = (currentPage.value - 1) * itemsPerPage.value
+ const projectIds = (await getFilteredProjectIds()).slice(startIndex)
+ await moderationQueue.setQueue(projectIds)
+
+ const targetProjectId = await findFirstEligibleProject()
+
+ if (!targetProjectId) {
+ addNotification({
+ title: 'No projects available',
+ text: 'All projects in queue are already moderated or locked by others.',
+ type: 'warning',
+ })
+ return
+ }
- if (!targetProjectId) {
- addNotification({
- title: 'No projects available',
- text: 'All projects in queue are already moderated or locked by others.',
- type: 'warning',
- })
- return
+ await navigateToModerationProject(targetProjectId)
+ } finally {
+ loadingState.end(token)
}
-
- await navigateToModerationProject(targetProjectId)
}
async function startFromProject(projectId: string) {
- const allFilteredProjectIds = await getFilteredProjectIds()
- const projectIndex = allFilteredProjectIds.indexOf(projectId)
- const projectIds = projectIndex === -1 ? [projectId] : allFilteredProjectIds.slice(projectIndex)
- await moderationQueue.setQueue(projectIds)
-
- const targetProjectId = await findFirstEligibleProject()
-
- if (!targetProjectId) {
- addNotification({
- title: 'No projects available',
- text: 'All projects in queue are already moderated or locked by others.',
- type: 'warning',
- })
- return
- }
+ const token = loadingState.begin()
+ try {
+ const allFilteredProjectIds = await getFilteredProjectIds()
+ const projectIndex = allFilteredProjectIds.indexOf(projectId)
+ const projectIds = projectIndex === -1 ? [projectId] : allFilteredProjectIds.slice(projectIndex)
+ await moderationQueue.setQueue(projectIds)
+
+ const targetProjectId = await findFirstEligibleProject()
+
+ if (!targetProjectId) {
+ addNotification({
+ title: 'No projects available',
+ text: 'All projects in queue are already moderated or locked by others.',
+ type: 'warning',
+ })
+ return
+ }
- await navigateToModerationProject(targetProjectId)
+ await navigateToModerationProject(targetProjectId)
+ } finally {
+ loadingState.end(token)
+ }
}
async function reviewSkippedQueue() {
- await moderationQueue.startSkippedReview()
-
- const targetProjectId = await findFirstEligibleProject()
+ const token = loadingState.begin()
+ try {
+ await moderationQueue.startSkippedReview()
+
+ const targetProjectId = await findFirstEligibleProject()
+
+ if (!targetProjectId) {
+ addNotification({
+ title: 'No projects available',
+ text: 'All previously skipped projects are already moderated or locked by others.',
+ type: 'warning',
+ })
+ return
+ }
- if (!targetProjectId) {
- addNotification({
- title: 'No projects available',
- text: 'All previously skipped projects are already moderated or locked by others.',
- type: 'warning',
- })
- return
+ await navigateToModerationProject(targetProjectId)
+ } finally {
+ loadingState.end(token)
}
-
- await navigateToModerationProject(targetProjectId)
}
diff --git a/apps/frontend/src/pages/moderation/project-review.vue b/apps/frontend/src/pages/moderation/project-review.vue
new file mode 100644
index 00000000000..2634974d06e
--- /dev/null
+++ b/apps/frontend/src/pages/moderation/project-review.vue
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
diff --git a/apps/frontend/src/providers/project-review/content.ts b/apps/frontend/src/providers/project-review/content.ts
new file mode 100644
index 00000000000..aa8d38c030f
--- /dev/null
+++ b/apps/frontend/src/providers/project-review/content.ts
@@ -0,0 +1,77 @@
+import { injectModrinthClient } from '@modrinth/ui'
+import { useQuery, useQueryClient } from '@tanstack/vue-query'
+import { computed, type Ref, watch } from 'vue'
+
+import {
+ flattenFileReports,
+ getFileDetailCount,
+} from '~/components/ui/moderation/tech-review/helpers'
+import { useTechReviewDecisions } from '~/components/ui/moderation/tech-review/use-tech-review-decisions'
+import { projectQueryOptions } from '~/composables/queries/project'
+
+export function useReviewContent(projectId: Ref) {
+ const client = injectModrinthClient()
+ const queryClient = useQueryClient()
+ const versionsQuery = useQuery(
+ computed(() => ({
+ ...projectQueryOptions.versionsV3(projectId.value, client),
+ enabled: !!projectId.value,
+ })),
+ )
+ const reportsQuery = useQuery(
+ computed(() => ({
+ queryKey: ['tech-review-project-report', projectId.value],
+ queryFn: () => client.labrinth.tech_review_internal.getProjectReport(projectId.value),
+ enabled: !!projectId.value,
+ })),
+ )
+ const versions = computed(() =>
+ (versionsQuery.data.value ?? [])
+ .filter((version) => version.name !== '__synthetic')
+ .toSorted((a, b) => Date.parse(b.date_published) - Date.parse(a.date_published)),
+ )
+ const reports = computed(() =>
+ flattenFileReports(reportsQuery.data.value?.project_report?.versions ?? []),
+ )
+ const decisions = useTechReviewDecisions(reports)
+ watch(projectId, decisions.resetDecisions, { flush: 'sync' })
+ const pendingReports = computed(() =>
+ reports.value.filter((file) => decisions.getFileMarkedCount(file) < getFileDetailCount(file)),
+ )
+ const completedReports = computed(() =>
+ reports.value.filter(
+ (file) =>
+ getFileDetailCount(file) > 0 &&
+ decisions.getFileMarkedCount(file) === getFileDetailCount(file),
+ ),
+ )
+ const unflaggedReports = computed(() =>
+ reports.value.filter((file) => getFileDetailCount(file) === 0),
+ )
+ const contentCounts = computed(() => ({
+ versions: versionsQuery.isSuccess.value ? versions.value.length : undefined,
+ 'tech-review': reportsQuery.isSuccess.value ? pendingReports.value.length : undefined,
+ }))
+
+ async function refreshReports() {
+ await Promise.all([
+ queryClient.invalidateQueries({
+ queryKey: ['tech-review-project-report'],
+ }),
+ queryClient.invalidateQueries({ queryKey: ['tech-reviews'] }),
+ ])
+ }
+
+ return {
+ versionsQuery,
+ versions,
+ reportsQuery,
+ reports,
+ decisions,
+ pendingReports,
+ completedReports,
+ unflaggedReports,
+ contentCounts,
+ refreshReports,
+ }
+}
diff --git a/apps/frontend/src/providers/project-review/context.ts b/apps/frontend/src/providers/project-review/context.ts
new file mode 100644
index 00000000000..d4fe72fe25a
--- /dev/null
+++ b/apps/frontend/src/providers/project-review/context.ts
@@ -0,0 +1,95 @@
+import type { ConfirmLeaveModal } from '@modrinth/ui'
+import { createContext } from '@modrinth/ui'
+import { isAdmin, isStaff } from '@modrinth/utils'
+import { useLocalStorage } from '@vueuse/core'
+import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
+import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
+
+import { useDisclosureEditor } from '~/components/ui/project-settings/disclosures/use-disclosure-editor'
+import { useAuthState } from '~/composables/auth'
+import { useModerationQueue } from '~/services/moderation/queue'
+
+import { useReviewContent } from './content'
+import { useReviewProject } from './project'
+import { useReviewQueue } from './queue'
+import type { ProjectReviewPageContext } from './types'
+
+export const [injectProjectReviewPageContext, provideProjectReviewPageContext] =
+ createContext('ProjectReviewPage')
+
+export function createProjectReviewPageContext() {
+ const pixelated = useLocalStorage('project-review-image-pixelated', false, {
+ initOnMounted: true,
+ })
+ const route = useRoute()
+ const router = useRouter()
+ const queue = useModerationQueue()
+ const selection = computed(() =>
+ typeof route.query.project === 'string' ? route.query.project : '',
+ )
+ const data = useReviewProject(selection)
+ const content = useReviewContent(data.projectId)
+ const auth = useAuthState()
+ const disclosures = useDisclosureEditor({
+ projectId: data.projectId,
+ projectTypes: computed(() => data.project.value?.project_types ?? []),
+ canEditDisclosures: computed(
+ () =>
+ !!data.project.value &&
+ (data.project.value.versions.length > 0 || data.project.value.minecraft_server != null),
+ ),
+ hasPermission: computed(() => isStaff(auth.value.user)),
+ isActingAsModerator: computed(() => isStaff(auth.value.user)),
+ isAdminUser: computed(() => isAdmin(auth.value.user)),
+ })
+ const confirmLeaveModal = ref>()
+ async function confirmDiscardDisclosures() {
+ if (disclosures.saving.value) return false
+ if (!disclosures.hasChanges.value) return true
+ if (!(await confirmLeaveModal.value?.prompt())) return false
+ disclosures.reset()
+ return true
+ }
+ const navigation = useReviewQueue(data.projectId, queue, confirmDiscardDisclosures)
+ onBeforeRouteLeave(confirmDiscardDisclosures)
+ onBeforeRouteUpdate((to, from) => {
+ if (to.query.project !== from.query.project) return confirmDiscardDisclosures()
+ })
+ function beforeUnload(event: BeforeUnloadEvent) {
+ if (disclosures.hasChanges.value || disclosures.saving.value) event.preventDefault()
+ }
+ onMounted(() => window.addEventListener('beforeunload', beforeUnload))
+ onBeforeUnmount(() => window.removeEventListener('beforeunload', beforeUnload))
+
+ onMounted(async () => {
+ await queue.ready
+ if (selection.value) return
+ const id = queue.currentQueue.activeProjectId ?? queue.getCurrentProjectId()
+ if (id) await router.replace({ query: { ...route.query, project: id } })
+ })
+ watch(data.projectId, async (id) => {
+ if (!id || navigation.busy.value) return
+ await queue.visitProject(id, queue.currentQueue.history.includes(id))
+ })
+
+ return {
+ pixelated,
+ disclosures,
+ confirmLeaveModal,
+ ...data,
+ ...content,
+ tabCounts: computed(() => ({
+ ...content.contentCounts.value,
+ gallery: data.project.value ? data.gallery.value.length : undefined,
+ disclosures: disclosures.disclosuresQuery.isSuccess.value
+ ? disclosures.currentSnapshot.value.disclosures.length
+ : undefined,
+ permissions: data.permissions.value.loaded
+ ? data.permissions.value.awaitingReviewCount
+ : undefined,
+ })),
+ selection,
+ queue,
+ navigation,
+ }
+}
diff --git a/apps/frontend/src/providers/project-review/index.ts b/apps/frontend/src/providers/project-review/index.ts
new file mode 100644
index 00000000000..c4e39a57578
--- /dev/null
+++ b/apps/frontend/src/providers/project-review/index.ts
@@ -0,0 +1,6 @@
+export {
+ createProjectReviewPageContext,
+ injectProjectReviewPageContext,
+ provideProjectReviewPageContext,
+} from './context'
+export type { ProjectReviewPageContext } from './types'
diff --git a/apps/frontend/src/providers/project-review/project-links.ts b/apps/frontend/src/providers/project-review/project-links.ts
new file mode 100644
index 00000000000..0bbb1e51627
--- /dev/null
+++ b/apps/frontend/src/providers/project-review/project-links.ts
@@ -0,0 +1,25 @@
+import type { Labrinth } from '@modrinth/api-client'
+
+export function previousReviewLinkUrls(issues: readonly Labrinth.Threads.v3.ThreadIssue[]) {
+ const links = new Map()
+ for (const issue of issues) {
+ if (issue.verdict === 'resolved') continue
+ for (const { what, verdict } of issue.facets) {
+ if (what.type !== 'modify_links' || verdict === 'resolved') continue
+ for (const [key, link] of Object.entries(what.value.links)) {
+ links.set(key, link.original)
+ }
+ }
+ }
+ return links
+}
+
+export function reviewExternalUrl(value: string | undefined): string | undefined {
+ if (!value) return
+ try {
+ const url = new URL(value)
+ if (url.protocol === 'https:' || url.protocol === 'http:') return url.href
+ } catch {
+ return undefined
+ }
+}
diff --git a/apps/frontend/src/providers/project-review/project.ts b/apps/frontend/src/providers/project-review/project.ts
new file mode 100644
index 00000000000..bcd0e588092
--- /dev/null
+++ b/apps/frontend/src/providers/project-review/project.ts
@@ -0,0 +1,179 @@
+import { injectModrinthClient } from '@modrinth/ui'
+import { useQuery, useQueryClient } from '@tanstack/vue-query'
+import { computed, type Ref } from 'vue'
+
+import { useCreatorProjectStats } from '~/composables/creator-project-stats'
+import { projectQueryOptions } from '~/composables/queries/project'
+import { isThreadIssueStatusChange } from '~/helpers/thread-issues'
+
+export function useReviewProject(selection: Ref) {
+ const client = injectModrinthClient()
+ const queryClient = useQueryClient()
+ const identity = useQuery(
+ computed(() => ({
+ ...projectQueryOptions.check(selection.value, client),
+ enabled: import.meta.client && !!selection.value,
+ })),
+ )
+ const projectId = computed(() => identity.data.value?.id ?? '')
+ const projectQuery = useQuery(
+ computed(() => ({
+ ...projectQueryOptions.v3(projectId.value, client),
+ enabled: !!projectId.value,
+ })),
+ )
+ const legacyQuery = useQuery(
+ computed(() => ({
+ ...projectQueryOptions.v2(projectId.value, client),
+ enabled: !!projectId.value,
+ })),
+ )
+ const memberQuery = useQuery(
+ computed(() => ({
+ ...projectQueryOptions.members(projectId.value, client),
+ enabled: !!projectId.value,
+ })),
+ )
+ const organizationQuery = useQuery(
+ computed(() => ({
+ ...projectQueryOptions.organization(projectId.value, client),
+ enabled: !!projectId.value && !!projectQuery.data.value?.organization,
+ })),
+ )
+ const organization = computed(() =>
+ projectQuery.data.value?.organization ? (organizationQuery.data.value ?? null) : null,
+ )
+ const organizationId = computed(() => organization.value?.id ?? '')
+ const members = computed(() =>
+ (memberQuery.data.value ?? [])
+ .filter((member) => member.accepted)
+ .toSorted((a, b) => Number(b.is_owner) - Number(a.is_owner) || a.ordering - b.ordering),
+ )
+ const organizationMembers = computed(() =>
+ (organization.value?.members ?? [])
+ .filter((member) => member.accepted)
+ .toSorted((a, b) => Number(b.is_owner) - Number(a.is_owner) || a.ordering - b.ordering),
+ )
+ const creatorStats = useCreatorProjectStats(
+ computed(() =>
+ [...organizationMembers.value, ...members.value].map((member) => member.user.id),
+ ),
+ organizationId,
+ )
+ const threadId = computed(() => projectQuery.data.value?.thread_id ?? '')
+ const threadQuery = useQuery({
+ queryKey: computed(() => ['thread', threadId.value]),
+ queryFn: () => client.labrinth.threads_v3.getThread(threadId.value),
+ enabled: computed(() => !!threadId.value),
+ })
+ const wasReviewed = computed(() => {
+ if (projectQuery.data.value?.status !== 'processing') return false
+ const messages = threadQuery.data.value?.messages ?? []
+ const approved = ['approved', 'archived', 'unlisted', 'private']
+ const rejected = ['rejected', 'withheld']
+ const lastApproval = messages.findLastIndex(
+ (message) =>
+ isThreadIssueStatusChange(message.body) && approved.includes(message.body.new_status),
+ )
+ return messages
+ .slice(lastApproval + 1)
+ .some(
+ (message) =>
+ message.body.type === 'status_change' && rejected.includes(message.body.new_status),
+ )
+ })
+ const attributionQuery = useQuery(
+ computed(() => ({
+ ...projectQueryOptions.attribution(projectId.value, client),
+ enabled:
+ !!projectId.value &&
+ !!projectQuery.data.value?.project_types.includes('modpack') &&
+ !projectQuery.data.value.minecraft_server,
+ })),
+ )
+ const permissions = computed(() => ({
+ groups: attributionQuery.data.value ?? [],
+ awaitingReviewCount: (attributionQuery.data.value ?? []).filter(
+ ({ attribution }) =>
+ attribution && attribution.kind !== 'globally_allowed' && !attribution.moderation_status,
+ ).length,
+ unresolvedCount: (attributionQuery.data.value ?? []).filter(
+ ({ attribution }) =>
+ !attribution ||
+ (attribution.kind !== 'globally_allowed' &&
+ attribution.moderation_status?.kind !== 'approved'),
+ ).length,
+ loaded: attributionQuery.isSuccess.value,
+ loading: attributionQuery.isFetching.value,
+ error: attributionQuery.error.value,
+ }))
+
+ const submissionCount = computed(
+ () =>
+ threadQuery.data.value?.messages.filter(
+ (message) =>
+ message.body.type === 'auto_approval' ||
+ (message.body.type === 'status_change' && message.body.new_status === 'processing'),
+ ).length,
+ )
+
+ async function refresh() {
+ await Promise.all([
+ queryClient.invalidateQueries({
+ queryKey: ['project', 'v3', projectId.value],
+ }),
+ queryClient.invalidateQueries({
+ queryKey: ['project', 'v2', projectId.value],
+ }),
+ queryClient.invalidateQueries({ queryKey: ['project', projectId.value] }),
+ queryClient.invalidateQueries({
+ queryKey: ['project-attribution', projectId.value],
+ }),
+ queryClient.invalidateQueries({
+ queryKey: ['tech-reviews', 'flagged-projects'],
+ }),
+ identity.refetch(),
+ ])
+ }
+
+ return {
+ projectId,
+ gallery: computed(() =>
+ (projectQuery.data.value?.gallery ?? []).toSorted((a, b) => a.ordering - b.ordering),
+ ),
+ project: projectQuery.data,
+ projectV2: legacyQuery.data,
+ organization,
+ organizationStats: creatorStats.organizationStats,
+ organizationMembers,
+ threadQuery,
+ wasReviewed,
+ permissions,
+ members,
+ memberStats: creatorStats.memberStats,
+ membersLoading: computed(
+ () => memberQuery.isPending.value || creatorStats.membersLoading.value,
+ ),
+ membersError: computed(() => memberQuery.isError.value || creatorStats.membersError.value),
+ organizationLoading: computed(
+ () =>
+ !!projectQuery.data.value?.organization &&
+ (organizationQuery.isPending.value || creatorStats.organizationLoading.value),
+ ),
+ organizationError: computed(
+ () =>
+ !!projectQuery.data.value?.organization &&
+ (organizationQuery.isError.value || creatorStats.organizationError.value),
+ ),
+ compatibilityError: legacyQuery.isError,
+ submissionCount,
+ isLoading: computed(
+ () =>
+ !!selection.value &&
+ (identity.isPending.value || projectQuery.isPending.value) &&
+ !identity.isError.value,
+ ),
+ error: computed(() => identity.error.value ?? projectQuery.error.value),
+ refresh,
+ }
+}
diff --git a/apps/frontend/src/providers/project-review/queue.ts b/apps/frontend/src/providers/project-review/queue.ts
new file mode 100644
index 00000000000..bd59dd85f9b
--- /dev/null
+++ b/apps/frontend/src/providers/project-review/queue.ts
@@ -0,0 +1,115 @@
+import { injectModrinthClient } from '@modrinth/ui'
+import { useQueryClient } from '@tanstack/vue-query'
+import { computed, type Ref, ref } from 'vue'
+
+import { projectQueryOptions } from '~/composables/queries/project'
+import type { ModerationQueueService } from '~/services/moderation/queue'
+import { findNextEligibleQueueProject } from '~/services/moderation/queue-eligibility'
+
+export function useReviewQueue(
+ projectId: Ref,
+ queue: ModerationQueueService,
+ beforeNavigate: () => Promise,
+) {
+ const router = useRouter()
+ const route = useRoute()
+ const client = injectModrinthClient()
+ const queryClient = useQueryClient()
+ const busy = ref(false)
+ const error = ref(null)
+ const canGoBack = computed(() => queue.currentQueue.history.length > 0)
+ const inQueue = computed(
+ () =>
+ queue.isQueueMode &&
+ [
+ ...queue.currentQueue.items,
+ ...queue.currentQueue.completed,
+ ...queue.currentQueue.skipped,
+ ].includes(projectId.value),
+ )
+ const completed = computed(() => queue.currentQueue.completed.includes(projectId.value))
+ const remaining = computed(() =>
+ inQueue.value ? queue.currentQueue.items.filter((id) => id !== projectId.value) : [],
+ )
+
+ async function run(action: () => Promise) {
+ if (busy.value) return
+ busy.value = true
+ error.value = null
+ try {
+ if (!(await beforeNavigate())) return
+ await action()
+ } catch (cause) {
+ error.value = cause
+ } finally {
+ busy.value = false
+ }
+ }
+
+ async function open(id: string, back = false) {
+ const failure = await router.push({
+ path: '/moderation/project-review',
+ query: { ...route.query, project: id },
+ })
+ if (failure) throw failure
+ await queue.visitProject(id, back)
+ }
+
+ async function advance(completedProjectId?: string) {
+ if (completedProjectId && completedProjectId !== projectId.value) return
+ await run(async () => {
+ const id = projectId.value
+ if (!id || !inQueue.value || (completedProjectId && completedProjectId !== id)) return
+ if (completedProjectId && !completed.value) await queue.completeProject(id)
+ const candidates = remaining.value
+ const next = await findNextEligibleQueueProject(client, queue, candidates, true)
+ if (next) await queryClient.fetchQuery(projectQueryOptions.v3(next.project, client))
+ if (!completed.value) await queue.deferProject(id)
+ for (const excluded of next?.excluded ?? candidates) await queue.excludeProject(excluded)
+ if (next) {
+ await open(next.project)
+ } else {
+ await router.push({
+ path: '/moderation',
+ state: { queueSummary: true },
+ })
+ }
+ })
+ }
+
+ async function next() {
+ await advance()
+ }
+
+ async function completeAndNext(id: string) {
+ await advance(id)
+ }
+
+ async function back() {
+ await run(async () => {
+ const id = queue.currentQueue.history.at(-1)
+ if (!id) return
+ await queryClient.fetchQuery(projectQueryOptions.v3(id, client))
+ await open(id, true)
+ })
+ }
+
+ async function exit() {
+ await run(async () => {
+ await router.push('/moderation')
+ })
+ }
+
+ return {
+ busy,
+ error,
+ canGoBack,
+ inQueue,
+ completed,
+ remaining,
+ next,
+ completeAndNext,
+ back,
+ exit,
+ }
+}
diff --git a/apps/frontend/src/providers/project-review/review-decision.ts b/apps/frontend/src/providers/project-review/review-decision.ts
new file mode 100644
index 00000000000..1fe15dcfc6b
--- /dev/null
+++ b/apps/frontend/src/providers/project-review/review-decision.ts
@@ -0,0 +1,45 @@
+import type { AbstractModrinthClient, Labrinth } from '@modrinth/api-client'
+
+import { sendThreadReply, type ThreadReply } from '~/helpers/thread-issues'
+
+export type FacetUpdate = {
+ id: string
+ data: Labrinth.Threads.v3.EditThreadIssueFacet
+}
+
+export interface ReviewDecision extends ThreadReply {
+ projectId: string
+ status?: Labrinth.Projects.v2.ProjectStatus
+ facetUpdates: FacetUpdate[]
+ issues?: Labrinth.Threads.v3.NewThreadIssues
+}
+
+/** Applies a review's findings and optional status change. */
+export async function applyReviewDecision(
+ decision: ReviewDecision,
+ client: Pick,
+ assertCurrent: () => void,
+ onMessageSent: () => void,
+): Promise {
+ const threads = client.labrinth.threads_v3
+ assertCurrent()
+ if (decision.body.trim()) {
+ await sendThreadReply(decision, client, assertCurrent)
+ onMessageSent()
+ }
+ if (decision.issues) {
+ assertCurrent()
+ await threads.createIssues(decision.threadId, decision.issues)
+ }
+ for (const facet of decision.facetUpdates) {
+ assertCurrent()
+ await threads.editIssueFacet(facet.id, facet.data)
+ }
+ assertCurrent()
+ if (decision.status) {
+ await client.labrinth.projects_v3.edit(decision.projectId, {
+ status: decision.status,
+ })
+ }
+ assertCurrent()
+}
diff --git a/apps/frontend/src/providers/project-review/review-messages.ts b/apps/frontend/src/providers/project-review/review-messages.ts
new file mode 100644
index 00000000000..b541c069909
--- /dev/null
+++ b/apps/frontend/src/providers/project-review/review-messages.ts
@@ -0,0 +1,72 @@
+import type { Labrinth } from '@modrinth/api-client'
+import { expandVariables } from '@modrinth/moderation/src/utils'
+import { createContext } from '@modrinth/ui'
+import { computed, reactive, type Ref, watch } from 'vue'
+
+import type { createReviewPanels } from './review-panels'
+
+export const [injectReviewMessages, provideReviewMessages] =
+ createContext>('ProjectReviewMessages')
+
+export function createReviewMessages(
+ project: Ref,
+ projectV2: Ref,
+ panels: ReturnType,
+) {
+ const overrides = reactive(new Map())
+ const defaults = reactive(new Map())
+ const generating = computed(() => !!project.value && projectV2.value?.id !== project.value.id)
+ const issueMessages = computed(() => {
+ const current = project.value
+ const legacy = projectV2.value
+ if (!current || !legacy || current.id !== legacy.id) return new Map()
+ return new Map(
+ panels.activeIssues.value.map(({ id, issue }) => [
+ id,
+ expandVariables(issue.message.trim(), legacy, current),
+ ]),
+ )
+ })
+ function issueMessage(id: string) {
+ const custom = panels.customIssues.value.find((issue) => issue.id === id)?.custom
+ if (custom) return custom.message
+ return overrides.get(id) ?? issueMessages.value.get(id) ?? defaults.get(id) ?? ''
+ }
+ function editIssueMessage(id: string, message: string) {
+ if (panels.customIssues.value.some((issue) => issue.id === id)) {
+ panels.updateCustomIssue(id, { message })
+ return
+ }
+ if (message === issueMessage(id)) return
+ const defaultMessage = issueMessages.value.get(id) ?? defaults.get(id)
+ if (message === defaultMessage) overrides.delete(id)
+ else overrides.set(id, message)
+ }
+ function resetIssueMessage(id: string) {
+ overrides.delete(id)
+ panels.updateCustomIssue(id, { message: '' })
+ }
+ function resetAllIssueMessages() {
+ overrides.clear()
+ }
+ watch(
+ () => project.value?.id,
+ () => {
+ overrides.clear()
+ defaults.clear()
+ },
+ { flush: 'sync' },
+ )
+
+ return {
+ generating,
+ issueMessage,
+ setIssueDefault: (id: string, message: string) => {
+ defaults.set(id, message)
+ },
+ editIssueMessage,
+ resetIssueMessage,
+ resetAllIssueMessages,
+ hasIssueOverride: (id: string) => overrides.has(id),
+ }
+}
diff --git a/apps/frontend/src/providers/project-review/review-panels.ts b/apps/frontend/src/providers/project-review/review-panels.ts
new file mode 100644
index 00000000000..b8c8e8da795
--- /dev/null
+++ b/apps/frontend/src/providers/project-review/review-panels.ts
@@ -0,0 +1,922 @@
+import type { Labrinth } from '@modrinth/api-client'
+import { IssuePriority, reviewPanels } from '@modrinth/moderation/src/data/issues'
+import { expandItemReviewPanels } from '@modrinth/moderation/src/data/issues/component-builders/item-panels'
+import {
+ issueTargets,
+ resolveIssueActions,
+} from '@modrinth/moderation/src/data/issues/component-builders/targets'
+import type {
+ Issue,
+ IssueAction,
+ Panel,
+ PanelNode,
+ PanelRegistration,
+ ReviewContext,
+ WithContext,
+} from '@modrinth/moderation/src/data/issues/component-builders/types'
+import { createContext } from '@modrinth/ui'
+import { computed, type Ref } from 'vue'
+
+import type { ReviewTarget } from './review'
+import type { createReviewSession } from './review-session'
+
+interface ResolvedIssueControlOptions {
+ disabled: boolean
+ required: boolean
+ issue?: Issue
+ issueId: string
+ childToggleIds?: string[]
+ label: string
+ tooltip?: string
+}
+
+export type ResolvedIssueControl = ResolvedIssueControlOptions &
+ (
+ | {
+ type: 'toggle'
+ id?: string
+ issueListLabel?: string
+ issueListGroup?: string
+ }
+ | {
+ type: 'markdown' | 'text' | 'textarea'
+ key: string
+ initial: string
+ placeholder?: string
+ rows?: number
+ maxlength?: number
+ }
+ | {
+ type: 'select'
+ key: string
+ multiple: boolean
+ initial: string[]
+ placeholder?: string
+ options: { value: string; label: string; disabled: boolean }[]
+ }
+ )
+
+interface IssueSelection {
+ issue: Issue
+ active: boolean
+ keys: Set
+ textValues: Record
+ selectValues: Record
+ missing: string[]
+}
+
+interface ResolvedPanelSection {
+ label?: string
+ controls: ResolvedIssueControl[]
+}
+
+export interface ReviewPanelBinding {
+ key: string
+ parentKey?: string
+ projectId: string
+ panel: {
+ icon: Panel['icon']
+ title: string | undefined
+ hint: string
+ guidanceUrl: string
+ sections: ResolvedPanelSection[]
+ }
+}
+
+export type ReviewIssueSelection = NonNullable
+
+export interface ReviewIssueControl {
+ binding: ReviewPanelBinding
+ control: ResolvedIssueControl
+}
+
+export interface ReviewIssue {
+ id: string
+ title: string
+ category: string
+ priority?: Issue['priority']
+ controls: ReviewIssueControl[]
+ custom?: {
+ id: string
+ priority: string
+ message: string
+ facets: string[]
+ previous?: Labrinth.Threads.v3.ThreadIssue
+ }
+}
+
+export const customIssueActions = {
+ mark_addressed: issueTargets.markAddressed(),
+ acknowledge_checkbox: issueTargets.acknowledge('checkbox'),
+ acknowledge_reply: issueTargets.acknowledge('reply'),
+ modify_title: issueTargets.modifyTitle(),
+ modify_slug: issueTargets.modifySlug(),
+ modify_summary: issueTargets.modifySummary(),
+ modify_description: issueTargets.modifyDescription(),
+ modify_license: issueTargets.modifyLicense(),
+ modify_icon: issueTargets.modifyIcon(),
+ remove_icon: issueTargets.removeIcon(),
+ add_gallery_images: issueTargets.addGalleryImages(),
+ modify_server_languages: issueTargets.modifyServerLanguages(),
+} satisfies Record
+
+function customIssueActionKey(what: Labrinth.Threads.v3.ThreadIssueTarget) {
+ if (what.type === 'acknowledge')
+ return what.value.mode === 'checkbox' ? 'acknowledge_checkbox' : 'acknowledge_reply'
+ return Object.hasOwn(customIssueActions, what.type)
+ ? (what.type as keyof typeof customIssueActions)
+ : undefined
+}
+
+function customIssueActionKeys(
+ keys: ReadonlySet,
+ previous?: Labrinth.Threads.v3.ThreadIssue,
+) {
+ return [
+ ...new Set(
+ [...keys].flatMap((key) => {
+ if (Object.hasOwn(customIssueActions, key)) return [key]
+ const facet = previous?.facets.find((facet) => key === `previous-facet:${facet.id}`)
+ const action = facet ? customIssueActionKey(facet.what) : undefined
+ return action ? [action] : []
+ }),
+ ),
+ ]
+}
+
+function customIssuePriority(value: unknown): keyof typeof IssuePriority {
+ const priority = String(value ?? 'Bottom')
+ if (priority === 'TOP' || priority === 'First') return 'Top'
+ if (priority === 'BOTTOM' || priority === 'Last') return 'Bottom'
+ return Object.hasOwn(IssuePriority, priority)
+ ? (priority as keyof typeof IssuePriority)
+ : 'Bottom'
+}
+
+function resolveWithContext(value: WithContext, context: ReviewContext): T {
+ return typeof value === 'function' ? (value as (context: ReviewContext) => T)(context) : value
+}
+
+export const [injectReviewPanels, provideReviewPanels] =
+ createContext>('ProjectReviewPanels')
+
+export function createReviewPanels(
+ project: Ref,
+ session: ReturnType,
+ reviewData: Ref<
+ Pick<
+ ReviewContext,
+ | 'projectV2'
+ | 'disclosures'
+ | 'members'
+ | 'organization'
+ | 'organizationMembers'
+ | 'wasReviewed'
+ | 'permissions'
+ > & {
+ previousLinks: ReadonlyMap
+ previousIssueIds?: readonly string[]
+ }
+ >,
+ definitions: Record = reviewPanels,
+) {
+ const customIssues = computed(() => {
+ if (!project.value) return []
+ return Object.entries(session.read(project.value.id, 'custom-issues')).flatMap(
+ ([id, value]) => {
+ if (!value || typeof value !== 'object' || value instanceof Set) return []
+ const previous =
+ typeof value.previous === 'string'
+ ? (JSON.parse(value.previous) as Labrinth.Threads.v3.ThreadIssue)
+ : undefined
+ return [
+ {
+ id,
+ title: 'Custom issue',
+ category: 'Project wide',
+ priority: IssuePriority[customIssuePriority(value.priority)],
+ controls: [],
+ custom: {
+ id: String(value.id ?? id),
+ priority: customIssuePriority(value.priority),
+ message: String(value.message ?? ''),
+ facets:
+ value.facets instanceof Set ? customIssueActionKeys(value.facets, previous) : [],
+ previous,
+ },
+ },
+ ]
+ },
+ )
+ })
+
+ function updateCustomIssue(id: string, changes: Partial>) {
+ const current = customIssues.value.find((issue) => issue.id === id)?.custom
+ if (!project.value || !current) return
+ const next = { ...current, ...changes }
+ const { previous, ...values } = next
+ session.write(project.value.id, 'custom-issues', id, {
+ ...values,
+ facets: new Set(next.facets),
+ ...(previous ? { previous: JSON.stringify(previous) } : {}),
+ })
+ }
+
+ function restoreCustomIssue(previous: Labrinth.Threads.v3.ThreadIssue) {
+ if (!project.value) return
+ const id = `restored-custom:${previous.id}`
+ if (!customIssues.value.some((issue) => issue.id === id)) {
+ session.write(project.value.id, 'custom-issues', id, {
+ id: previous.why.issue_id ?? id,
+ priority: customIssuePriority(previous.why.custom?.priority),
+ message: previous.why.message ?? '',
+ facets: new Set(
+ previous.facets.flatMap(({ what }) => {
+ const action = customIssueActionKey(what)
+ return action ? [action] : []
+ }),
+ ),
+ previous: JSON.stringify(previous),
+ })
+ }
+ addIssue(id)
+ return customIssues.value.find((issue) => issue.id === id)
+ }
+
+ function addCustomIssue() {
+ if (!project.value) return
+ const sequence = Number(session.read(project.value.id, 'custom-issue-counter').value ?? 0) + 1
+ let number = sequence
+ const used = new Set([
+ ...availableIssues.value.flatMap((issue) => [issue.id, issue.custom?.id?.trim()]),
+ ...(reviewData.value.previousIssueIds ?? []),
+ ])
+ while (used.has(`custom-issue-${number}`)) number++
+ const id = `custom-issue-${number}`
+ session.write(project.value.id, 'custom-issue-counter', 'value', number)
+ session.write(project.value.id, 'custom-issues', id, {
+ id,
+ priority: 'Bottom',
+ message: '',
+ facets: new Set(),
+ })
+ addIssue(id)
+ }
+
+ function selectedToggleIds(projectId: string, issueId: string): Set {
+ const keys = session.read(projectId, 'issues')[issueId]
+ return keys instanceof Set ? keys : new Set()
+ }
+
+ function isSelected(projectId: string, control: ResolvedIssueControl): boolean {
+ if (control.type !== 'toggle') return false
+ return control.id === undefined
+ ? session.read(projectId, 'issue-active')[control.issueId] === true
+ : selectedToggleIds(projectId, control.issueId).has(control.id)
+ }
+
+ function textValues(projectId: string, issueId: string): Record {
+ const values = session.read(projectId, 'issue-text')[issueId]
+ if (!values || typeof values !== 'object' || values instanceof Set) return {}
+ const result: Record = {}
+ for (const [key, value] of Object.entries(values)) {
+ if (typeof value === 'string') result[key] = value
+ }
+ return result
+ }
+
+ function contextSelectValues(projectId: string, issueId: string, key: string): string[] {
+ const values = session.read(projectId, 'issue-select')[issueId]
+ const scope =
+ values && typeof values === 'object' && !(values instanceof Set) && key in values
+ ? issueId
+ : ''
+ return readSelectValues(projectId, scope, key)
+ }
+
+ const selectedIssueIds = computed(() => {
+ const projectId = project.value?.id
+ if (!projectId) return []
+ return [
+ ...new Set([
+ ...Object.entries(session.read(projectId, 'issue-active'))
+ .filter(([, selected]) => selected === true)
+ .map(([id]) => id),
+ ...Object.entries(session.read(projectId, 'issues'))
+ .filter(([, keys]) => keys instanceof Set && keys.size > 0)
+ .map(([id]) => id),
+ ]),
+ ]
+ })
+
+ const panels = computed(() => {
+ const bindings = new Map()
+ const projectV3 = project.value
+ if (!projectV3) return bindings
+ const context: ReviewContext = {
+ projectV3,
+ ...reviewData.value,
+ getTextValue: (id, issueId = '') =>
+ textValues(projectV3.id, issueId)[id] ?? textValues(projectV3.id, '')[id] ?? '',
+ getSelectValue: (id, issueId = '') => contextSelectValues(projectV3.id, issueId, id)[0] ?? '',
+ getSelectValues: (id, issueId = '') => contextSelectValues(projectV3.id, issueId, id),
+ getMarkdownValue: (id, issueId = '') =>
+ textValues(projectV3.id, issueId)[id] ?? textValues(projectV3.id, '')[id] ?? '',
+ selected: {
+ items: {},
+ issueIds: selectedIssueIds.value,
+ toggleIds: [
+ ...new Set(
+ Object.values(session.read(projectV3.id, 'issues')).flatMap((keys) =>
+ keys instanceof Set ? [...keys] : [],
+ ),
+ ),
+ ],
+ },
+ }
+ const {
+ panels: itemDefinitions,
+ parents: itemParents,
+ issues: itemIssues,
+ } = expandItemReviewPanels(definitions, context)
+ for (const [key, panel] of Object.entries(itemDefinitions)) {
+ const previousLink =
+ key.endsWith('-link') && reviewData.value.previousLinks.has(key.slice(0, -'-link'.length))
+ if (resolveWithContext(panel.shown, context) === false && !previousLink) continue
+ const resolveNodes = (
+ nodes: readonly PanelNode[],
+ label?: string,
+ ): ResolvedPanelSection[] => {
+ const sections: ResolvedPanelSection[] = []
+ let controls: ResolvedIssueControl[] = []
+
+ function flush() {
+ if (controls.length) sections.push({ label, controls })
+ controls = []
+ label = undefined
+ }
+
+ for (const node of nodes) {
+ if (node.type === 'section') {
+ if (resolveWithContext(node.shown, context) === false) continue
+ flush()
+ sections.push(...resolveNodes(node.children, resolveWithContext(node.label, context)))
+ continue
+ }
+ const issue = node.issue ? (itemIssues.get(node.issue.id) ?? node.issue) : undefined
+ const issueId = issue?.id ?? ''
+ const issueContext: ReviewContext = {
+ ...context,
+ getMarkdownValue: (id, scope = issueId) =>
+ textValues(projectV3.id, scope)[id] ?? textValues(projectV3.id, '')[id] ?? '',
+ getTextValue: (id, scope = issueId) =>
+ textValues(projectV3.id, scope)[id] ?? textValues(projectV3.id, '')[id] ?? '',
+ getSelectValue: (id, scope = issueId) =>
+ contextSelectValues(projectV3.id, scope, id)[0] ?? '',
+ getSelectValues: (id, scope = issueId) => contextSelectValues(projectV3.id, scope, id),
+ selected: {
+ items: {},
+ issueIds: selectedIssueIds.value,
+ toggleIds: issue
+ ? [...selectedToggleIds(projectV3.id, issueId)]
+ : context.selected.toggleIds,
+ },
+ }
+ if (resolveWithContext(node.shown, issueContext) === false) continue
+ const options: ResolvedIssueControlOptions = {
+ disabled: resolveWithContext(node.disabled, issueContext) === true,
+ issue,
+ issueId,
+ required:
+ node.type !== 'toggle' && resolveWithContext(node.required, issueContext) === true,
+ label: resolveWithContext(node.label, issueContext),
+ tooltip: resolveWithContext(node.tooltip, issueContext),
+ }
+ let control: ResolvedIssueControl
+ if (node.type === 'toggle') {
+ control = {
+ ...options,
+ type: 'toggle',
+ id: node.id,
+ issueListLabel: resolveWithContext(node.issueListLabel, issueContext),
+ issueListGroup: resolveWithContext(node.issueListGroup, issueContext),
+ }
+ } else if (node.type === 'select') {
+ const initial = resolveWithContext(node.initial, issueContext)
+ control = {
+ ...options,
+ type: 'select',
+ key: node.id,
+ multiple: node.multiple === true,
+ initial: typeof initial === 'string' ? [initial] : [...(initial ?? [])],
+ placeholder: resolveWithContext(node.placeholder, issueContext),
+ options: resolveWithContext(node.options, issueContext)
+ .filter((option) => resolveWithContext(option.shown, issueContext) !== false)
+ .map((option) => ({
+ value: option.value,
+ label: resolveWithContext(option.label, issueContext),
+ disabled: resolveWithContext(option.disabled, issueContext) === true,
+ })),
+ }
+ } else {
+ control = {
+ ...options,
+ type: node.type,
+ key: node.id,
+ initial:
+ node.type === 'text' || node.type === 'textarea'
+ ? (resolveWithContext(node.initial, issueContext) ?? '')
+ : '',
+ placeholder:
+ node.type === 'text' || node.type === 'textarea'
+ ? resolveWithContext(node.placeholder, issueContext)
+ : undefined,
+ rows: node.type === 'textarea' ? node.rows : undefined,
+ maxlength: node.type === 'textarea' ? node.maxlength : undefined,
+ }
+ }
+ controls.push(control)
+ }
+ flush()
+ return sections
+ }
+
+ const sections = resolveNodes(panel.children)
+ bindings.set(key, {
+ key,
+ parentKey: itemParents.get(key),
+ projectId: projectV3.id,
+ panel: {
+ icon: panel.icon,
+ title: resolveWithContext(panel.title, context),
+ hint: resolveWithContext(panel.hint, context),
+ guidanceUrl: panel.guidanceUrl,
+ sections,
+ },
+ })
+ }
+ for (const binding of bindings.values()) {
+ for (const control of binding.panel.sections.flatMap((section) => section.controls)) {
+ if (control.type !== 'toggle') continue
+ const children = [...bindings.values()]
+ .filter((item) => item.parentKey === binding.key)
+ .flatMap((item) => item.panel.sections.flatMap((section) => section.controls))
+ .filter(
+ (item) =>
+ item.type === 'toggle' &&
+ item.issueId === control.issueId &&
+ item.id?.endsWith(`:${control.id ?? control.issueId}`),
+ )
+ if (!children.length) continue
+ control.childToggleIds = children.flatMap((item) =>
+ item.type === 'toggle' && !item.disabled && item.id ? [item.id] : [],
+ )
+ control.disabled ||= control.childToggleIds.length === 0
+ }
+ }
+ return bindings
+ })
+
+ function resolve(target: ReviewTarget) {
+ if (target.kind === 'panel') return panels.value.get(target.key)
+ const aliases: Partial> = {
+ tags: 'categories',
+ compatibility: 'metadata',
+ 'license-url': 'license',
+ version: 'versions',
+ }
+ if (target.kind === 'gallery-image') return panels.value.get(`${target.kind}:${target.key}`)
+ if (target.kind === 'disclosure') return panels.value.get(`${target.key}-disclosure`)
+ return panels.value.get(
+ target.kind === 'link' ? `${target.key}-link` : (aliases[target.kind] ?? target.kind),
+ )
+ }
+
+ function selected(binding: ReviewPanelBinding, control: ResolvedIssueControl): boolean {
+ if (control.childToggleIds)
+ return (
+ control.childToggleIds.length > 0 &&
+ control.childToggleIds.every((id) =>
+ selectedToggleIds(binding.projectId, control.issueId).has(id),
+ )
+ )
+ return isSelected(binding.projectId, control)
+ }
+
+ /** Counts selected toggle controls in this panel, rather than distinct issue IDs. */
+ function selectedFindingCount(binding: ReviewPanelBinding): number {
+ return binding.panel.sections.reduce(
+ (count, section) =>
+ count + section.controls.filter((control) => selected(binding, control)).length,
+ 0,
+ )
+ }
+
+ function textValue(binding: ReviewPanelBinding, control: ResolvedIssueControl): string {
+ if (control.type !== 'markdown' && control.type !== 'text' && control.type !== 'textarea')
+ return ''
+ return textValues(binding.projectId, control.issueId)[control.key] ?? control.initial
+ }
+
+ function readSelectValues(projectId: string, issueId: string, key: string): string[] {
+ const values = session.read(projectId, 'issue-select')[issueId]
+ if (!values || typeof values !== 'object' || values instanceof Set) return []
+ const value = values[key]
+ return value instanceof Set ? [...value] : []
+ }
+
+ function selectValues(binding: ReviewPanelBinding, control: ResolvedIssueControl): string[] {
+ if (control.type !== 'select') return []
+ const stored = session.read(binding.projectId, 'issue-select')[control.issueId]
+ const hasValue =
+ stored && typeof stored === 'object' && !(stored instanceof Set) && control.key in stored
+ const values = hasValue
+ ? readSelectValues(binding.projectId, control.issueId, control.key)
+ : control.initial
+ const allowed = new Set(
+ control.options.filter((option) => !option.disabled).map((option) => option.value),
+ )
+ const selected = [...new Set(values)].filter((value) => allowed.has(value))
+ return control.multiple ? selected : selected.slice(0, 1)
+ }
+
+ function missing(binding: ReviewPanelBinding, control: ResolvedIssueControl): boolean {
+ if (!control.required || control.disabled) return false
+ return control.type === 'select'
+ ? selectValues(binding, control).length === 0
+ : control.type !== 'toggle' && !textValue(binding, control).trim()
+ }
+
+ function write(
+ binding: ReviewPanelBinding,
+ control: ResolvedIssueControl,
+ value: boolean | string | string[],
+ ) {
+ if (binding.projectId !== project.value?.id) return
+ const current = panels.value
+ .get(binding.key)
+ ?.panel.sections.flatMap((section) => section.controls)
+ .find(
+ (entry) =>
+ entry.issueId === control.issueId &&
+ entry.type === control.type &&
+ (entry.type === 'toggle' && control.type === 'toggle'
+ ? entry.id === control.id
+ : entry.type !== 'toggle' && control.type !== 'toggle' && entry.key === control.key),
+ )
+ if (!current || current.disabled) return
+ if (current.type === 'select') {
+ if (typeof value === 'boolean') return
+ const values = typeof value === 'string' ? (value ? [value] : []) : value
+ if (!current.multiple && values.length > 1) return
+ if (
+ values.some(
+ (value) => !current.options.some((option) => option.value === value && !option.disabled),
+ )
+ )
+ return
+ const stored = session.read(binding.projectId, 'issue-select')[current.issueId]
+ const previous =
+ stored && typeof stored === 'object' && !(stored instanceof Set) ? stored : {}
+ session.write(binding.projectId, 'issue-select', current.issueId, {
+ ...previous,
+ [current.key]: new Set(values),
+ })
+ return
+ }
+ if (current.type !== 'toggle') {
+ if (typeof value !== 'string') return
+ const values = textValues(binding.projectId, current.issueId)
+ values[current.key] = value
+ session.write(binding.projectId, 'issue-text', current.issueId, values)
+ return
+ }
+ if (typeof value !== 'boolean') return
+ if (current.childToggleIds) {
+ const keys = new Set(selectedToggleIds(binding.projectId, current.issueId))
+ for (const id of current.childToggleIds) {
+ if (value) keys.add(id)
+ else keys.delete(id)
+ }
+ session.write(binding.projectId, 'issues', current.issueId, keys.size ? keys : undefined)
+ updateIssueOrder(binding.projectId, current.issueId)
+ return
+ }
+ if (current.id === undefined) {
+ session.write(binding.projectId, 'issue-active', current.issueId, value || undefined)
+ updateIssueOrder(binding.projectId, current.issueId)
+ return
+ }
+ const keys = new Set(selectedToggleIds(binding.projectId, current.issueId))
+ if (value) keys.add(current.id)
+ else keys.delete(current.id)
+ session.write(binding.projectId, 'issues', current.issueId, keys.size ? keys : undefined)
+ updateIssueOrder(binding.projectId, current.issueId)
+ }
+
+ function updateIssueOrder(projectId: string, issueId: string) {
+ const active =
+ session.read(projectId, 'issue-active')[issueId] === true ||
+ selectedToggleIds(projectId, issueId).size > 0
+ const order = session.read(projectId, 'issue-order')
+ if (active && order[issueId] !== true) session.write(projectId, 'issue-order', issueId, true)
+ else if (!active && order[issueId] === true)
+ session.write(projectId, 'issue-order', issueId, undefined)
+ }
+
+ const availableIssues = computed(() => {
+ const issues = new Map()
+ for (const binding of panels.value.values()) {
+ for (const section of binding.panel.sections) {
+ for (const control of section.controls) {
+ if (control.childToggleIds || !control.issue) continue
+ const issue: ReviewIssue = issues.get(control.issueId) ?? {
+ id: control.issueId,
+ title: control.issue.title,
+ category: control.issue.category,
+ priority: control.issue.priority,
+ controls: [],
+ }
+ if (
+ !issue.controls.some(
+ ({ control: existing }) =>
+ existing.type === control.type &&
+ (existing.type === 'toggle' && control.type === 'toggle'
+ ? existing.id === control.id
+ : existing.type !== 'toggle' &&
+ control.type !== 'toggle' &&
+ existing.key === control.key),
+ )
+ )
+ issue.controls.push({ binding, control })
+ issues.set(issue.id, issue)
+ }
+ }
+ }
+ return [
+ ...customIssues.value,
+ ...[...issues.values()].filter((issue) =>
+ issue.controls.some(({ control }) => control.type === 'toggle' && !control.disabled),
+ ),
+ ]
+ })
+
+ function addIssue(id: string) {
+ const current = project.value
+ const issue = availableIssues.value.find((issue) => issue.id === id)
+ if (!current || !issue) return
+ session.write(current.id, 'issue-active', id, true)
+ updateIssueOrder(current.id, id)
+ }
+
+ function issueSelection(id: string): ReviewIssueSelection {
+ const projectId = project.value?.id
+ if (!projectId) return { toggle_ids: [], text_values: {}, select_values: {} }
+ const selects = session.read(projectId, 'issue-select')[id]
+ return {
+ toggle_ids: [...selectedToggleIds(projectId, id)].sort(),
+ text_values: Object.fromEntries(
+ Object.entries(textValues(projectId, id)).sort(([a], [b]) => a.localeCompare(b)),
+ ),
+ select_values:
+ selects && typeof selects === 'object' && !(selects instanceof Set)
+ ? Object.fromEntries(
+ Object.entries(selects)
+ .sort(([a], [b]) => a.localeCompare(b))
+ .flatMap(([key, values]) =>
+ values instanceof Set ? [[key, [...values].sort()]] : [],
+ ),
+ )
+ : {},
+ }
+ }
+
+ function isRestoredIssue(id: string) {
+ const projectId = project.value?.id
+ if (!projectId) return false
+ const selection = issueSnapshot(id)
+ return Object.values(session.read(projectId, 'previous-issue-selection')).some(
+ (saved) => saved === selection,
+ )
+ }
+
+ function issueSnapshot(id: string) {
+ const custom = customIssues.value.find((issue) => issue.id === id)?.custom
+ return JSON.stringify({
+ id,
+ ...issueSelection(id),
+ ...(custom
+ ? {
+ custom: {
+ id: custom.id,
+ priority: custom.priority,
+ message: custom.message,
+ facets: [...custom.facets].sort(),
+ },
+ }
+ : {}),
+ })
+ }
+
+ function removeIssue(id: string) {
+ if (!project.value) return
+ for (const scope of [
+ 'issue-active',
+ 'issues',
+ 'issue-text',
+ 'issue-select',
+ 'issue-order',
+ 'custom-issues',
+ ]) {
+ session.write(project.value.id, scope, id, undefined)
+ }
+ }
+
+ const activeIssues = computed(() => {
+ const projectV3 = project.value
+ if (!projectV3) return []
+ const issues = new Map()
+ const sharedInputs = {
+ textValues: {} as Record,
+ selectValues: {} as Record,
+ }
+ for (const entry of availableIssues.value) {
+ if (session.read(projectV3.id, 'issue-active')[entry.id] !== true) continue
+ issues.set(entry.id, {
+ issue: entry.custom
+ ? {
+ id: entry.id,
+ title: entry.title,
+ category: entry.category,
+ priority: entry.priority,
+ message: entry.custom.message,
+ locations: entry.custom.previous?.why.locations,
+ actions: entry.custom.facets.flatMap((type) =>
+ Object.hasOwn(customIssueActions, type)
+ ? [customIssueActions[type as keyof typeof customIssueActions]]
+ : [],
+ ),
+ }
+ : entry.controls[0].control.issue!,
+ active: true,
+ keys: new Set(),
+ textValues: {},
+ selectValues: {},
+ missing: [],
+ })
+ }
+ for (const binding of panels.value.values()) {
+ for (const section of binding.panel.sections) {
+ for (const control of section.controls) {
+ if (!control.issue) {
+ if (control.disabled) continue
+ if (control.type === 'select')
+ sharedInputs.selectValues[control.key] = selectValues(binding, control)
+ else if (control.type !== 'toggle')
+ sharedInputs.textValues[control.key] = textValue(binding, control)
+ continue
+ }
+ if (
+ control.childToggleIds ||
+ control.disabled ||
+ (control.type === 'toggle' && !selected(binding, control))
+ )
+ continue
+ const selection: IssueSelection = issues.get(control.issueId) ?? {
+ issue: control.issue,
+ active: false,
+ keys: new Set(),
+ textValues: {},
+ selectValues: {},
+ missing: [],
+ }
+ if (missing(binding, control) && control.type !== 'toggle')
+ selection.missing.push(control.key)
+ if (control.type === 'select')
+ selection.selectValues[control.key] = selectValues(binding, control)
+ else if (control.type !== 'toggle')
+ selection.textValues[control.key] = textValue(binding, control)
+ else {
+ selection.active = true
+ if (control.id !== undefined) selection.keys.add(control.id)
+ }
+ issues.set(control.issueId, selection)
+ }
+ }
+ }
+ const order = new Map(
+ Object.keys(session.read(projectV3.id, 'issue-order')).map((id, index) => [id, index]),
+ )
+ return [...issues]
+ .filter(([, { active }]) => active)
+ .sort(
+ ([a, { issue: issueA }], [b, { issue: issueB }]) =>
+ (issueA.priority ?? IssuePriority.Default) - (issueB.priority ?? IssuePriority.Default) ||
+ (order.get(a) ?? -1) - (order.get(b) ?? -1),
+ )
+ .map(([id, { issue, keys, missing }]) => {
+ const context: ReviewContext = {
+ projectV3,
+ ...reviewData.value,
+ selected: {
+ items: {},
+ issueIds: selectedIssueIds.value,
+ toggleIds: [...keys],
+ },
+ getMarkdownValue: (key, scope = id) =>
+ issues.get(scope)?.textValues[key] ?? sharedInputs.textValues[key] ?? '',
+ getTextValue: (key, scope = id) =>
+ issues.get(scope)?.textValues[key] ?? sharedInputs.textValues[key] ?? '',
+ getSelectValue: (key, scope = id) =>
+ (issues.get(scope)?.selectValues[key] ?? sharedInputs.selectValues[key])?.[0] ?? '',
+ getSelectValues: (key, scope = id) =>
+ issues.get(scope)?.selectValues[key] ?? sharedInputs.selectValues[key] ?? [],
+ }
+ const controls = availableIssues.value.find((entry) => entry.id === id)?.controls ?? []
+ if (
+ !keys.size &&
+ !controls.some(({ control }) => control.type === 'toggle' && control.id === undefined)
+ ) {
+ if (!customIssues.value.some((entry) => entry.id === id)) missing.push('toggle')
+ }
+ const custom = customIssues.value.find((entry) => entry.id === id)?.custom
+ if (custom) {
+ if (!custom.message.trim()) missing.push('message')
+ if (
+ !custom.id.trim() ||
+ (custom.id.trim() !== custom.previous?.why.issue_id &&
+ reviewData.value.previousIssueIds?.includes(custom.id.trim())) ||
+ availableIssues.value.some(
+ (entry) => entry.id !== id && (entry.custom?.id ?? entry.id) === custom.id.trim(),
+ )
+ )
+ missing.push('id')
+ }
+ return {
+ id,
+ missing: [...new Set(missing)],
+ locations: [...(resolveWithContext(issue.locations, context) ?? [])],
+ facets: resolveIssueActions(issue.actions, context),
+ issue: {
+ message: resolveWithContext(issue.message, context),
+ suggestedStatus: resolveWithContext(issue.suggestedStatus, context),
+ },
+ }
+ })
+ })
+
+ const validationErrors = computed(() => [
+ ...activeIssues.value
+ .filter(({ id }) => !isRestoredIssue(id))
+ .flatMap(({ id, missing }) => missing.map((key) => ({ issueId: id, key }))),
+ ...[...panels.value.values()].flatMap((binding) =>
+ binding.panel.sections.flatMap((section) =>
+ section.controls.flatMap((control) =>
+ !control.issue && control.type !== 'toggle' && missing(binding, control)
+ ? [{ issueId: '', key: control.key }]
+ : [],
+ ),
+ ),
+ ),
+ ])
+
+ return {
+ previousLinks: computed(() => reviewData.value.previousLinks),
+ issueBindings: (issueId: string) =>
+ [...panels.value.values()].filter((binding) =>
+ binding.panel.sections.some((section) =>
+ section.controls.some(
+ (control) =>
+ control.issueId === issueId &&
+ !control.childToggleIds &&
+ !control.disabled &&
+ selected(binding, control),
+ ),
+ ),
+ ),
+ hasServer: computed(() => !!project.value?.minecraft_server),
+ customIssues,
+ addCustomIssue,
+ updateCustomIssue,
+ restoreCustomIssue,
+ availableIssues,
+ addIssue,
+ issueSelection,
+ issueSnapshot,
+ isRestoredIssue,
+ removeIssue,
+ resolve,
+ mixed: (binding: ReviewPanelBinding, control: ResolvedIssueControl) =>
+ !!control.childToggleIds?.some((id) =>
+ selectedToggleIds(binding.projectId, control.issueId).has(id),
+ ) && !selected(binding, control),
+ selected,
+ selectedFindingCount,
+ textValue,
+ selectValues,
+ missing,
+ write,
+ activeIssues,
+ validationErrors,
+ }
+}
diff --git a/apps/frontend/src/providers/project-review/review-previous-issues.ts b/apps/frontend/src/providers/project-review/review-previous-issues.ts
new file mode 100644
index 00000000000..1c0d89e4776
--- /dev/null
+++ b/apps/frontend/src/providers/project-review/review-previous-issues.ts
@@ -0,0 +1,494 @@
+import type { Labrinth } from '@modrinth/api-client'
+import { IssuePriority } from '@modrinth/moderation/src/data/issues'
+import { issueTargetLabels } from '@modrinth/moderation/src/data/issues/component-builders/targets'
+import { createContext, useVIntl } from '@modrinth/ui'
+import { computed, type Ref, watch } from 'vue'
+
+import type { ReviewTarget } from './review'
+import type { createReviewMessages } from './review-messages'
+import type {
+ createReviewPanels,
+ ResolvedIssueControl,
+ ReviewIssue,
+ ReviewPanelBinding,
+} from './review-panels'
+import type { createReviewSession } from './review-session'
+
+type ThreadIssue = Labrinth.Threads.v3.ThreadIssue
+type ThreadIssueFacet = Labrinth.Threads.v3.ThreadIssueFacet
+
+export const [injectReviewPreviousIssues, provideReviewPreviousIssues] =
+ createContext>('ReviewPreviousIssues')
+
+export function createReviewPreviousIssues(
+ project: Ref,
+ thread: Ref,
+ wasReviewed: Ref,
+ session: ReturnType,
+ panels: ReturnType,
+ messages: ReturnType,
+) {
+ const { formatMessage } = useVIntl()
+ const issues = computed(() =>
+ wasReviewed.value && project.value?.thread_id === thread.value?.id
+ ? (thread.value?.issues ?? [])
+ : [],
+ )
+
+ function reviewIssue(issue: ThreadIssue) {
+ const restored = panels.customIssues.value.find(
+ (entry) => entry.custom?.previous?.id === issue.id,
+ )
+ if (restored) return restored
+ const why = issue.why
+ if (why?.custom) return undefined
+ const id = why && typeof why === 'object' && 'issue_id' in why ? why.issue_id : undefined
+ return panels.availableIssues.value.find((entry) => !entry.custom && entry.id === id)
+ }
+
+ function issueDetails(issue: ThreadIssue) {
+ const why = issue.why
+ return why && typeof why === 'object' && !Array.isArray(why)
+ ? (why as Record)
+ : {}
+ }
+
+ function issueMessage(issue: ThreadIssue) {
+ const message = issueDetails(issue).message
+ return typeof message === 'string' ? message : ''
+ }
+
+ function messageKey(issue: ThreadIssue) {
+ return `previous:${issue.id}`
+ }
+
+ function reviewMessage(issue: ThreadIssue) {
+ const key = messageKey(issue)
+ if (messages.hasIssueOverride(key)) return messages.issueMessage(key)
+ const definition = reviewIssue(issue)
+ if (!definition || !isApplicable(issue)) return issueMessage(issue)
+ if (!panels.activeIssues.value.some((active) => active.id === definition.id))
+ return issueMessage(issue)
+ return messages.issueMessage(definition.id)
+ }
+
+ function isApplicable(issue: ThreadIssue) {
+ const projectId = project.value?.id
+ if (!projectId) return false
+ return session.read(projectId, 'previous-issue-applicability')[issue.id] === true
+ }
+
+ function isFacetApplicable(issue: ThreadIssue, facet: ThreadIssueFacet): boolean {
+ const projectId = project.value?.id
+ if (!projectId) return false
+ const stored = session.read(projectId, 'previous-facet-applicability')[facet.id]
+ return typeof stored === 'boolean'
+ ? stored
+ : isApplicable(issue) && (isResolved(issue) || facet.verdict !== 'resolved')
+ }
+
+ function setFacetApplicable(issue: ThreadIssue, facet: ThreadIssueFacet, applicable: boolean) {
+ const projectId = project.value?.id
+ if (!projectId || !issue.facets.some(({ id }) => id === facet.id)) return
+ const selections = issue.facets.map((entry) => ({
+ id: entry.id,
+ applicable: entry.id === facet.id ? applicable : isFacetApplicable(issue, entry),
+ }))
+ if (applicable && !isApplicable(issue)) restoreIssue(issue)
+ for (const entry of selections)
+ session.write(projectId, 'previous-facet-applicability', entry.id, entry.applicable)
+ if (!selections.some((entry) => entry.applicable)) markNoLongerApplicable(issue)
+ }
+
+ function cardIssue(issue: ThreadIssue): ReviewIssue {
+ const definition = reviewIssue(issue)
+ if (definition) return definition
+ const details = issueDetails(issue)
+ const heading = issueMessage(issue)
+ .match(/^##[ \t]+([^\r\n]*)/m)?.[1]
+ .trim()
+ return {
+ id: typeof details.issue_id === 'string' ? details.issue_id : `previous:${issue.id}`,
+ title:
+ heading ||
+ (typeof details.title === 'string'
+ ? details.title
+ : typeof details.issue_id === 'string'
+ ? details.issue_id.replaceAll('-', ' ')
+ : formatMessage(issueTargetLabels[issue.facets[0]?.what.type ?? 'mark_addressed'])),
+ category: '',
+ priority: issue.why.custom
+ ? (IssuePriority[issue.why.custom.priority as keyof typeof IssuePriority] ??
+ IssuePriority.Bottom)
+ : undefined,
+ controls: [],
+ }
+ }
+
+ function issueBindings(issue: ThreadIssue): ReviewPanelBinding[] {
+ const definitionBindings = new Map(
+ cardIssue(issue).controls.map(({ binding }) => [binding.key, binding]),
+ )
+ const targets: ReviewTarget[] = []
+ const simpleTargets: Partial<
+ Record
+ > = {
+ modify_title: { kind: 'title' },
+ modify_slug: { kind: 'slug' },
+ modify_summary: { kind: 'summary' },
+ modify_description: { kind: 'description' },
+ modify_license: { kind: 'license' },
+ modify_icon: { kind: 'icon' },
+ remove_icon: { kind: 'icon' },
+ remove_tags: { kind: 'tags' },
+ add_gallery_images: { kind: 'gallery' },
+ modify_gallery_image: { kind: 'gallery' },
+ remove_gallery_images: { kind: 'gallery' },
+ version: { kind: 'versions' },
+ modify_server_languages: { kind: 'server' },
+ modify_server_address: { kind: 'server' },
+ }
+ const disclosureKeys: Record = {
+ ai_content: 'ai',
+ ai_functionality: 'ai-functionality',
+ advertisements: 'ads',
+ epilepsy_triggers: 'photosensitivity',
+ system_interactions: 'system-interactions',
+ telemetry: 'telemetry',
+ derivative_work: 'derivative-content',
+ paid_features: 'paid-features',
+ archived: 'archive',
+ }
+ for (const { what } of issue.facets) {
+ if (what.type === 'modify_links') {
+ for (const key of Object.keys(what.value.links)) targets.push({ kind: 'link', key })
+ } else if (what.type === 'modify_gallery_image') {
+ targets.push({ kind: 'gallery-image', key: String(what.value.image_id) })
+ } else if (what.type === 'remove_gallery_images') {
+ for (const id of what.value.image_ids)
+ targets.push({ kind: 'gallery-image', key: String(id) })
+ } else if (
+ what.type === 'add_project_disclosures' ||
+ what.type === 'remove_project_disclosures'
+ ) {
+ for (const type of what.value.disclosure_types) {
+ const key = disclosureKeys[type]
+ if (key) targets.push({ kind: 'disclosure', key })
+ }
+ } else if (
+ what.type === 'modify_project_disclosure' ||
+ what.type === 'modify_project_disclosure_note'
+ ) {
+ const key = disclosureKeys[what.value.disclosure_type]
+ if (key) targets.push({ kind: 'disclosure', key })
+ } else {
+ const target = simpleTargets[what.type]
+ if (target) targets.push(target)
+ }
+ }
+ if (!targets.length && definitionBindings.size === 1) return [...definitionBindings.values()]
+ const bindings = new Map()
+ for (const target of targets) {
+ const binding =
+ panels.resolve(target) ??
+ (target.kind === 'gallery-image' ? panels.resolve({ kind: 'gallery' }) : undefined)
+ if (binding) bindings.set(binding.key, binding)
+ }
+ return [...bindings.values()]
+ }
+
+ const initializedIssues = new Set()
+ watch(
+ [issues, panels.availableIssues],
+ () => {
+ const projectId = project.value?.id
+ if (!projectId) return
+ for (const issue of issues.value) {
+ messages.setIssueDefault(messageKey(issue), issueMessage(issue))
+ const definition = reviewIssue(issue)
+ if (!definition) continue
+ messages.setIssueDefault(definition.id, issueMessage(issue))
+ const key = `${projectId}:${issue.id}`
+ if (initializedIssues.has(key)) continue
+ initializedIssues.add(key)
+ if (session.read(projectId, 'previous-issue-applicability')[issue.id] !== undefined)
+ continue
+ if (!panels.activeIssues.value.some(({ id }) => id === definition.id)) continue
+ const autoSelected = panels.isRestoredIssue(definition.id)
+ const explicitlyAdded = issues.value.some(
+ (previous) => reviewIssue(previous)?.id === definition.id && isApplicable(previous),
+ )
+ if (autoSelected && !explicitlyAdded) {
+ panels.removeIssue(definition.id)
+ session.write(projectId, 'previous-issue-selection', issue.id, undefined)
+ } else if (!autoSelected && issue.verdict !== 'resolved') {
+ session.write(projectId, 'previous-issue-applicability', issue.id, true)
+ }
+ }
+ },
+ { immediate: true },
+ )
+
+ watch(panels.activeIssues, (active, previous) => {
+ const projectId = project.value?.id
+ if (!projectId) return
+ const activeIds = new Set(active.map(({ id }) => id))
+ const previousIds = new Set(previous.map(({ id }) => id))
+ for (const issue of issues.value) {
+ const id = reviewIssue(issue)?.id
+ if (!id) continue
+ if (!isResolved(issue) && activeIds.has(id) && !previousIds.has(id)) {
+ const alreadyAdded = issues.value.some(
+ (previousIssue) =>
+ reviewIssue(previousIssue)?.id === id &&
+ session.read(projectId, 'previous-issue-applicability')[previousIssue.id] === true,
+ )
+ if (!alreadyAdded) {
+ session.write(projectId, 'previous-issue-applicability', issue.id, true)
+ session.write(projectId, 'previous-issue-selection', issue.id, undefined)
+ }
+ } else if (!activeIds.has(id) && previousIds.has(id)) {
+ session.write(projectId, 'previous-issue-applicability', issue.id, false)
+ }
+ }
+ })
+
+ function markNoLongerApplicable(issue: ThreadIssue) {
+ const id = project.value?.id
+ if (!id || !issues.value.some(({ id }) => id === issue.id)) return
+ session.write(id, 'previous-issue-applicability', issue.id, false)
+ session.write(id, 'previous-issue-copy', issue.id, undefined)
+ for (const facet of issue.facets)
+ session.write(id, 'previous-facet-applicability', facet.id, false)
+ const definition = reviewIssue(issue)
+ if (
+ definition &&
+ !issues.value.some(
+ (previous) => isApplicable(previous) && reviewIssue(previous)?.id === definition.id,
+ )
+ ) {
+ panels.removeIssue(definition.id)
+ }
+ }
+
+ function restoreIssue(issue: ThreadIssue) {
+ const projectId = project.value?.id
+ if (!projectId || !issues.value.some(({ id }) => id === issue.id)) return
+ session.write(projectId, 'previous-issue-applicability', issue.id, true)
+ session.write(projectId, 'previous-issue-copy', issue.id, true)
+ messages.resetIssueMessage(messageKey(issue))
+ for (const facet of issue.facets)
+ session.write(projectId, 'previous-facet-applicability', facet.id, true)
+ const definition = reviewIssue(issue) ?? panels.restoreCustomIssue(issue)
+ if (!definition) return
+ if (!definition.custom) {
+ messages.resetIssueMessage(definition.id)
+ panels.removeIssue(definition.id)
+ }
+ if (definition.custom) {
+ panels.addIssue(definition.id)
+ session.write(
+ projectId,
+ 'previous-issue-selection',
+ issue.id,
+ panels.issueSnapshot(definition.id),
+ )
+ return
+ }
+ panels.addIssue(definition.id)
+ const selection = issue.why.selection
+ if (selection) {
+ session.write(projectId, 'issues', definition.id, new Set(selection.toggle_ids ?? []))
+ session.write(projectId, 'issue-text', definition.id, { ...selection.text_values })
+ session.write(
+ projectId,
+ 'issue-select',
+ definition.id,
+ Object.fromEntries(
+ Object.entries(selection.select_values ?? {}).map(([key, values]) => [
+ key,
+ new Set(values),
+ ]),
+ ),
+ )
+ } else {
+ for (const binding of issueBindings(issue)) {
+ const toggles = binding.panel.sections
+ .flatMap((section) => section.controls)
+ .filter(
+ (control) =>
+ control.issueId === definition.id && control.type === 'toggle' && !control.disabled,
+ )
+ if (toggles.length === 1) panels.write(binding, toggles[0], true)
+ }
+ }
+ session.write(
+ projectId,
+ 'previous-issue-selection',
+ issue.id,
+ panels.issueSnapshot(definition.id),
+ )
+ }
+
+ function isResolved(issue: ThreadIssue) {
+ return issue.verdict === 'resolved'
+ }
+
+ function wasControlSelected(issue: ThreadIssue, control: ResolvedIssueControl) {
+ if (control.type !== 'toggle' || issue.why?.issue_id !== control.issueId) return false
+ const selection = issue.why?.selection
+ return (
+ control.id === undefined ||
+ (Array.isArray(selection?.toggle_ids) && selection.toggle_ids.includes(control.id))
+ )
+ }
+
+ const reReviewIssues = computed(() => issues.value.filter((issue) => !isResolved(issue)))
+ const resolvedIssues = computed(() => issues.value.filter(isResolved))
+
+ const appliedIssues = computed(() => issues.value.filter(isApplicable))
+ const associatedIssueIds = computed(
+ () =>
+ new Set(
+ issues.value
+ .filter((issue) => !isResolved(issue) || isApplicable(issue))
+ .map((issue) => cardIssue(issue).id),
+ ),
+ )
+ function needsReplacement(issue: ThreadIssue): boolean {
+ if (!isApplicable(issue)) return false
+ const id = reviewIssue(issue)?.id
+ return (
+ session.read(project.value!.id, 'previous-issue-copy')[issue.id] === true ||
+ isResolved(issue) ||
+ messages.hasIssueOverride(messageKey(issue)) ||
+ reviewMessage(issue) !== issueMessage(issue) ||
+ (panels.activeIssues.value.some((active) => active.id === id) &&
+ !panels.isRestoredIssue(id ?? '')) ||
+ issue.facets.some((facet) => isFacetApplicable(issue, facet) && facet.verdict === 'resolved')
+ )
+ }
+
+ function targetKey(what: Labrinth.Threads.v3.ThreadIssueTarget): string {
+ switch (what.type) {
+ case 'version':
+ return `${what.type}:${what.value.version_id}:${what.value.target.type}:${
+ what.value.target.type === 'modify_additional_file_type'
+ ? what.value.target.value.file_id
+ : what.value.target.type === 'remove_additional_files'
+ ? [...what.value.target.value.file_ids].sort().join(',')
+ : ''
+ }`
+ case 'modify_gallery_image':
+ return `${what.type}:${what.value.image_id}`
+ case 'modify_team_member_role':
+ return `${what.type}:${what.value.team_id}:${what.value.user_id}`
+ case 'modify_server_address':
+ return `${what.type}:${what.value.platform}`
+ case 'modify_project_disclosure':
+ case 'modify_project_disclosure_note':
+ return `${what.type}:${what.value.disclosure_type}`
+ case 'modify_links':
+ return `${what.type}:${Object.keys(what.value.links).sort().join(',')}`
+ case 'remove_tags':
+ return `${what.type}:${[...what.value.tags].sort().join(',')}`
+ case 'remove_gallery_images':
+ return `${what.type}:${[...what.value.image_ids].sort((a, b) => a - b).join(',')}`
+ case 'add_project_disclosures':
+ case 'remove_project_disclosures':
+ return `${what.type}:${[...what.value.disclosure_types].sort().join(',')}`
+ case 'acknowledge':
+ return `${what.type}:${what.value.mode}`
+ default:
+ return what.type
+ }
+ }
+
+ const recreatedIssues = computed(() =>
+ appliedIssues.value
+ .filter(needsReplacement)
+ .flatMap((issue): Labrinth.Threads.v3.NewThreadIssue[] => {
+ const id = reviewIssue(issue)?.id
+ const active = panels.activeIssues.value.find((active) => active.id === id)
+ const changed = !!active && !panels.isRestoredIssue(active.id)
+ const custom = panels.customIssues.value.find((entry) => entry.id === id)?.custom
+ const previousFacets = new Map(issue.facets.map((facet) => [targetKey(facet.what), facet]))
+ const facets = active
+ ? active.facets.filter(({ what }) => {
+ const previous = previousFacets.get(targetKey(what))
+ return changed || !previous || isFacetApplicable(issue, previous)
+ })
+ : issue.facets
+ .filter((facet) => isFacetApplicable(issue, facet))
+ .map(({ what }) => ({ what }))
+ const [first, ...rest] = facets
+ if (!first) return []
+ return [
+ {
+ why: {
+ ...issueDetails(issue),
+ message: reviewMessage(issue),
+ ...(custom
+ ? {
+ issue_id: custom.id.trim(),
+ custom: { priority: custom.priority },
+ }
+ : {}),
+ ...(active
+ ? {
+ selection: panels.issueSelection(active.id),
+ locations: active.locations,
+ }
+ : {}),
+ },
+ facets: [first, ...rest],
+ },
+ ]
+ }),
+ )
+ const facetUpdates = computed(() =>
+ issues.value.flatMap((issue) =>
+ issue.facets.flatMap(
+ (facet): { id: string; data: Labrinth.Threads.v3.EditThreadIssueFacet }[] => {
+ if (facet.moderator_verified) return []
+ if (!needsReplacement(issue) && isFacetApplicable(issue, facet))
+ return facet.user_addressed ? [{ id: facet.id, data: { user_addressed: false } }] : []
+ return [{ id: facet.id, data: { moderator_verified: true } }]
+ },
+ ),
+ ),
+ )
+
+ return {
+ issues,
+ reReviewIssues,
+ appliedIssues,
+ resolvedIssues,
+ associatedIssueIds,
+ recreatedIssues,
+ isReReviewControl: (control: ResolvedIssueControl) =>
+ issues.value.some((issue) => !isResolved(issue) && wasControlSelected(issue, control)),
+ reReviewFindingCount: (binding: ReviewPanelBinding) =>
+ binding.panel.sections.reduce(
+ (count, section) =>
+ count +
+ section.controls.filter((control) =>
+ reReviewIssues.value.some((issue) => wasControlSelected(issue, control)),
+ ).length,
+ 0,
+ ),
+ facetUpdates,
+ hasUnresolvedFacets: computed(() =>
+ issues.value.some((issue) => issue.facets.some((facet) => isFacetApplicable(issue, facet))),
+ ),
+ isFacetApplicable,
+ setFacetApplicable,
+ messageKey,
+ reviewMessage,
+ cardIssue,
+ issueMessage,
+ issueBindings,
+ markNoLongerApplicable,
+ restoreIssue,
+ }
+}
diff --git a/apps/frontend/src/providers/project-review/review-session.ts b/apps/frontend/src/providers/project-review/review-session.ts
new file mode 100644
index 00000000000..0eb866d41b1
--- /dev/null
+++ b/apps/frontend/src/providers/project-review/review-session.ts
@@ -0,0 +1,133 @@
+import type { NodeState } from '@modrinth/moderation/src/types/node'
+import { createContext } from '@modrinth/ui'
+import { reactive, watch } from 'vue'
+
+import { useAuthState } from '~/composables/auth'
+
+type ReviewState = Record
+type ProjectReviewState = Record
+
+const DRAFT_EXPIRY_MS = 30 * 24 * 60 * 60 * 1000
+
+function isRecord(value: unknown): value is Record {
+ return !!value && typeof value === 'object' && !(value instanceof Set) && !Array.isArray(value)
+}
+
+function isNodeState(value: unknown): value is NodeState {
+ return (
+ value === null ||
+ typeof value === 'boolean' ||
+ typeof value === 'string' ||
+ typeof value === 'number' ||
+ (value instanceof Set && [...value].every((entry) => typeof entry === 'string')) ||
+ (isRecord(value) && Object.values(value).every(isNodeState))
+ )
+}
+
+function isProjectReviewState(value: unknown): value is ProjectReviewState {
+ return (
+ isRecord(value) &&
+ Object.values(value).every(
+ (scope) => isRecord(scope) && Object.values(scope).every(isNodeState),
+ )
+ )
+}
+
+export const [injectReviewSession, provideReviewSession] =
+ createContext>('ProjectReviewSession')
+
+export function createReviewSession() {
+ const auth = useAuthState()
+ const projects = reactive(new Map())
+
+ watch(
+ () => auth.value.user?.id,
+ () => projects.clear(),
+ { flush: 'sync' },
+ )
+
+ function storageKey(projectId: string) {
+ const userId = auth.value.user?.id
+ return import.meta.client && userId
+ ? `project-review-draft:v1:${userId}:${projectId}`
+ : undefined
+ }
+
+ function removeDraft(projectId: string) {
+ const key = storageKey(projectId)
+ if (!key) return
+ try {
+ localStorage.removeItem(key)
+ } catch (error) {
+ console.warn('Failed to clear project review draft:', error)
+ }
+ }
+
+ function readProject(projectId: string): ProjectReviewState {
+ const cached = projects.get(projectId)
+ if (cached) return cached
+ const key = storageKey(projectId)
+ let state: ProjectReviewState = {}
+ if (key) {
+ try {
+ const saved = localStorage.getItem(key)
+ if (saved) {
+ const draft: unknown = JSON.parse(saved, (_, value) =>
+ Array.isArray(value) ? new Set(value) : value,
+ )
+ if (
+ isRecord(draft) &&
+ typeof draft.updatedAt === 'number' &&
+ Date.now() - draft.updatedAt < DRAFT_EXPIRY_MS &&
+ isProjectReviewState(draft.state)
+ ) {
+ state = draft.state
+ } else {
+ removeDraft(projectId)
+ }
+ }
+ } catch (error) {
+ console.warn('Failed to restore project review draft:', error)
+ removeDraft(projectId)
+ }
+ }
+ projects.set(projectId, state)
+ return projects.get(projectId)!
+ }
+
+ function read(projectId: string, scope: string): ReviewState {
+ return readProject(projectId)[scope] ?? {}
+ }
+
+ function write(projectId: string, scope: string, id: string, value: NodeState) {
+ const project = readProject(projectId)
+ const state = { ...project[scope] }
+ if (value === undefined) Reflect.deleteProperty(state, id)
+ else state[id] = value
+ const next = { ...project, [scope]: state }
+ projects.set(projectId, next)
+ const key = storageKey(projectId)
+ if (!key) return
+ if (Object.values(next).every((scope) => Object.keys(scope).length === 0)) {
+ removeDraft(projectId)
+ return
+ }
+ try {
+ localStorage.setItem(
+ key,
+ JSON.stringify({ updatedAt: Date.now(), state: next }, (_, value) =>
+ value instanceof Set ? [...value] : value,
+ ),
+ )
+ } catch (error) {
+ console.warn('Failed to save project review draft:', error)
+ }
+ }
+
+ function clearProject(projectId: string) {
+ projects.set(projectId, {})
+ removeDraft(projectId)
+ }
+
+ return { read, write, readProject, clearProject }
+}
diff --git a/apps/frontend/src/providers/project-review/review-submission.ts b/apps/frontend/src/providers/project-review/review-submission.ts
new file mode 100644
index 00000000000..a741b310dcb
--- /dev/null
+++ b/apps/frontend/src/providers/project-review/review-submission.ts
@@ -0,0 +1,287 @@
+import type { Labrinth } from '@modrinth/api-client'
+import {
+ commonMessages,
+ createContext,
+ defineMessages,
+ injectModrinthClient,
+ injectNotificationManager,
+ useVIntl,
+} from '@modrinth/ui'
+import { useMutation, useQueryClient } from '@tanstack/vue-query'
+import { computed, onScopeDispose, ref, watch } from 'vue'
+
+import { projectReviewMessages } from '~/components/project-review/messages'
+import { useAuthState } from '~/composables/auth'
+import { sendThreadReply, type ThreadReply } from '~/helpers/thread-issues'
+import { isStaff } from '~/helpers/users.js'
+
+import { injectProjectReviewPageContext } from './index'
+import { applyReviewDecision, type ReviewDecision } from './review-decision'
+import type { createReviewMessages } from './review-messages'
+import type { createReviewPanels } from './review-panels'
+import type { createReviewPreviousIssues } from './review-previous-issues'
+import type { createReviewSession } from './review-session'
+
+type ProjectStatus = Labrinth.Projects.v2.ProjectStatus
+type ReviewEditorMode = 'reply' | 'note'
+type NewThreadIssues = Labrinth.Threads.v3.NewThreadIssues
+
+export const [injectReviewSubmission, provideReviewSubmission] =
+ createContext>('ReviewSubmission')
+
+export function createReviewSubmission(
+ messages: ReturnType,
+ panels: ReturnType,
+ session: ReturnType,
+ previousIssues: ReturnType,
+) {
+ const client = injectModrinthClient()
+ const queryClient = useQueryClient()
+ const { formatMessage } = useVIntl()
+ const { addNotification } = injectNotificationManager()
+ const auth = useAuthState()
+ const { project, threadQuery, disclosures } = injectProjectReviewPageContext()
+ const draft = ref('')
+ const uploadedImages = ref([])
+ let disposed = false
+ watch(
+ () => project.value?.id,
+ () => {
+ draft.value = ''
+ uploadedImages.value = []
+ },
+ { flush: 'sync' },
+ )
+ onScopeDispose(() => {
+ disposed = true
+ })
+
+ const errors = defineMessages({
+ unresolved: {
+ id: 'project-review.decision.unresolved',
+ defaultMessage:
+ 'Verify all remaining fixes and remove new findings before approving this project.',
+ },
+ unsaved: {
+ id: 'project-review.disclosures.unsaved',
+ defaultMessage: 'Save or reset your disclosure changes before changing the project status.',
+ },
+ missing: {
+ id: 'project-review.corrections.missing-fields',
+ defaultMessage: 'Complete the required review fields before submitting a review decision.',
+ },
+ changed: {
+ id: 'project-review.corrections.project-changed',
+ defaultMessage: 'The selected project changed. Review it again.',
+ },
+ imageType: {
+ id: 'project-review.reply.image-type',
+ defaultMessage: 'Choose a PNG, JPEG, GIF, or WebP image.',
+ },
+ imageSize: {
+ id: 'project-review.reply.image-size',
+ defaultMessage: 'Images must be smaller than 1 MiB.',
+ },
+ })
+
+ function assertCurrent(id: string) {
+ if (disposed || project.value?.id !== id) throw new Error(formatMessage(errors.changed))
+ }
+
+ function selectedIssues(): NewThreadIssues | undefined {
+ const selected: Labrinth.Threads.v3.NewThreadIssue[] = [...previousIssues.recreatedIssues.value]
+ const previousIds = previousIssues.associatedIssueIds.value
+ const titles = new Map(panels.availableIssues.value.map(({ id, title }) => [id, title]))
+ for (const { id, facets, locations } of panels.activeIssues.value) {
+ if (previousIds.has(id)) continue
+ const custom = panels.customIssues.value.find((issue) => issue.id === id)?.custom
+ selected.push({
+ why: {
+ issue_id: custom?.id.trim() ?? id,
+ ...(custom ? { custom: { priority: custom.priority } } : {}),
+ locations,
+ title: titles.get(id) ?? id,
+ message: messages.issueMessage(id),
+ selection: panels.issueSelection(id),
+ },
+ facets,
+ })
+ }
+ const [first, ...rest] = selected
+ return first ? { issues: [first, ...rest] } : undefined
+ }
+
+ const submission = useMutation({
+ mutationFn: async ({
+ id,
+ decision,
+ reply,
+ clearIssues,
+ }: {
+ id: string
+ threadId: string
+ decision?: ReviewDecision
+ reply?: ThreadReply
+ clearIssues?: boolean
+ }) => {
+ assertCurrent(id)
+ const clearMessage = () => {
+ assertCurrent(id)
+ draft.value = ''
+ uploadedImages.value = []
+ }
+ if (decision) {
+ await applyReviewDecision(decision, client, () => assertCurrent(id), clearMessage)
+ session.clearProject(id)
+ } else if (reply) {
+ const issueIds = clearIssues ? panels.activeIssues.value.map(({ id }) => id) : []
+ const appliedIssues = clearIssues ? [...previousIssues.appliedIssues.value] : []
+ await sendThreadReply(reply, client, () => assertCurrent(id))
+ clearMessage()
+ for (const issue of appliedIssues) previousIssues.markNoLongerApplicable(issue)
+ for (const issueId of issueIds) panels.removeIssue(issueId)
+ }
+ },
+ onError: (error) =>
+ addNotification({
+ title: formatMessage(commonMessages.errorNotificationTitle),
+ text: error instanceof Error ? error.message : String(error),
+ type: 'error',
+ }),
+ onSettled: async (_, __, { id, threadId }) => {
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: ['project', 'v3', id] }),
+ queryClient.invalidateQueries({ queryKey: ['project', 'v2', id] }),
+ queryClient.invalidateQueries({ queryKey: ['project', id] }),
+ queryClient.invalidateQueries({ queryKey: ['thread', threadId] }),
+ ])
+ },
+ })
+
+ const upload = useMutation({
+ mutationFn: async ({ file, id }: { file: File; id: string }) => {
+ const ext = file.type.split('/')[1]
+ if (!['image/png', 'image/jpeg', 'image/gif', 'image/webp'].includes(file.type))
+ throw new Error(formatMessage(errors.imageType))
+ if (file.size > 1024 * 1024) throw new Error(formatMessage(errors.imageSize))
+ const response = await client.upload<{ id: string; url: string }>('/image', {
+ api: 'labrinth',
+ version: 3,
+ file,
+ params: { context: 'thread_message', ext },
+ }).promise
+ assertCurrent(id)
+ uploadedImages.value = [...uploadedImages.value, response.id].slice(-10)
+ return response.url
+ },
+ onError: (error) =>
+ addNotification({
+ title: formatMessage(commonMessages.errorNotificationTitle),
+ text: error instanceof Error ? error.message : String(error),
+ type: 'error',
+ }),
+ })
+ const pending = computed(() => submission.isPending.value || upload.isPending.value)
+ const canSubmit = computed(
+ () =>
+ isStaff(auth.value.user) &&
+ !!project.value &&
+ threadQuery.data.value?.id === project.value.thread_id &&
+ !submission.isPending.value &&
+ !upload.isPending.value,
+ )
+ const loadingAction = computed(() =>
+ submission.isPending.value
+ ? submission.variables.value?.decision
+ ? (submission.variables.value.decision.status ?? 'issues')
+ : submission.variables.value?.reply?.privateMessage
+ ? 'note'
+ : submission.variables.value?.clearIssues
+ ? 'reply-clear'
+ : 'reply'
+ : undefined,
+ )
+ async function submit(mode: ReviewEditorMode = 'reply', clearIssues = false) {
+ const current = project.value
+ if (!canSubmit.value || !current || !draft.value.trim()) return
+ await submission
+ .mutateAsync({
+ id: current.id,
+ threadId: current.thread_id,
+ clearIssues: mode === 'reply' && clearIssues,
+ reply: {
+ threadId: current.thread_id,
+ body: draft.value,
+ images: [...uploadedImages.value],
+ privateMessage: mode === 'note',
+ },
+ })
+ .catch(() => undefined)
+ }
+
+ const canApprove = computed(
+ () => !previousIssues.hasUnresolvedFacets.value && panels.activeIssues.value.length === 0,
+ )
+
+ const canAddIssues = computed(() => canSubmit.value && !!selectedIssues())
+
+ async function submitReview(status?: ProjectStatus) {
+ const current = project.value
+ const thread = threadQuery.data.value
+ if (!canSubmit.value || !current || !thread || messages.generating.value) return
+ if (!status && !canAddIssues.value) return
+ let mutationStarted = false
+ try {
+ if (disclosures.hasChanges.value || disclosures.saving.value)
+ throw new Error(formatMessage(errors.unsaved))
+ if (
+ panels.validationErrors.value.some(
+ ({ issueId, key }) =>
+ issueId === 'categories-inaccurate' && key === 'categories-remove-tags',
+ )
+ )
+ throw new Error('Please select tags to remove for inaccurate tags issue.')
+ if (panels.validationErrors.value.length) throw new Error(formatMessage(errors.missing))
+ if (status && ['approved', 'unlisted', 'private'].includes(status) && !canApprove.value)
+ throw new Error(formatMessage(errors.unresolved))
+ const decision: ReviewDecision = {
+ projectId: current.id,
+ threadId: current.thread_id,
+ status,
+ body: status ? draft.value : '',
+ images: status ? [...uploadedImages.value] : [],
+ privateMessage: false,
+ facetUpdates: status ? [...previousIssues.facetUpdates.value] : [],
+ issues: selectedIssues(),
+ }
+ mutationStarted = true
+ await submission.mutateAsync({
+ id: current.id,
+ threadId: current.thread_id,
+ decision,
+ })
+ return true
+ } catch (error) {
+ if (!mutationStarted)
+ addNotification({
+ title: formatMessage(commonMessages.errorNotificationTitle),
+ text: error instanceof Error ? error.message : String(error),
+ type: 'error',
+ })
+ return false
+ }
+ }
+
+ return {
+ draft,
+ pending,
+ canSubmit,
+ canApprove,
+ canAddIssues,
+ loadingAction,
+ submit,
+ submitDecision: (status: ProjectStatus) => submitReview(status),
+ submitIssues: () => submitReview(),
+ uploadImage: (file: File) => upload.mutateAsync({ file, id: project.value?.id ?? '' }),
+ }
+}
diff --git a/apps/frontend/src/providers/project-review/review.ts b/apps/frontend/src/providers/project-review/review.ts
new file mode 100644
index 00000000000..44eb46a6d55
--- /dev/null
+++ b/apps/frontend/src/providers/project-review/review.ts
@@ -0,0 +1,26 @@
+export type ReviewTarget =
+ | {
+ kind:
+ | 'title'
+ | 'slug'
+ | 'icon'
+ | 'summary'
+ | 'tags'
+ | 'compatibility'
+ | 'server'
+ | 'reupload'
+ | 're-review'
+ | 'post-approval'
+ | 'status-alerts'
+ | 'undefined-project'
+ | 'license'
+ | 'license-url'
+ | 'description'
+ | 'gallery'
+ | 'disclosures'
+ | 'versions'
+ | 'permissions'
+ | 'rules'
+ }
+ | { kind: 'link' | 'gallery-image' | 'version' | 'disclosure'; key: string }
+ | { kind: 'panel'; key: string }
diff --git a/apps/frontend/src/providers/project-review/types.ts b/apps/frontend/src/providers/project-review/types.ts
new file mode 100644
index 00000000000..34fcfc34765
--- /dev/null
+++ b/apps/frontend/src/providers/project-review/types.ts
@@ -0,0 +1,3 @@
+import type { createProjectReviewPageContext } from './context'
+
+export type ProjectReviewPageContext = ReturnType
diff --git a/apps/frontend/src/providers/version/manage-version-modal.ts b/apps/frontend/src/providers/version/manage-version-modal.ts
index 24999df97c0..ca59bf864b8 100644
--- a/apps/frontend/src/providers/version/manage-version-modal.ts
+++ b/apps/frontend/src/providers/version/manage-version-modal.ts
@@ -6,7 +6,6 @@ import {
defineMessage,
injectModrinthClient,
injectNotificationManager,
- injectProjectPageContext,
type MessageDescriptor,
type MultiStageModal,
resolveCtxFn,
@@ -70,7 +69,15 @@ export interface PrimaryFile {
existing?: boolean
}
+export type EditVersionStage = 'metadata' | 'add-details' | 'add-files'
+
+export interface ManageVersionHost {
+ projectV2: Ref
+ invalidate: (projectId: string, versionId?: string) => Promise
+}
+
export interface ManageVersionContextValue {
+ projectV2: Ref
// State
draftVersion: Ref
filesToAdd: Ref
@@ -209,11 +216,12 @@ export const [injectManageVersionContext, provideManageVersionContext] =
export function createManageVersionContext(
modal: ShallowRef | null>,
+ host: ManageVersionHost,
onSave?: () => void,
): ManageVersionContextValue {
const { labrinth } = injectModrinthClient()
const { addNotification } = injectNotificationManager()
- const { invalidate, projectV2 } = injectProjectPageContext()
+ const { invalidate, projectV2 } = host
// State
const draftVersion = ref(structuredClone(EMPTY_DRAFT_VERSION))
@@ -232,7 +240,11 @@ export function createManageVersionContext(
const isSubmitting = ref(false)
const isUploading = ref(false)
- const uploadProgress = ref({ loaded: 0, total: 0, progress: 0 })
+ const uploadProgress = ref({
+ loaded: 0,
+ total: 0,
+ progress: 0,
+ })
const projectType = computed(() => {
const primaryFile = filesToAdd.value[0]?.file
@@ -694,7 +706,10 @@ export function createManageVersionContext(
const environment = await inferEnvironmentFromVersions(projectId, loaders)
if (environment && !draftVersion.value.environment) {
draftVersion.value.environment = environment
- inferredVersionData.value = { ...inferredVersionData.value, environment }
+ inferredVersionData.value = {
+ ...inferredVersionData.value,
+ environment,
+ }
}
},
)
@@ -805,7 +820,7 @@ export function createManageVersionContext(
text: 'The version has been successfully added to your project.',
type: 'success',
})
- await invalidate()
+ await invalidate(version.project_id, version.version_id)
onSave?.()
} catch (err: any) {
addNotification({
@@ -879,7 +894,7 @@ export function createManageVersionContext(
text: 'The version has been successfully saved to your project.',
type: 'success',
})
- await invalidate()
+ await invalidate(version.project_id, version.version_id)
onSave?.()
} catch (err: any) {
addNotification({
@@ -937,6 +952,7 @@ export function createManageVersionContext(
})
const contextValue: ManageVersionContextValue = {
+ projectV2,
// State
draftVersion,
filesToAdd,
diff --git a/apps/frontend/src/public/dev-favicons/favicon-local-review.svg b/apps/frontend/src/public/dev-favicons/favicon-local-review.svg
new file mode 100644
index 00000000000..c39551a5acb
--- /dev/null
+++ b/apps/frontend/src/public/dev-favicons/favicon-local-review.svg
@@ -0,0 +1,69 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/frontend/src/public/dev-favicons/favicon-preview-review.svg b/apps/frontend/src/public/dev-favicons/favicon-preview-review.svg
new file mode 100644
index 00000000000..7c4fb9f1c64
--- /dev/null
+++ b/apps/frontend/src/public/dev-favicons/favicon-preview-review.svg
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/frontend/src/public/dev-favicons/favicon-staging-review.svg b/apps/frontend/src/public/dev-favicons/favicon-staging-review.svg
new file mode 100644
index 00000000000..e02898c62b1
--- /dev/null
+++ b/apps/frontend/src/public/dev-favicons/favicon-staging-review.svg
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/frontend/src/public/favicon-light-review.svg b/apps/frontend/src/public/favicon-light-review.svg
new file mode 100644
index 00000000000..b7454e43e3c
--- /dev/null
+++ b/apps/frontend/src/public/favicon-light-review.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
diff --git a/apps/frontend/src/public/favicon-review.svg b/apps/frontend/src/public/favicon-review.svg
new file mode 100644
index 00000000000..f4162ca525d
--- /dev/null
+++ b/apps/frontend/src/public/favicon-review.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
diff --git a/apps/frontend/src/services/moderation/queue-eligibility.ts b/apps/frontend/src/services/moderation/queue-eligibility.ts
index fe084a4b154..2438d2b4828 100644
--- a/apps/frontend/src/services/moderation/queue-eligibility.ts
+++ b/apps/frontend/src/services/moderation/queue-eligibility.ts
@@ -35,6 +35,7 @@ export async function batchCheckQueueCandidates(
client: AbstractModrinthClient,
moderationQueue: ModerationQueueService,
projectIds: string[],
+ strict = false,
): Promise> {
const results = new Map()
@@ -48,7 +49,7 @@ export async function batchCheckQueueCandidates(
return { id, locked: false, isProcessing: false }
}
- const lockResponse = await moderationQueue.checkLock(id)
+ const lockResponse = await moderationQueue.checkLock(id, strict)
return {
id,
@@ -67,6 +68,7 @@ export async function batchCheckQueueCandidates(
if (result.status === 'fulfilled') {
results.set(result.value.id, result.value)
} else {
+ if (strict) throw result.reason
results.set(projectIds[index], { locked: false, isProcessing: false })
}
})
@@ -78,6 +80,7 @@ export async function findNextEligibleQueueProject(
client: AbstractModrinthClient,
moderationQueue: ModerationQueueService,
candidateIds: string[],
+ strict = false,
): Promise {
const excluded: string[] = []
let checkedCount = 0
@@ -86,7 +89,7 @@ export async function findNextEligibleQueueProject(
const batch = candidateIds.slice(checkedCount, checkedCount + BATCH_SIZE)
checkedCount += batch.length
- const results = await batchCheckQueueCandidates(client, moderationQueue, batch)
+ const results = await batchCheckQueueCandidates(client, moderationQueue, batch, strict)
for (const id of batch) {
const result = results.get(id)
diff --git a/apps/frontend/src/services/moderation/queue-storage.ts b/apps/frontend/src/services/moderation/queue-storage.ts
index 22a450a337f..197788ef1be 100644
--- a/apps/frontend/src/services/moderation/queue-storage.ts
+++ b/apps/frontend/src/services/moderation/queue-storage.ts
@@ -8,6 +8,8 @@ export interface PersistedModerationQueueState {
skipped?: string[]
total: number
completed: string[]
+ history?: string[]
+ activeProjectId?: string | null
lastUpdated: string
}
isQueueMode: boolean
@@ -34,6 +36,8 @@ function isPersistedStateCandidate(value: unknown): value is PersistedModeration
if (queue.skipped !== undefined && !isStringArray(queue.skipped)) return false
if (typeof queue.total !== 'number' || Number.isNaN(queue.total)) return false
if (!isStringArray(queue.completed)) return false
+ if (queue.history !== undefined && !isStringArray(queue.history)) return false
+ if (queue.activeProjectId != null && typeof queue.activeProjectId !== 'string') return false
if (typeof queue.lastUpdated !== 'string') return false
return true
diff --git a/apps/frontend/src/services/moderation/queue.ts b/apps/frontend/src/services/moderation/queue.ts
index 9c0a45a8843..64af712da36 100644
--- a/apps/frontend/src/services/moderation/queue.ts
+++ b/apps/frontend/src/services/moderation/queue.ts
@@ -13,6 +13,8 @@ export interface ModerationQueue {
skipped: string[]
total: number
completed: string[]
+ history: string[]
+ activeProjectId: string | null
lastUpdated: Date
}
@@ -39,11 +41,12 @@ export interface ModerationQueueService {
startSkippedReview(): Promise
getCurrentProjectId(): string | null
resetQueue(): Promise
+ visitProject(projectId: string, back?: boolean): Promise
acquireLock(projectId: string): Promise
overrideLock(projectId: string): Promise
releaseLock(projectId: string): Promise
- checkLock(projectId: string): Promise
+ checkLock(projectId: string, throwOnError?: boolean): Promise
refreshLock(): Promise
}
@@ -52,11 +55,20 @@ const EMPTY_QUEUE: ModerationQueue = {
skipped: [],
total: 0,
completed: [],
+ history: [],
+ activeProjectId: null,
lastUpdated: new Date(),
}
function createEmptyQueue(): ModerationQueue {
- return { ...EMPTY_QUEUE, lastUpdated: new Date(), items: [], skipped: [], completed: [] }
+ return {
+ ...EMPTY_QUEUE,
+ lastUpdated: new Date(),
+ items: [],
+ skipped: [],
+ completed: [],
+ history: [],
+ }
}
function sanitizeQueue(raw: PersistedModerationQueueState['currentQueue']): ModerationQueue {
@@ -74,6 +86,8 @@ function sanitizeQueue(raw: PersistedModerationQueueState['currentQueue']): Mode
skipped,
total,
completed,
+ history: (raw.history ?? []).filter((id): id is string => typeof id === 'string'),
+ activeProjectId: typeof raw.activeProjectId === 'string' ? raw.activeProjectId : null,
lastUpdated: Number.isNaN(lastUpdated.getTime()) ? new Date() : lastUpdated,
}
}
@@ -90,6 +104,8 @@ function persistedPayload(
skipped: [...queue.skipped],
total: queue.total,
completed: [...queue.completed],
+ history: [...queue.history],
+ activeProjectId: queue.activeProjectId,
lastUpdated: queue.lastUpdated.toISOString(),
},
isQueueMode,
@@ -159,6 +175,8 @@ function createModerationQueueState(client: AbstractModrinthClient = injectModri
skipped: [],
total: items.length,
completed: [],
+ history: [],
+ activeProjectId: null,
lastUpdated: new Date(),
}
}
@@ -183,6 +201,26 @@ function createModerationQueueState(client: AbstractModrinthClient = injectModri
})
}
+ async function visitProject(projectId: string, back = false): Promise {
+ await withMutation(() => {
+ const queue = currentQueue.value
+ if (queue.activeProjectId === projectId) return
+ if (back) {
+ const index = queue.history.lastIndexOf(projectId)
+ if (index === -1) return
+ queue.history = queue.history.slice(0, index)
+ } else if (queue.activeProjectId) {
+ queue.history = [...queue.history, queue.activeProjectId]
+ }
+ queue.activeProjectId = projectId
+ if (queue.skipped.includes(projectId)) {
+ queue.skipped = queue.skipped.filter((id) => id !== projectId)
+ queue.items = [projectId, ...queue.items.filter((id) => id !== projectId)]
+ }
+ queue.lastUpdated = new Date()
+ })
+ }
+
async function excludeProject(projectId: string): Promise {
return withMutation(() => {
if (!currentQueue.value.items.includes(projectId)) {
@@ -281,7 +319,7 @@ function createModerationQueueState(client: AbstractModrinthClient = injectModri
}
}
- async function checkLock(projectId: string): Promise {
+ async function checkLock(projectId: string, throwOnError = false): Promise {
await ready
try {
@@ -289,6 +327,7 @@ function createModerationQueueState(client: AbstractModrinthClient = injectModri
return response
} catch (error) {
console.error('Failed to check moderation lock:', error)
+ if (throwOnError) throw error
return { locked: false, is_own_lock: false }
}
}
@@ -327,6 +366,7 @@ function createModerationQueueState(client: AbstractModrinthClient = injectModri
startSkippedReview,
getCurrentProjectId,
resetQueue,
+ visitProject,
acquireLock,
overrideLock,
diff --git a/apps/frontend/src/utils/favicon.ts b/apps/frontend/src/utils/favicon.ts
index 75087601a37..3a855071b4e 100644
--- a/apps/frontend/src/utils/favicon.ts
+++ b/apps/frontend/src/utils/favicon.ts
@@ -1,4 +1,4 @@
-export type FaviconVariant = 'default' | 'settings'
+export type FaviconVariant = 'default' | 'review' | 'settings'
export type FaviconEnvironment = 'local' | 'preview' | 'staging'
const PRODUCTION_FAVICON_HREFS = {
@@ -10,6 +10,10 @@ const PRODUCTION_FAVICON_HREFS = {
light: '/favicon-light-settings-32x32.png',
dark: '/favicon-settings-32x32.png',
},
+ review: {
+ light: '/favicon-light-review.svg',
+ dark: '/favicon-review.svg',
+ },
} as const
const FAVICON_MEDIA = [
@@ -49,8 +53,18 @@ function getFaviconHrefs(variant: FaviconVariant, environment: FaviconEnvironmen
if (!environment) {
return {
...PRODUCTION_FAVICON_HREFS[variant],
- type: 'image/png',
- sizes: '32x32',
+ type: variant === 'review' ? 'image/svg+xml' : 'image/png',
+ sizes: variant === 'review' ? 'any' : '32x32',
+ }
+ }
+
+ if (variant === 'review') {
+ const href = `/dev-favicons/favicon-${environment}-review.svg`
+ return {
+ light: href,
+ dark: href,
+ type: 'image/svg+xml',
+ sizes: 'any',
}
}
diff --git a/apps/frontend/src/utils/license-messages.ts b/apps/frontend/src/utils/license-messages.ts
new file mode 100644
index 00000000000..1667e1ea590
--- /dev/null
+++ b/apps/frontend/src/utils/license-messages.ts
@@ -0,0 +1,19 @@
+import { defineMessages } from '@modrinth/ui'
+
+export const licenseUrlMessages = defineMessages({
+ url: { id: 'project.settings.license.url', defaultMessage: 'License URL' },
+ optionalUrl: {
+ id: 'project.settings.license.optional-url',
+ defaultMessage: 'License URL (optional)',
+ },
+ urlDescription: {
+ id: 'project.settings.license.url-description',
+ defaultMessage:
+ "The web location of the full license text. If you don't provide a link, the license text will be displayed instead.",
+ },
+ customUrlDescription: {
+ id: 'project.settings.license.custom-url-description',
+ defaultMessage:
+ 'The web location of the full license text. You have to provide a link since this is a custom license.',
+ },
+})
diff --git a/packages/api-client/src/modules/labrinth/tech-review/internal.ts b/packages/api-client/src/modules/labrinth/tech-review/internal.ts
index 8ab98aa915e..8fccfc8ae0a 100644
--- a/packages/api-client/src/modules/labrinth/tech-review/internal.ts
+++ b/packages/api-client/src/modules/labrinth/tech-review/internal.ts
@@ -311,4 +311,31 @@ export class LabrinthTechReviewInternalModule extends AbstractModule {
},
)
}
+
+ public async getUsersFlaggedProjects(
+ userIds: string[],
+ ): Promise> {
+ return this.client.request>(
+ '/moderation/tech-review/users/flagged-projects',
+ {
+ api: 'labrinth',
+ version: 'internal',
+ method: 'GET',
+ params: { ids: JSON.stringify(userIds) },
+ },
+ )
+ }
+
+ public async getOrganizationFlaggedProjects(
+ organizationId: string,
+ ): Promise {
+ return this.client.request(
+ `/moderation/tech-review/organization/${organizationId}/flagged-projects`,
+ {
+ api: 'labrinth',
+ version: 'internal',
+ method: 'GET',
+ },
+ )
+ }
}
diff --git a/packages/api-client/src/modules/labrinth/threads/v3.ts b/packages/api-client/src/modules/labrinth/threads/v3.ts
index 52c9c436c6b..be9a5160777 100644
--- a/packages/api-client/src/modules/labrinth/threads/v3.ts
+++ b/packages/api-client/src/modules/labrinth/threads/v3.ts
@@ -71,9 +71,44 @@ export class LabrinthThreadsV3Module extends AbstractModule {
version: 3,
method: 'POST',
body: message,
+ retry: false,
})
}
+ /** Create moderation issues in a thread (requires THREAD_WRITE scope). */
+ public async createIssues(id: string, data: Labrinth.Threads.v3.NewThreadIssues): Promise {
+ return this.client.request(`/thread/${id}/issue`, {
+ api: 'labrinth',
+ version: 3,
+ method: 'PUT',
+ body: data,
+ retry: false,
+ })
+ }
+
+ /** Update a thread issue facet's addressed or verified state. */
+ public async editIssueFacet(
+ id: string,
+ data: Labrinth.Threads.v3.EditThreadIssueFacet,
+ ): Promise {
+ return this.client.request(`/thread/issue/facet/${id}`, {
+ api: 'labrinth',
+ version: 3,
+ method: 'PATCH',
+ body: data,
+ })
+ }
+
+ /** Mark a thread issue facet as addressed by the project member. */
+ public async user_addressed(facetId: string): Promise {
+ return this.editIssueFacet(facetId, { user_addressed: true })
+ }
+
+ /** Mark a thread issue facet as verified by a moderator. */
+ public async moderator_verified(facetId: string): Promise {
+ return this.editIssueFacet(facetId, { moderator_verified: true })
+ }
+
/**
* Delete a message from a thread (v3)
*
diff --git a/packages/api-client/src/modules/labrinth/types.ts b/packages/api-client/src/modules/labrinth/types.ts
index c6ceed2f157..7b25b2a2a6f 100644
--- a/packages/api-client/src/modules/labrinth/types.ts
+++ b/packages/api-client/src/modules/labrinth/types.ts
@@ -1108,6 +1108,7 @@ export namespace Labrinth {
| 'unknown'
export type GalleryItem = {
+ id?: number
url: string
raw_url: string
featured: boolean
@@ -2119,6 +2120,200 @@ export namespace Labrinth {
export namespace v3 {
export type ThreadType = 'report' | 'project' | 'direct_message'
+ export type TextTarget = {
+ original: string
+ suggestion?: string | null
+ }
+
+ export type OptionalTextTarget = {
+ original?: string | null
+ /** Omitted means no suggestion; null suggests removing the value. */
+ suggestion?: string | null
+ }
+
+ export type DisclosureMetadata = Omit<
+ Extract,
+ 'type'
+ >
+
+ export type DisclosureMetadataTarget = {
+ original: DisclosureMetadata | null
+ suggestion?: DisclosureMetadata | null
+ }
+
+ export type ModifyDisclosureTarget = {
+ [T in Projects.v3.ProjectDisclosureType]: {
+ type: 'modify_project_disclosure'
+ value: {
+ disclosure_type: T
+ metadata: DisclosureMetadataTarget
+ }
+ }
+ }[Projects.v3.ProjectDisclosureType]
+
+ export type VersionIssueTarget =
+ | { type: 'remove' }
+ | { type: 'modify_environment'; value: TextTarget }
+ | {
+ type: 'modify_game_versions'
+ value: { original: string[]; suggestion?: string[] | null }
+ }
+ | {
+ type: 'modify_dependencies'
+ value: {
+ original: Versions.v3.Dependency[]
+ suggestion?: Versions.v3.Dependency[] | null
+ }
+ }
+ | { type: 'modify_changelog'; value: TextTarget }
+ | { type: 'remove_additional_files'; value: { file_ids: string[] } }
+ | {
+ type: 'modify_additional_file_type'
+ value: {
+ file_id: string
+ filename: string
+ original?: Versions.v3.FileType | null
+ suggestion?: Versions.v3.FileType | null
+ }
+ }
+
+ export type ThreadIssueTarget =
+ | { type: 'mark_addressed' }
+ | { type: 'modify_title'; value: TextTarget }
+ | { type: 'modify_slug'; value: TextTarget }
+ | { type: 'modify_summary'; value: TextTarget }
+ | { type: 'modify_description'; value: TextTarget }
+ | { type: 'modify_license'; value: { license: TextTarget; url: TextTarget } }
+ | { type: 'modify_icon'; value: { original_url: string | null } }
+ | { type: 'remove_icon' }
+ | { type: 'remove_tags'; value: { tags: string[] } }
+ | { type: 'modify_links'; value: { links: Record } }
+ | { type: 'add_gallery_images'; value: { original_count: number } }
+ | {
+ type: 'modify_gallery_image'
+ value: {
+ image_id: number
+ original_url: string
+ name?: OptionalTextTarget | null
+ description?: OptionalTextTarget | null
+ }
+ }
+ | {
+ type: 'remove_gallery_images'
+ value: {
+ image_ids: number[]
+ original_images?: { id: number; url: string; name?: string }[]
+ }
+ }
+ | { type: 'add_project_disclosures'; value: { disclosure_types: string[] } }
+ | { type: 'remove_project_disclosures'; value: { disclosure_types: string[] } }
+ | ModifyDisclosureTarget
+ | {
+ type: 'modify_project_disclosure_note'
+ value: { disclosure_type: string; note: OptionalTextTarget }
+ }
+ | {
+ type: 'version'
+ value: {
+ version_id: string
+ version_number: string
+ target: VersionIssueTarget
+ }
+ }
+ | {
+ type: 'modify_team_member_role'
+ value: { team_id: string; user_id: string; role: TextTarget }
+ }
+ | {
+ type: 'modify_server_languages'
+ value: { original: string[]; suggestion?: string[] | null }
+ }
+ | {
+ type: 'modify_server_address'
+ value: {
+ platform: 'minecraft_java' | 'minecraft_bedrock'
+ address: TextTarget
+ }
+ }
+ | { type: 'acknowledge'; value: { mode: 'checkbox' | 'reply' } }
+
+ export type NewThreadIssueFacet = { what: ThreadIssueTarget }
+
+ export type ThreadIssueLocation = {
+ field:
+ | 'title'
+ | 'slug'
+ | 'summary'
+ | 'icon'
+ | 'description'
+ | 'license'
+ | 'tags'
+ | 'links'
+ | 'gallery'
+ | 'disclosures'
+ | 'versions'
+ | 'members'
+ | 'server'
+ | 'permissions'
+ label?: { id: string; defaultMessage?: string }
+ }
+
+ /**
+ * The backend stores `why` as an arbitrary JSON blob; this schema is interpreted by the frontend.
+ * Fields are optional to support issues created before review metadata was added.
+ */
+ export type ThreadIssueWhy = {
+ issue_id?: string
+ /**
+ * this is to indicate where to show issue card if the issue facet(action) does not have a specific location.
+ */
+ locations?: ThreadIssueLocation[]
+ title?: string
+ message?: string
+ custom?: { priority: string }
+ /**
+ * this is the state of the issue, at creation time.used for re - rev
+ */
+ selection?: {
+ toggle_ids: string[]
+ text_values: Record
+ select_values: Record
+ }
+ }
+
+ export type NewThreadIssue = {
+ why: ThreadIssueWhy
+ facets: [NewThreadIssueFacet, ...NewThreadIssueFacet[]]
+ }
+
+ export type NewThreadIssues = {
+ issues: [NewThreadIssue, ...NewThreadIssue[]]
+ }
+
+ export type ThreadIssueVerdict = 'open' | 'addressed' | 'resolved'
+
+ export type ThreadIssueFacet = {
+ id: string
+ what: ThreadIssueTarget
+ user_addressed: boolean
+ moderator_verified: boolean
+ verdict: ThreadIssueVerdict
+ }
+
+ export type ThreadIssue = {
+ id: string
+ created_by: string | null
+ created_at: string
+ why: ThreadIssueWhy
+ facets: ThreadIssueFacet[]
+ verdict: ThreadIssueVerdict
+ }
+
+ export type EditThreadIssueFacet = {
+ user_addressed?: boolean
+ moderator_verified?: boolean
+ }
+
export type MessageBody =
| {
type: 'text'
@@ -2132,6 +2327,11 @@ export namespace Labrinth {
new_status: Projects.v2.ProjectStatus
old_status: Projects.v2.ProjectStatus
}
+ | {
+ type: 'auto_approval'
+ new_status: Projects.v2.ProjectStatus
+ old_status: Projects.v2.ProjectStatus
+ }
| {
type: 'tech_review'
verdict: 'safe' | 'unsafe'
@@ -2180,6 +2380,7 @@ export namespace Labrinth {
project_id: string | null
report_id: string | null
messages: ThreadMessage[]
+ issues: ThreadIssue[]
members: ThreadMember[]
}
@@ -2517,6 +2718,14 @@ export namespace Labrinth {
export namespace TechReview {
export namespace Internal {
+ export type FlaggedProject = {
+ project_id: string
+ thread_id: string
+ status: Projects.v3.Project['status']
+ message_id: string
+ reviewed: string
+ }
+
export type DelphiRule = {
id: number
name: string
diff --git a/packages/assets/generated-icons.ts b/packages/assets/generated-icons.ts
index 07ccbe012d8..aee0718c668 100644
--- a/packages/assets/generated-icons.ts
+++ b/packages/assets/generated-icons.ts
@@ -9,6 +9,7 @@ import _AffiliateIcon from './icons/affiliate.svg?component'
import _AlignLeftIcon from './icons/align-left.svg?component'
import _ArchiveIcon from './icons/archive.svg?component'
import _ArrowBigRightDashIcon from './icons/arrow-big-right-dash.svg?component'
+import _ArrowBigUpIcon from './icons/arrow-big-up.svg?component'
import _ArrowBigUpDashIcon from './icons/arrow-big-up-dash.svg?component'
import _ArrowDownIcon from './icons/arrow-down.svg?component'
import _ArrowDown10Icon from './icons/arrow-down-1-0.svg?component'
@@ -187,6 +188,8 @@ import _LinkIcon from './icons/link.svg?component'
import _Link2Icon from './icons/link-2.svg?component'
import _ListIcon from './icons/list.svg?component'
import _ListBulletedIcon from './icons/list-bulleted.svg?component'
+import _ListChevronsDownUpIcon from './icons/list-chevrons-down-up.svg?component'
+import _ListChevronsUpDownIcon from './icons/list-chevrons-up-down.svg?component'
import _ListEndIcon from './icons/list-end.svg?component'
import _ListFilterIcon from './icons/list-filter.svg?component'
import _ListOrderedIcon from './icons/list-ordered.svg?component'
@@ -234,8 +237,13 @@ import _PackageSearchIcon from './icons/package-search.svg?component'
import _PageRoundIcon from './icons/page-round.svg?component'
import _PaintbrushIcon from './icons/paintbrush.svg?component'
import _PaletteIcon from './icons/palette.svg?component'
+import _PanelLeftCloseIcon from './icons/panel-left-close.svg?component'
+import _PanelLeftOpenIcon from './icons/panel-left-open.svg?component'
import _PanelRightCloseIcon from './icons/panel-right-close.svg?component'
import _PanelRightOpenIcon from './icons/panel-right-open.svg?component'
+import _PanelTopIcon from './icons/panel-top.svg?component'
+import _PanelTopCloseIcon from './icons/panel-top-close.svg?component'
+import _PanelTopOpenIcon from './icons/panel-top-open.svg?component'
import _PauseIcon from './icons/pause.svg?component'
import _PencilIcon from './icons/pencil.svg?component'
import _PickaxeIcon from './icons/pickaxe.svg?component'
@@ -477,6 +485,7 @@ export const AffiliateIcon = _AffiliateIcon
export const AlignLeftIcon = _AlignLeftIcon
export const ArchiveIcon = _ArchiveIcon
export const ArrowBigRightDashIcon = _ArrowBigRightDashIcon
+export const ArrowBigUpIcon = _ArrowBigUpIcon
export const ArrowBigUpDashIcon = _ArrowBigUpDashIcon
export const ArrowDownIcon = _ArrowDownIcon
export const ArrowDown10Icon = _ArrowDown10Icon
@@ -655,6 +664,8 @@ export const LinkIcon = _LinkIcon
export const Link2Icon = _Link2Icon
export const ListIcon = _ListIcon
export const ListBulletedIcon = _ListBulletedIcon
+export const ListChevronsDownUpIcon = _ListChevronsDownUpIcon
+export const ListChevronsUpDownIcon = _ListChevronsUpDownIcon
export const ListEndIcon = _ListEndIcon
export const ListFilterIcon = _ListFilterIcon
export const ListOrderedIcon = _ListOrderedIcon
@@ -702,8 +713,13 @@ export const PackageSearchIcon = _PackageSearchIcon
export const PageRoundIcon = _PageRoundIcon
export const PaintbrushIcon = _PaintbrushIcon
export const PaletteIcon = _PaletteIcon
+export const PanelLeftCloseIcon = _PanelLeftCloseIcon
+export const PanelLeftOpenIcon = _PanelLeftOpenIcon
export const PanelRightCloseIcon = _PanelRightCloseIcon
export const PanelRightOpenIcon = _PanelRightOpenIcon
+export const PanelTopIcon = _PanelTopIcon
+export const PanelTopCloseIcon = _PanelTopCloseIcon
+export const PanelTopOpenIcon = _PanelTopOpenIcon
export const PauseIcon = _PauseIcon
export const PencilIcon = _PencilIcon
export const PickaxeIcon = _PickaxeIcon
diff --git a/packages/assets/icons/arrow-big-up.svg b/packages/assets/icons/arrow-big-up.svg
new file mode 100644
index 00000000000..60e31e350e2
--- /dev/null
+++ b/packages/assets/icons/arrow-big-up.svg
@@ -0,0 +1,15 @@
+
+
+
+
diff --git a/packages/assets/icons/list-chevrons-down-up.svg b/packages/assets/icons/list-chevrons-down-up.svg
new file mode 100644
index 00000000000..26f2bb6d1d8
--- /dev/null
+++ b/packages/assets/icons/list-chevrons-down-up.svg
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
diff --git a/packages/assets/icons/list-chevrons-up-down.svg b/packages/assets/icons/list-chevrons-up-down.svg
new file mode 100644
index 00000000000..354121a717c
--- /dev/null
+++ b/packages/assets/icons/list-chevrons-up-down.svg
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
diff --git a/packages/assets/icons/panel-left-close.svg b/packages/assets/icons/panel-left-close.svg
new file mode 100644
index 00000000000..106e8ebc5ba
--- /dev/null
+++ b/packages/assets/icons/panel-left-close.svg
@@ -0,0 +1,17 @@
+
+
+
+
+
+
diff --git a/packages/assets/icons/panel-left-open.svg b/packages/assets/icons/panel-left-open.svg
new file mode 100644
index 00000000000..ab69e66b0ee
--- /dev/null
+++ b/packages/assets/icons/panel-left-open.svg
@@ -0,0 +1,17 @@
+
+
+
+
+
+
diff --git a/packages/assets/icons/panel-right-close.svg b/packages/assets/icons/panel-right-close.svg
index b8d6cbace6c..50f3517c9b4 100644
--- a/packages/assets/icons/panel-right-close.svg
+++ b/packages/assets/icons/panel-right-close.svg
@@ -1,17 +1,17 @@
-
-
-
+
+
+
diff --git a/packages/assets/icons/panel-right-open.svg b/packages/assets/icons/panel-right-open.svg
index edd9fd0c5c8..7a4d0ade1dc 100644
--- a/packages/assets/icons/panel-right-open.svg
+++ b/packages/assets/icons/panel-right-open.svg
@@ -1,17 +1,17 @@
-
-
-
+
+
+
diff --git a/packages/assets/icons/panel-top-close.svg b/packages/assets/icons/panel-top-close.svg
new file mode 100644
index 00000000000..09a5129d855
--- /dev/null
+++ b/packages/assets/icons/panel-top-close.svg
@@ -0,0 +1,17 @@
+
+
+
+
+
+
diff --git a/packages/assets/icons/panel-top-open.svg b/packages/assets/icons/panel-top-open.svg
new file mode 100644
index 00000000000..153adce97a5
--- /dev/null
+++ b/packages/assets/icons/panel-top-open.svg
@@ -0,0 +1,17 @@
+
+
+
+
+
+
diff --git a/packages/assets/icons/panel-top.svg b/packages/assets/icons/panel-top.svg
new file mode 100644
index 00000000000..f5f9bb5202e
--- /dev/null
+++ b/packages/assets/icons/panel-top.svg
@@ -0,0 +1,16 @@
+
+
+
+
+
diff --git a/packages/moderation/src/data/issues/categories.ts b/packages/moderation/src/data/issues/categories.ts
new file mode 100644
index 00000000000..856b98bf7c7
--- /dev/null
+++ b/packages/moderation/src/data/issues/categories.ts
@@ -0,0 +1,96 @@
+import { TagsIcon } from '@modrinth/assets'
+
+import inaccurateMessage from '../messages/checklist/messages/tags/inaccurate.md'
+import optimizationMisusedMessage from '../messages/checklist/messages/tags/optimization-misused.md'
+import resolutionsMisusedMessage from '../messages/checklist/messages/tags/resolutions-misused.md'
+import { issue, panel, section, select, toggle } from './component-builders/builders'
+import { issueTargets } from './component-builders/targets'
+import type { ReviewContext } from './component-builders/types'
+
+const resolutionTags = new Set(['8x-', '16x', '32x', '48x', '64x', '128x', '256x', '512x+'])
+
+function automaticallyRemovedTags({ projectV3, selected }: ReviewContext) {
+ const tags = new Set()
+ if (selected.toggleIds.includes('categories-optimization-misused')) tags.add('optimization')
+ if (selected.toggleIds.includes('categories-resolutions-misused'))
+ for (const tag of [...projectV3.categories, ...projectV3.additional_categories])
+ if (resolutionTags.has(tag)) tags.add(tag)
+ return [...tags]
+}
+
+function removedTags(ctx: ReviewContext) {
+ return [
+ ...new Set([
+ ...ctx.getSelectValues('categories-remove-tags'),
+ ...automaticallyRemovedTags(ctx),
+ ]),
+ ]
+}
+
+export const categoriesInaccurateIssue = issue({
+ id: 'categories-inaccurate',
+ title: 'Inaccurate tags',
+ category: 'Tags',
+ actions: [issueTargets.removeTags(removedTags)],
+ message: (ctx) => {
+ const tags = removedTags(ctx)
+ return [
+ inaccurateMessage,
+ ctx.selected.toggleIds.includes('categories-optimization-misused')
+ ? optimizationMisusedMessage
+ : '',
+ ctx.selected.toggleIds.includes('categories-resolutions-misused')
+ ? resolutionsMisusedMessage
+ : '',
+ tags.length
+ ? `Please remove the following tags from your project\n\n${tags.map((tag) => `- ${tag}`).join('\n')}`
+ : '',
+ ]
+ .filter((message) => message.trim())
+ .join('\n\n')
+ },
+ suggestedStatus: 'flagged',
+})
+
+export const categoriesReviewPanel = panel({
+ title: 'Tags',
+ hint: "Are the project's tags accurate?",
+ icon: TagsIcon,
+ guidanceUrl:
+ 'https://www.notion.so/2e15ee711bf080e4a41df61bbab49892#2e15ee711bf0802f96aafc0397a9f6d3',
+ shown: ({ projectV3 }) =>
+ projectV3.categories.length > 0 || projectV3.additional_categories.length > 0,
+}).content(
+ toggle({ issue: categoriesInaccurateIssue, label: 'Inaccurate' }),
+ toggle({
+ issue: categoriesInaccurateIssue,
+ label: 'Optimization',
+ id: 'categories-optimization-misused',
+ shown: ({ projectV3 }) =>
+ [...projectV3.categories, ...projectV3.additional_categories].includes('optimization'),
+ }),
+ toggle({
+ issue: categoriesInaccurateIssue,
+ label: 'Resolutions',
+ id: 'categories-resolutions-misused',
+ shown: ({ projectV3 }) => projectV3.project_types.includes('resourcepack'),
+ }),
+ section({
+ shown: ({ selected }) =>
+ selected.issueIds.includes(categoriesInaccurateIssue.id) ||
+ selected.toggleIds.includes('categories-optimization-misused') ||
+ selected.toggleIds.includes('categories-resolutions-misused'),
+ }).content(
+ select({
+ issue: categoriesInaccurateIssue,
+ id: 'categories-remove-tags',
+ label: 'Remove inaccurate tags',
+ multiple: true,
+ required: (ctx) => automaticallyRemovedTags(ctx).length === 0,
+ options: ({ projectV3 }) =>
+ [...new Set([...projectV3.categories, ...projectV3.additional_categories])].map(
+ (value) => ({ value, label: value }),
+ ),
+ }),
+ ),
+)
diff --git a/packages/moderation/src/data/issues/component-builders/builders.ts b/packages/moderation/src/data/issues/component-builders/builders.ts
new file mode 100644
index 00000000000..85a03f24210
--- /dev/null
+++ b/packages/moderation/src/data/issues/component-builders/builders.ts
@@ -0,0 +1,63 @@
+import type {
+ EachPanel,
+ Issue,
+ IssueConfig,
+ IssueMarkdown,
+ IssueMarkdownConfig,
+ IssueSelect,
+ IssueSelectConfig,
+ IssueText,
+ IssueTextarea,
+ IssueTextareaConfig,
+ IssueTextConfig,
+ IssueToggle,
+ IssueToggleConfig,
+ PanelConfig,
+ PanelNode,
+ PanelSectionConfig,
+} from './types'
+
+class ContentBuilder {
+ children: PanelNode[] = []
+
+ content(...children: PanelNode[]): this {
+ this.children = children
+ return this
+ }
+}
+
+export function issue(config: IssueConfig): Issue {
+ return { ...config }
+}
+
+export function panel(config: PanelConfig) {
+ return Object.assign(new ContentBuilder(), config)
+}
+
+export function section(config: PanelSectionConfig = {}) {
+ return Object.assign(new ContentBuilder(), config, { type: 'section' as const })
+}
+
+export function toggle(config: IssueToggleConfig): IssueToggle {
+ return { ...config, type: 'toggle' }
+}
+
+export function markdown(config: IssueMarkdownConfig): IssueMarkdown {
+ return { ...config, type: 'markdown' }
+}
+
+export function text(config: IssueTextConfig): IssueText {
+ return { ...config, type: 'text' }
+}
+
+export function textarea(config: IssueTextareaConfig): IssueTextarea {
+ return { ...config, type: 'textarea' }
+}
+
+export function select(config: IssueSelectConfig): IssueSelect {
+ return { ...config, type: 'select' }
+}
+
+export function each(items: EachPanel['items']): EachPanel {
+ return { type: 'each', items }
+}
diff --git a/packages/moderation/src/data/issues/component-builders/item-panels.ts b/packages/moderation/src/data/issues/component-builders/item-panels.ts
new file mode 100644
index 00000000000..aa815d1c15d
--- /dev/null
+++ b/packages/moderation/src/data/issues/component-builders/item-panels.ts
@@ -0,0 +1,240 @@
+import type {
+ EachPanel,
+ Issue,
+ LabeledReviewPanel,
+ Panel,
+ PanelNode,
+ PanelRegistration,
+ ReviewContext,
+ ReviewPanelItem,
+ WithContext,
+} from './types'
+
+function isPanelGroup(definition: PanelRegistration): definition is readonly LabeledReviewPanel[] {
+ return Array.isArray(definition)
+}
+
+function resolve(value: WithContext, context: ReviewContext): T {
+ return typeof value === 'function' ? (value as (ctx: ReviewContext) => T)(context) : value
+}
+
+function itemContext(
+ ctx: ReviewContext,
+ item: ReviewPanelItem,
+ toggleIds: ReadonlyMap,
+): ReviewContext {
+ const prefix = `${item.key}:`
+ return {
+ ...ctx,
+ selected: {
+ ...ctx.selected,
+ issueIds: ctx.selected.issueIds.filter((id) =>
+ toggleIds
+ .get(id)
+ ?.some((toggleId) => ctx.selected.toggleIds.includes(`${prefix}${toggleId}`)),
+ ),
+ toggleIds: ctx.selected.toggleIds
+ .filter((id) => id.startsWith(prefix))
+ .map((id) => id.slice(prefix.length)),
+ },
+ getMarkdownValue: (id, issue) => ctx.getMarkdownValue(`${prefix}${id}`, issue),
+ getTextValue: (id, issue) => ctx.getTextValue(`${prefix}${id}`, issue),
+ getSelectValue: (id, issue) => ctx.getSelectValue(`${prefix}${id}`, issue),
+ getSelectValues: (id, issue) => ctx.getSelectValues(`${prefix}${id}`, issue),
+ }
+}
+
+/** Expands keyed item panels while preserving independent inputs and shared issue aggregation. */
+export function expandItemReviewPanels(
+ definitions: Record,
+ context: ReviewContext,
+) {
+ const panels: Record = {}
+ const parents = new Map()
+ const items = new Map()
+ const itemKinds = new Map()
+ const issues = new Map()
+ const itemIssues = new Map>()
+ const itemToggleIds = new Map>()
+ const registrations = new Map()
+ for (const [key, definition] of Object.entries(definitions)) {
+ const entries = isPanelGroup(definition)
+ ? definition.map(({ key, panel }) => [key, panel] as const)
+ : [[key, definition] as const]
+ for (const [panelKey, panel] of entries) {
+ if (registrations.has(panelKey)) throw new Error(`Duplicate review panel "${panelKey}"`)
+ registrations.set(panelKey, panel)
+ }
+ }
+ for (const [key, definition] of registrations) {
+ if (!('type' in definition)) {
+ panels[key] = definition
+ continue
+ }
+ for (const item of definition.items(context)) {
+ if (resolve(item.panel.shown, context) === false) continue
+ const itemKey = `${key}:${item.key}`
+ if (items.has(itemKey)) throw new Error(`Duplicate review panel "${itemKey}"`)
+ items.set(itemKey, item)
+ itemKinds.set(item, key)
+ }
+ }
+ function collectItemControls(nodes: readonly PanelNode[], item: ReviewPanelItem) {
+ const definitions = itemIssues.get(item) ?? new Map()
+ const toggles = itemToggleIds.get(item) ?? new Map()
+ itemIssues.set(item, definitions)
+ itemToggleIds.set(item, toggles)
+ for (const node of nodes) {
+ if (node.type === 'section') {
+ collectItemControls(node.children, item)
+ continue
+ }
+ if (node.issue) definitions.set(node.issue.id, node.issue)
+ if (node.type === 'toggle') {
+ const ids = toggles.get(node.issue.id) ?? []
+ ids.push(node.id ?? node.issue.id)
+ toggles.set(node.issue.id, ids)
+ }
+ }
+ }
+ for (const item of items.values()) collectItemControls(item.panel.children, item)
+ function scopedContext(ctx: ReviewContext, item: ReviewPanelItem) {
+ return itemContext(ctx, item, itemToggleIds.get(item)!)
+ }
+ function sharedIssue(original: Issue): Issue {
+ const existing = issues.get(original.id)
+ if (existing) return existing
+ const selectedItems = (ctx: ReviewContext) =>
+ [...items.values()].filter((item) =>
+ itemToggleIds
+ .get(item)
+ ?.get(original.id)
+ ?.some((id) => ctx.selected.toggleIds.includes(`${item.key}:${id}`)),
+ )
+ function selectedContext(ctx: ReviewContext): ReviewContext {
+ const selected: Record = {}
+ for (const item of selectedItems(ctx)) {
+ const kind = itemKinds.get(item)!
+ const entries = (selected[kind] ??= [])
+ entries.push({ key: item.key, context: scopedContext(ctx, item) })
+ }
+ return { ...ctx, selected: { ...ctx.selected, items: selected } }
+ }
+ const issue: Issue = {
+ ...original,
+ message: (ctx) => {
+ ctx = selectedContext(ctx)
+ const selected = selectedItems(ctx)
+ if (!selected.length) return resolve(original.message, ctx)
+ const messages = new Set(
+ selected.map((item) =>
+ resolve(itemIssues.get(item)!.get(original.id)!.message, {
+ ...scopedContext(ctx, item),
+ selected: ctx.selected,
+ }),
+ ),
+ )
+ return [...messages].join('\n\n')
+ },
+ locations: (ctx) => resolve(original.locations, selectedContext(ctx)) ?? [],
+ actions: (ctx) => {
+ const context = selectedContext(ctx)
+ return (resolve(original.actions, context) ?? []).map((action) => () => action(context))
+ },
+ }
+ issues.set(original.id, issue)
+ return issue
+ }
+ for (const definitions of itemIssues.values()) {
+ for (const issue of definitions.values()) sharedIssue(issue)
+ }
+ function scopeNodes(nodes: readonly PanelNode[], item?: ReviewPanelItem): PanelNode[] {
+ return nodes.flatMap((node): PanelNode[] => {
+ if (node.type === 'section') {
+ if (!item) {
+ const children = scopeNodes(node.children)
+ return children.length ? [{ ...node, children }] : []
+ }
+ return [
+ {
+ ...node,
+ shown: (ctx) => resolve(node.shown, scopedContext(ctx, item)) !== false,
+ label: (ctx) => resolve(node.label, scopedContext(ctx, item)) ?? '',
+ children: scopeNodes(node.children, item),
+ },
+ ]
+ }
+ const issue = node.issue ? (issues.get(node.issue.id) ?? node.issue) : undefined
+ if (!item) return [node]
+ const scoped = {
+ ...node,
+ ...(issue ? { issue } : {}),
+ id: `${item.key}:${node.type === 'toggle' ? (node.id ?? node.issue.id) : node.id}`,
+ label: (ctx: ReviewContext) => resolve(node.label, scopedContext(ctx, item)),
+ shown: (ctx: ReviewContext) => resolve(node.shown, scopedContext(ctx, item)) !== false,
+ disabled: (ctx: ReviewContext) => resolve(node.disabled, scopedContext(ctx, item)) === true,
+ tooltip:
+ node.tooltip === undefined
+ ? undefined
+ : (ctx: ReviewContext) => resolve(node.tooltip, scopedContext(ctx, item)) ?? '',
+ }
+ if (scoped.type === 'select' && node.type === 'select') {
+ scoped.initial = (ctx) => resolve(node.initial, scopedContext(ctx, item)) ?? []
+ scoped.placeholder =
+ node.placeholder === undefined
+ ? undefined
+ : (ctx) => resolve(node.placeholder, scopedContext(ctx, item)) ?? ''
+ scoped.options = (ctx) =>
+ resolve(node.options, scopedContext(ctx, item)).map((option) => ({
+ ...option,
+ label: (ctx: ReviewContext) => resolve(option.label, scopedContext(ctx, item)),
+ shown: (ctx: ReviewContext) =>
+ resolve(option.shown, scopedContext(ctx, item)) !== false,
+ disabled: (ctx: ReviewContext) =>
+ resolve(option.disabled, scopedContext(ctx, item)) === true,
+ }))
+ }
+ if (scoped.type !== 'toggle' && node.type !== 'toggle') {
+ scoped.required = (ctx) => resolve(node.required, scopedContext(ctx, item)) === true
+ }
+ if (
+ (scoped.type === 'text' || scoped.type === 'textarea') &&
+ (node.type === 'text' || node.type === 'textarea')
+ ) {
+ scoped.initial = (ctx) => resolve(node.initial, scopedContext(ctx, item)) ?? ''
+ scoped.placeholder =
+ node.placeholder === undefined
+ ? undefined
+ : (ctx) => resolve(node.placeholder, scopedContext(ctx, item)) ?? ''
+ }
+ if (node.type !== 'toggle') return [scoped]
+ if (scoped.type === 'toggle') {
+ scoped.issueListLabel = (ctx) =>
+ resolve(node.issueListLabel, scopedContext(ctx, item)) ??
+ resolve(item.panel.title, scopedContext(ctx, item)) ??
+ item.key
+ scoped.issueListGroup = (ctx) =>
+ resolve(node.issueListGroup, scopedContext(ctx, item)) ??
+ resolve(node.label, scopedContext(ctx, item))
+ }
+ return [scoped]
+ })
+ }
+ for (const [key, panel] of Object.entries(panels)) {
+ panels[key] = { ...panel, children: scopeNodes(panel.children) }
+ }
+ for (const [key, item] of items) {
+ if (item.panel.parent) {
+ if (!panels[item.panel.parent])
+ throw new Error(`Review panel "${key}" has an unknown parent "${item.panel.parent}"`)
+ parents.set(key, item.panel.parent)
+ }
+ panels[key] = {
+ ...item.panel,
+ title: (ctx) => resolve(item.panel.title, scopedContext(ctx, item)) ?? item.key,
+ hint: (ctx) => resolve(item.panel.hint, scopedContext(ctx, item)),
+ children: scopeNodes(item.panel.children, item),
+ }
+ }
+ return { panels, parents, issues }
+}
diff --git a/packages/moderation/src/data/issues/component-builders/locations.ts b/packages/moderation/src/data/issues/component-builders/locations.ts
new file mode 100644
index 00000000000..cc9067bafd5
--- /dev/null
+++ b/packages/moderation/src/data/issues/component-builders/locations.ts
@@ -0,0 +1,76 @@
+import type { Labrinth } from '@modrinth/api-client'
+import { defineMessages } from '@modrinth/ui'
+
+import { issueTargetLabels } from './targets'
+
+type Location = Labrinth.Threads.v3.ThreadIssueLocation
+
+const labels = defineMessages({
+ gallery: {
+ id: 'project-review.issue-location.gallery',
+ defaultMessage: 'Edit gallery images',
+ },
+ versions: { id: 'project-review.issue-location.versions', defaultMessage: 'Manage versions' },
+ disclosures: {
+ id: 'project-review.issue-location.disclosures',
+ defaultMessage: 'Edit disclosures',
+ },
+ permissions: {
+ id: 'project-review.issue-location.permissions',
+ defaultMessage: 'Review permissions',
+ },
+})
+
+export const issueLocationRegistry = {
+ title: { area: '', inline: true, label: issueTargetLabels.modify_title },
+ slug: { area: '', inline: true, label: issueTargetLabels.modify_slug },
+ summary: { area: '', inline: true, label: issueTargetLabels.modify_summary },
+ icon: { area: '', inline: true, label: issueTargetLabels.modify_icon },
+ description: { area: 'description', inline: true, label: issueTargetLabels.modify_description },
+ license: { area: 'license', inline: true, label: issueTargetLabels.modify_license },
+ tags: { area: 'tags', inline: true, label: issueTargetLabels.remove_tags },
+ links: { area: 'links', inline: false, label: issueTargetLabels.modify_links },
+ gallery: { area: 'gallery', inline: true, label: labels.gallery },
+ disclosures: { area: 'disclosures', inline: false, label: labels.disclosures },
+ versions: { area: 'versions', inline: false, label: labels.versions },
+ members: { area: 'members', inline: false, label: issueTargetLabels.modify_team_member_role },
+ server: { area: 'server', inline: false, label: issueTargetLabels.modify_server_address },
+ permissions: { area: 'permissions', inline: false, label: labels.permissions },
+} satisfies Record<
+ Location['field'],
+ { area: string; inline: boolean; label: NonNullable }
+>
+
+export function issueLocation(field: Location['field'], label?: Location['label']): Location {
+ return { field, ...(label ? { label } : {}) }
+}
+
+/** Ignore unknown or malformed locations on older or externally created issues. */
+export function readIssueLocations(value: unknown): Location[] {
+ if (!Array.isArray(value)) return []
+ const locations = new Map()
+ for (const entry of value) {
+ if (!entry || typeof entry !== 'object' || typeof entry.field !== 'string') continue
+ if (!Object.hasOwn(issueLocationRegistry, entry.field)) continue
+ const field = entry.field as Location['field']
+ const label = entry.label
+ locations.set(
+ field,
+ issueLocation(
+ field,
+ label &&
+ typeof label === 'object' &&
+ typeof label.id === 'string' &&
+ (label.defaultMessage === undefined || typeof label.defaultMessage === 'string')
+ ? {
+ id: label.id,
+ ...(label.defaultMessage === undefined
+ ? {}
+ : { defaultMessage: label.defaultMessage }),
+ }
+ : undefined,
+ ),
+ )
+ }
+ return [...locations.values()]
+}
diff --git a/packages/moderation/src/data/issues/component-builders/priority.ts b/packages/moderation/src/data/issues/component-builders/priority.ts
new file mode 100644
index 00000000000..55c177dddb5
--- /dev/null
+++ b/packages/moderation/src/data/issues/component-builders/priority.ts
@@ -0,0 +1,12 @@
+export const IssuePriority = {
+ Top: -1000,
+ Alerts: -40,
+ Rules: -30,
+ Rejected: -20,
+ Withheld: -10,
+ Default: 0,
+ Note: 10,
+ Bottom: 20,
+} as const
+
+export type IssuePriority = (typeof IssuePriority)[keyof typeof IssuePriority]
diff --git a/packages/moderation/src/data/issues/component-builders/targets.ts b/packages/moderation/src/data/issues/component-builders/targets.ts
new file mode 100644
index 00000000000..1bec3b959fd
--- /dev/null
+++ b/packages/moderation/src/data/issues/component-builders/targets.ts
@@ -0,0 +1,240 @@
+import type { Labrinth } from '@modrinth/api-client'
+import { defineMessages, type MessageDescriptor } from '@modrinth/ui'
+
+import type { IssueAction, ReviewContext, WithContext } from './types'
+
+export const issueTargetLabels = defineMessages({
+ mark_addressed: {
+ id: 'project-review.issue-target.mark-addressed',
+ defaultMessage: 'Mark addressed',
+ },
+ modify_title: {
+ id: 'project-review.issue-target.modify-title',
+ defaultMessage: 'Change title',
+ },
+ modify_slug: {
+ id: 'project-review.issue-target.modify-slug',
+ defaultMessage: 'Change URL slug',
+ },
+ modify_summary: {
+ id: 'project-review.issue-target.modify-summary',
+ defaultMessage: 'Change summary',
+ },
+ modify_description: {
+ id: 'project-review.issue-target.modify-description',
+ defaultMessage: 'Change description',
+ },
+ modify_license: {
+ id: 'project-review.issue-target.modify-license',
+ defaultMessage: 'Change license',
+ },
+ modify_icon: {
+ id: 'project-review.issue-target.modify-icon',
+ defaultMessage: 'Change icon',
+ },
+ remove_icon: {
+ id: 'project-review.issue-target.remove-icon',
+ defaultMessage: 'Remove icon',
+ },
+ remove_tags: {
+ id: 'project-review.issue-target.remove-tags',
+ defaultMessage: 'Remove tags',
+ },
+ modify_links: {
+ id: 'project-review.issue-target.modify-links',
+ defaultMessage: 'Change links',
+ },
+ add_gallery_images: {
+ id: 'project-review.issue-target.add-gallery-images',
+ defaultMessage: 'Add gallery images',
+ },
+ modify_gallery_image: {
+ id: 'project-review.issue-target.modify-gallery-image',
+ defaultMessage: 'Change gallery image',
+ },
+ remove_gallery_images: {
+ id: 'project-review.issue-target.remove-gallery-images',
+ defaultMessage: 'Remove gallery images',
+ },
+ add_project_disclosures: {
+ id: 'project-review.issue-target.add-project-disclosures',
+ defaultMessage: 'Add disclosures',
+ },
+ remove_project_disclosures: {
+ id: 'project-review.issue-target.remove-project-disclosures',
+ defaultMessage: 'Remove disclosures',
+ },
+ modify_project_disclosure: {
+ id: 'project-review.issue-target.modify-project-disclosure',
+ defaultMessage: 'Change disclosure',
+ },
+ modify_project_disclosure_note: {
+ id: 'project-review.issue-target.modify-project-disclosure-note',
+ defaultMessage: 'Change disclosure note',
+ },
+ version: {
+ id: 'project-review.issue-target.version',
+ defaultMessage: 'Change version',
+ },
+ modify_team_member_role: {
+ id: 'project-review.issue-target.modify-team-member-role',
+ defaultMessage: 'Change team member role',
+ },
+ modify_server_languages: {
+ id: 'project-review.issue-target.modify-server-languages',
+ defaultMessage: 'Change server languages',
+ },
+ modify_server_address: {
+ id: 'project-review.issue-target.modify-server-address',
+ defaultMessage: 'Change server address',
+ },
+ acknowledge: {
+ id: 'project-review.issue-target.acknowledge',
+ defaultMessage: 'Acknowledge',
+ },
+}) satisfies Record
+
+type Suggestion = WithContext
+
+function resolveSuggestion(suggestion: Suggestion | undefined, ctx: ReviewContext) {
+ return typeof suggestion === 'function' ? suggestion(ctx) : suggestion
+}
+
+function textTarget(original: string, suggestion: string | null | undefined) {
+ return { original, ...(suggestion === undefined ? {} : { suggestion }) }
+}
+
+export const issueTargets = {
+ markAddressed: (): IssueAction => () => ({ type: 'mark_addressed' }),
+ modifyTitle:
+ (suggestion?: Suggestion): IssueAction =>
+ (ctx) => ({
+ type: 'modify_title',
+ value: textTarget(ctx.projectV3.name, resolveSuggestion(suggestion, ctx)),
+ }),
+ modifySlug:
+ (suggestion?: Suggestion): IssueAction =>
+ (ctx) => ({
+ type: 'modify_slug',
+ value: textTarget(ctx.projectV3.slug ?? '', resolveSuggestion(suggestion, ctx)),
+ }),
+ modifySummary:
+ (suggestion?: Suggestion): IssueAction =>
+ (ctx) => ({
+ type: 'modify_summary',
+ value: textTarget(ctx.projectV3.summary, resolveSuggestion(suggestion, ctx)),
+ }),
+ modifyDescription:
+ (suggestion?: Suggestion): IssueAction =>
+ (ctx) => ({
+ type: 'modify_description',
+ value: textTarget(ctx.projectV3.description, resolveSuggestion(suggestion, ctx)),
+ }),
+ modifyLicense:
+ (licenseSuggestion?: Suggestion, urlSuggestion?: Suggestion): IssueAction =>
+ (ctx) => {
+ const license = resolveSuggestion(licenseSuggestion, ctx)
+ const url = resolveSuggestion(urlSuggestion, ctx)
+ return {
+ type: 'modify_license',
+ value: {
+ license: textTarget(ctx.projectV3.license.id, license),
+ url: textTarget(ctx.projectV3.license.url ?? '', url),
+ },
+ }
+ },
+ modifyIcon:
+ (): IssueAction =>
+ ({ projectV3 }) => ({
+ type: 'modify_icon',
+ value: { original_url: projectV3.icon_url ?? null },
+ }),
+ removeIcon: (): IssueAction => () => ({ type: 'remove_icon' }),
+ removeTags:
+ (tags: WithContext): IssueAction =>
+ (ctx) => ({
+ type: 'remove_tags',
+ value: { tags: typeof tags === 'function' ? tags(ctx) : tags },
+ }),
+ modifySelectedLinks:
+ (): IssueAction =>
+ ({ projectV3, selected }) => {
+ const platforms = new Set(
+ selected.toggleIds
+ .map((id) => id.split(':')[0])
+ .filter((id) => id.endsWith('-link'))
+ .map((id) => id.slice(0, -'-link'.length)),
+ )
+ const links = Object.fromEntries(
+ [...platforms].map((platform) => [
+ platform,
+ textTarget(projectV3.link_urls[platform]?.url ?? '', undefined),
+ ]),
+ )
+ return { type: 'modify_links', value: { links } }
+ },
+ addGalleryImages:
+ (): IssueAction =>
+ ({ projectV3 }) => ({
+ type: 'add_gallery_images',
+ value: { original_count: projectV3.gallery.length },
+ }),
+ addDisclosures:
+ (disclosureTypes: Labrinth.Projects.v3.ProjectDisclosureType[]): IssueAction =>
+ () => ({
+ type: 'add_project_disclosures',
+ value: { disclosure_types: disclosureTypes },
+ }),
+ modifyDisclosure:
+ (disclosureType: Labrinth.Projects.v3.ProjectDisclosureType): IssueAction =>
+ ({ disclosures }) => {
+ const disclosure = disclosures.find(
+ (disclosure) => disclosure.type === disclosureType && !disclosure.deleted_at,
+ )
+ let original: Labrinth.Threads.v3.ModifyDisclosureTarget['value']['metadata']['original'] =
+ null
+ if (disclosure) {
+ const {
+ type,
+ set_by_moderator,
+ lock_status,
+ updated_at,
+ updated_by,
+ deleted_at,
+ ...metadata
+ } = disclosure
+ original = metadata
+ }
+ return {
+ type: 'modify_project_disclosure',
+ value: { disclosure_type: disclosureType, metadata: { original } },
+ } as Labrinth.Threads.v3.ModifyDisclosureTarget
+ },
+ modifyServerLanguages:
+ (suggestion?: WithContext): IssueAction =>
+ (ctx) => {
+ const suggested = typeof suggestion === 'function' ? suggestion(ctx) : suggestion
+ return {
+ type: 'modify_server_languages',
+ value: {
+ original: [...(ctx.projectV3.minecraft_server?.languages ?? [])],
+ ...(suggested === undefined ? {} : { suggestion: suggested }),
+ },
+ }
+ },
+ acknowledge:
+ (mode: 'checkbox' | 'reply'): IssueAction =>
+ () => ({
+ type: 'acknowledge',
+ value: { mode },
+ }),
+}
+
+export function resolveIssueActions(
+ actions: WithContext | undefined,
+ ctx: ReviewContext,
+): Labrinth.Threads.v3.NewThreadIssue['facets'] {
+ const [first, ...rest] = (typeof actions === 'function' ? actions(ctx) : actions) ?? []
+ if (!first) return [{ what: issueTargets.markAddressed()(ctx) }]
+ return [{ what: first(ctx) }, ...rest.map((action) => ({ what: action(ctx) }))]
+}
diff --git a/packages/moderation/src/data/issues/component-builders/types.ts b/packages/moderation/src/data/issues/component-builders/types.ts
new file mode 100644
index 00000000000..58958e4eb60
--- /dev/null
+++ b/packages/moderation/src/data/issues/component-builders/types.ts
@@ -0,0 +1,185 @@
+import type { Labrinth } from '@modrinth/api-client'
+import type { FunctionalComponent, SVGAttributes } from 'vue'
+
+import type { ModerationStatus } from '../../../types/node/state'
+import type { IssuePriority } from './priority'
+
+export interface ReviewContext {
+ projectV3: Labrinth.Projects.v3.Project
+ projectV2: Labrinth.Projects.v2.Project | undefined
+ disclosures: readonly Labrinth.Projects.v3.ProjectDisclosureData[]
+ members: readonly Labrinth.Projects.v3.TeamMember[]
+ organization: Labrinth.Organizations.v3.Organization | null
+ organizationMembers: readonly Labrinth.Projects.v3.TeamMember[]
+ wasReviewed: boolean
+ permissions: {
+ groups: readonly Labrinth.Attribution.Internal.AttributionGroup[]
+ unresolvedCount: number
+ loaded: boolean
+ loading: boolean
+ error: unknown
+ }
+ selected: {
+ /** Issue IDs selected directly or through explicit toggle IDs across the current project. */
+ issueIds: readonly string[]
+ /** Selected toggle IDs for the current issue; panel and section callbacks receive all selected toggle IDs. */
+ toggleIds: readonly string[]
+ /** Message and action callbacks receive selected items for the current issue, grouped by their registry key. */
+ items: Readonly>
+ }
+ /**
+ * Returns an input value for the current issue, falling back to shared inputs, or empty if unavailable.
+ * Panel and section callbacks read shared inputs unless an issue ID is supplied.
+ * Only visible, enabled controls contribute when resolving messages.
+ * UI callbacks read stored values; message callbacks also resolve defaults.
+ */
+ getMarkdownValue(id: string, issueId?: string): string
+ getTextValue(id: string, issueId?: string): string
+ getSelectValue(id: string, issueId?: string): string
+ getSelectValues(id: string, issueId?: string): readonly string[]
+}
+
+export type WithContext = T | ((ctx: ReviewContext) => T)
+
+export type IssueAction = (ctx: ReviewContext) => Labrinth.Threads.v3.ThreadIssueTarget
+
+export interface Issue {
+ id: string
+ title: string
+ category: string
+ /** Orders selected issues; omitted priorities use the default group. */
+ priority?: IssuePriority | number
+ message: WithContext
+ /** Targets to change, resolved from current context. Omitted or empty actions require checkbox acknowledgment. */
+ actions?: WithContext
+ /** Additional navigation destinations and field placement, independent of action completion. Locations sharing a field with an action reuse its button; a custom location label overrides the button label. */
+ locations?: WithContext
+ suggestedStatus?: WithContext
+}
+
+export type IssueConfig = Issue
+
+interface IssueControlOptions {
+ /** Omit to share this input across issues by its control ID. */
+ issue?: Issue
+ label: WithContext
+ shown?: WithContext
+ tooltip?: WithContext
+ disabled?: WithContext
+}
+
+export interface IssueToggle extends IssueControlOptions {
+ type: 'toggle'
+ issue: Issue
+ /** Optional label for this toggle in the issue list. */
+ issueListLabel?: WithContext
+ /** Optional issue-list row for this toggle. Defaults to the toggle label. */
+ issueListGroup?: WithContext
+ /**
+ * Identifies a selection within its issue. Reuse the ID across panels to share selection state.
+ * Without an ID, the toggle activates the issue directly and contributes no selected toggle ID.
+ */
+ id?: string
+}
+
+export type IssueToggleConfig = Omit
+
+export interface IssueMarkdown extends IssueControlOptions {
+ type: 'markdown'
+ id: string
+ required?: WithContext
+}
+
+export type IssueMarkdownConfig = Omit
+
+export interface IssueText extends IssueControlOptions {
+ type: 'text'
+ id: string
+ required?: WithContext
+ placeholder?: WithContext
+ initial?: WithContext
+}
+
+export type IssueTextConfig = Omit
+
+export interface IssueTextarea extends IssueControlOptions {
+ type: 'textarea'
+ id: string
+ required?: WithContext
+ placeholder?: WithContext
+ initial?: WithContext
+ rows?: number
+ maxlength?: number
+}
+
+export type IssueTextareaConfig = Omit
+
+export interface IssueSelectOption {
+ value: string
+ label: WithContext
+ shown?: WithContext
+ disabled?: WithContext
+}
+
+export interface IssueSelect extends IssueControlOptions {
+ type: 'select'
+ id: string
+ options: WithContext
+ multiple?: boolean
+ required?: WithContext
+ placeholder?: WithContext
+ initial?: WithContext
+}
+
+export type IssueSelectConfig = Omit
+
+export type IssueControl = IssueToggle | IssueMarkdown | IssueText | IssueTextarea | IssueSelect
+
+export interface ReviewPanelItem {
+ key: string
+ panel: Panel
+}
+
+export interface Panel {
+ icon: FunctionalComponent
+ /** Parent panel whose matching controls apply to this item. */
+ parent?: string
+ shown?: WithContext
+ title?: WithContext
+ hint: WithContext
+ guidanceUrl: string
+ children: PanelNode[]
+}
+
+/** Registers keyed panels returned by a callback. */
+export interface EachPanel {
+ type: 'each'
+ items: (ctx: ReviewContext) => readonly ReviewPanelItem[]
+}
+
+export interface LabeledReviewPanel extends ReviewPanelItem {
+ label: string
+}
+
+export type PanelRegistration = Panel | EachPanel | readonly LabeledReviewPanel[]
+
+export type PanelConfig = Omit
+
+/**
+ * Only panels and sections support `.content()`; toggles and markdown controls are leaves.
+ * When migrating other stages, move nested control content into a section and use `shown`
+ * to check `selected.issueIds` for a toggle without an ID, or `selected.toggleIds` for an explicit ID.
+ * Preserve ancestor visibility by nesting conditional sections or checking every parent toggle
+ * in a sibling section's `shown` callback. Keep existing issue IDs, toggle IDs, and markdown keys.
+ * See description.ts for an example of migrating toggle content into conditional sections.
+ */
+export interface PanelSection {
+ type: 'section'
+ label?: WithContext
+ shown?: WithContext
+ children: PanelNode[]
+}
+
+export type PanelSectionConfig = Omit
+
+export type PanelNode = PanelSection | IssueControl
diff --git a/packages/moderation/src/data/issues/description.ts b/packages/moderation/src/data/issues/description.ts
new file mode 100644
index 00000000000..dc9c58de60c
--- /dev/null
+++ b/packages/moderation/src/data/issues/description.ts
@@ -0,0 +1,175 @@
+import { LibraryIcon } from '@modrinth/assets'
+
+import clarity from '../messages/checklist/messages/description/clarity.md'
+import headersAsBody from '../messages/checklist/messages/description/headers-as-body.md'
+import imageOnly from '../messages/checklist/messages/description/image-only.md'
+import insufficientPacks from '../messages/checklist/messages/description/insufficient/default/packs.md'
+import insufficientProjects from '../messages/checklist/messages/description/insufficient/default/projects.md'
+import insufficientServers from '../messages/checklist/messages/description/insufficient/default/servers.md'
+import insufficientHeader from '../messages/checklist/messages/description/insufficient/header.md'
+import fork from '../messages/checklist/messages/description/insufficient/piece/fork.md'
+import spoilerGuide from '../messages/checklist/messages/description/insufficient/piece/spoiler-guide.md'
+import spoilers from '../messages/checklist/messages/description/insufficient/piece/spoilers.md'
+import unfinished from '../messages/checklist/messages/description/insufficient/piece/unfinished.md'
+import nonEnglish from '../messages/checklist/messages/description/non-english.md'
+import nonEnglishServer from '../messages/checklist/messages/description/non-english-server.md'
+import nonStandardText from '../messages/checklist/messages/description/non-standard-text.md'
+import { issue, markdown, panel, section, toggle } from './component-builders/builders'
+import { issueTargets } from './component-builders/targets'
+import { rulesAiImagesIssue } from './rules'
+
+export const insufficientDescriptionIssue = issue({
+ id: 'description-insufficient',
+ title: 'Insufficient description',
+ category: 'Description',
+ actions: [issueTargets.modifyDescription()],
+ suggestedStatus: 'flagged',
+ message: ({ projectV3, selected, getMarkdownValue }) => {
+ const { toggleIds } = selected
+ const parts: string[] = []
+
+ if (toggleIds.includes('description-custom')) {
+ const custom = getMarkdownValue('description-explainer').trim()
+ if (custom) parts.push(custom)
+ }
+ if (toggleIds.includes('description-fork')) parts.push(fork.trim())
+ if (toggleIds.includes('description-unfinished')) parts.push(unfinished.trim())
+ if (toggleIds.includes('description-spoilers')) parts.push(spoilers.trim())
+
+ if (!parts.length) {
+ if (projectV3.minecraft_java_server) {
+ parts.push(insufficientServers.trim())
+ } else if (projectV3.project_types.includes('modpack')) {
+ parts.push(insufficientPacks.trim())
+ } else {
+ parts.push(insufficientProjects.trim())
+ }
+ }
+
+ if (toggleIds.includes('description-spoilers')) parts.push(spoilerGuide.trim())
+
+ return [insufficientHeader.trim(), ...parts].join('\n\n')
+ },
+})
+
+export const nonEnglishDescriptionIssue = issue({
+ id: 'description-non-english',
+ title: 'Non-English description',
+ category: 'Description',
+ actions: [issueTargets.modifyDescription()],
+ suggestedStatus: 'flagged',
+ message: ({ projectV3 }) => (projectV3.minecraft_java_server ? nonEnglishServer : nonEnglish),
+})
+
+export const descriptionHeadersAsBodyIssue = issue({
+ id: 'description-headers-as-body',
+ title: 'Headers used as body text',
+ category: 'Description',
+ actions: [issueTargets.modifyDescription()],
+ suggestedStatus: 'flagged',
+ message: headersAsBody,
+})
+
+export const imageOnlyDescriptionIssue = issue({
+ id: 'description-image-only',
+ title: 'Image-only description',
+ category: 'Description',
+ actions: [issueTargets.modifyDescription()],
+ suggestedStatus: 'flagged',
+ message: imageOnly,
+})
+
+export const nonStandardDescriptionTextIssue = issue({
+ id: 'description-non-standard-text',
+ title: 'Non-standard description text',
+ category: 'Description',
+ actions: [issueTargets.modifyDescription()],
+ suggestedStatus: 'flagged',
+ message: nonStandardText,
+})
+
+export const unclearDescriptionIssue = issue({
+ id: 'description-clarity',
+ title: 'Unclear or misleading description',
+ category: 'Description',
+ actions: [issueTargets.modifyDescription()],
+ suggestedStatus: 'rejected',
+ message: clarity,
+})
+
+export const descriptionReviewPanel = panel({
+ title: 'Description',
+ hint: 'Is the description sufficient, accurate, and accessible?',
+ icon: LibraryIcon,
+ guidanceUrl:
+ 'https://www.notion.so/2e15ee711bf080e4a41df61bbab49892#2e15ee711bf080508042e70089dd787e',
+}).content(
+ toggle({
+ label: 'Insufficient',
+ issue: insufficientDescriptionIssue,
+ }),
+ toggle({
+ label: 'Non-English',
+ issue: nonEnglishDescriptionIssue,
+ shown: ({ projectV3 }) =>
+ !projectV3.minecraft_java_server || !!projectV3.minecraft_server?.languages?.includes('en'),
+ }),
+ toggle({
+ label: 'Headers as body text',
+ issue: descriptionHeadersAsBodyIssue,
+ }),
+ toggle({
+ label: 'Image-only',
+ issue: imageOnlyDescriptionIssue,
+ }),
+ toggle({
+ label: 'Non-standard text',
+ issue: nonStandardDescriptionTextIssue,
+ }),
+ toggle({
+ label: 'Unclear / Misleading',
+ issue: unclearDescriptionIssue,
+ }),
+ toggle({
+ label: 'AI Images',
+ issue: rulesAiImagesIssue,
+ id: 'description-ai-images',
+ }),
+ section({
+ label: 'Why is this Description Insufficient?',
+ shown: ({ selected }) => selected.issueIds.includes(insufficientDescriptionIssue.id),
+ }).content(
+ toggle({
+ label: 'Custom',
+ id: 'description-custom',
+ issue: insufficientDescriptionIssue,
+ }),
+ toggle({
+ label: 'Fork',
+ id: 'description-fork',
+ issue: insufficientDescriptionIssue,
+ }),
+ toggle({
+ label: 'Unfinished',
+ id: 'description-unfinished',
+ issue: insufficientDescriptionIssue,
+ }),
+ toggle({
+ label: 'Spoilers',
+ id: 'description-spoilers',
+ issue: insufficientDescriptionIssue,
+ }),
+ ),
+ section({
+ shown: ({ selected }) =>
+ selected.issueIds.includes(insufficientDescriptionIssue.id) &&
+ selected.toggleIds.includes('description-custom'),
+ }).content(
+ markdown({
+ label: 'How can the author improve their description?',
+ issue: insufficientDescriptionIssue,
+ id: 'description-explainer',
+ required: true,
+ }),
+ ),
+)
diff --git a/packages/moderation/src/data/issues/disclosures.ts b/packages/moderation/src/data/issues/disclosures.ts
new file mode 100644
index 00000000000..f0d4277b30d
--- /dev/null
+++ b/packages/moderation/src/data/issues/disclosures.ts
@@ -0,0 +1,562 @@
+import type { Labrinth } from '@modrinth/api-client'
+import { TriangleAlertIcon } from '@modrinth/assets'
+
+import disclosuresHeaderMessage from '../messages/checklist/messages/disclosures/header.md'
+import disclosuresHeaderListlessMessage from '../messages/checklist/messages/disclosures/header-listless.md'
+import missingAdsMessage from '../messages/checklist/messages/disclosures/missing-disclosures/ads.md'
+import missingAiMessage from '../messages/checklist/messages/disclosures/missing-disclosures/ai/ai.md'
+import missingAiFunctionalityMessage from '../messages/checklist/messages/disclosures/missing-disclosures/ai/ai-functionality.md'
+import missingAiAssetsMessage from '../messages/checklist/messages/disclosures/missing-disclosures/ai/ai-usages/assets.md'
+import missingAiCodeMessage from '../messages/checklist/messages/disclosures/missing-disclosures/ai/ai-usages/code.md'
+import missingAiTextMessage from '../messages/checklist/messages/disclosures/missing-disclosures/ai/ai-usages/text.md'
+import missingAiListIntroMessage from '../messages/checklist/messages/disclosures/missing-disclosures/ai/list-intro.md'
+import missingArchiveMessage from '../messages/checklist/messages/disclosures/missing-disclosures/archive.md'
+import missingDerivativeContentMessage from '../messages/checklist/messages/disclosures/missing-disclosures/derivative-content.md'
+import missingListIntroMessage from '../messages/checklist/messages/disclosures/missing-disclosures/list-intro.md'
+import missingPaidFeaturesMessage from '../messages/checklist/messages/disclosures/missing-disclosures/paid-features.md'
+import missingPhotosensitivityMessage from '../messages/checklist/messages/disclosures/missing-disclosures/photosensitivity.md'
+import missingSystemInteractionsMessage from '../messages/checklist/messages/disclosures/missing-disclosures/system-interactions.md'
+import missingTelemetryMessage from '../messages/checklist/messages/disclosures/missing-disclosures/telemetry.md'
+import missingTelemetryAlwaysMessage from '../messages/checklist/messages/disclosures/missing-disclosures/telemetry/consent-model/always.md'
+import missingTelemetryOptInMessage from '../messages/checklist/messages/disclosures/missing-disclosures/telemetry/consent-model/opt-in.md'
+import missingTelemetryOptOutMessage from '../messages/checklist/messages/disclosures/missing-disclosures/telemetry/consent-model/opt-out.md'
+import misusedAdsMessage from '../messages/checklist/messages/disclosures/misused-disclosures/ads.md'
+import misusedAiMessage from '../messages/checklist/messages/disclosures/misused-disclosures/ai/ai.md'
+import misusedAiFunctionalityMessage from '../messages/checklist/messages/disclosures/misused-disclosures/ai/ai-functionality.md'
+import misusedAiAssetsMessage from '../messages/checklist/messages/disclosures/misused-disclosures/ai/ai-usages/assets.md'
+import misusedAiCodeMessage from '../messages/checklist/messages/disclosures/misused-disclosures/ai/ai-usages/code.md'
+import misusedAiTextMessage from '../messages/checklist/messages/disclosures/misused-disclosures/ai/ai-usages/text.md'
+import misusedAiListIntroMessage from '../messages/checklist/messages/disclosures/misused-disclosures/ai/list-intro.md'
+import misusedArchiveMessage from '../messages/checklist/messages/disclosures/misused-disclosures/archive.md'
+import misusedDerivativeContentMessage from '../messages/checklist/messages/disclosures/misused-disclosures/derivative-content.md'
+import misusedListIntroMessage from '../messages/checklist/messages/disclosures/misused-disclosures/list-intro.md'
+import misusedPaidFeaturesMessage from '../messages/checklist/messages/disclosures/misused-disclosures/paid-features.md'
+import misusedPhotosensitivityMessage from '../messages/checklist/messages/disclosures/misused-disclosures/photosensitivity.md'
+import misusedSystemInteractionsMessage from '../messages/checklist/messages/disclosures/misused-disclosures/system-interactions.md'
+import misusedTelemetryMessage from '../messages/checklist/messages/disclosures/misused-disclosures/telemetry.md'
+import nonEnglishMessage from '../messages/checklist/messages/disclosures/non-english.md'
+import { issue, panel, section, toggle } from './component-builders/builders'
+import { issueLocation } from './component-builders/locations'
+import { issueTargets } from './component-builders/targets'
+
+const disclosureTypes = {
+ ai: 'ai_content',
+ 'ai-functionality': 'ai_functionality',
+ ads: 'advertisements',
+ 'paid-features': 'paid_features',
+ telemetry: 'telemetry',
+ 'derivative-content': 'derivative_work',
+ photosensitivity: 'epilepsy_triggers',
+ 'system-interactions': 'system_interactions',
+ archive: 'archived',
+} satisfies Record
+
+export const disclosuresIssue = issue({
+ id: 'disclosures',
+ locations: [issueLocation('disclosures')],
+ title: 'Invalid disclosures',
+ category: 'Disclosures',
+ actions: ({ selected }) => {
+ const missingTypes = Object.entries(disclosureTypes).flatMap(([field, type]) =>
+ selected.toggleIds.includes(`${field}-disclosure-missing`) ? [type] : [],
+ )
+ return [
+ ...(missingTypes.length ? [issueTargets.addDisclosures(missingTypes)] : []),
+ ...Object.entries(disclosureTypes).flatMap(([field, type]) =>
+ selected.toggleIds.includes(`${field}-disclosure-misused`)
+ ? [issueTargets.modifyDisclosure(type)]
+ : [],
+ ),
+ ]
+ },
+ message: ({ selected }) => {
+ const toggleIds = new Set(selected.toggleIds)
+ const missingParts: string[] = []
+ const misusedParts: string[] = []
+
+ for (const field of [
+ 'ai',
+ 'ai-functionality',
+ 'ads',
+ 'paid-features',
+ 'telemetry',
+ 'derivative-content',
+ 'photosensitivity',
+ 'system-interactions',
+ 'archive',
+ ] as const) {
+ const missingSelected = toggleIds.has(`${field}-disclosure-missing`)
+ const misusedSelected = toggleIds.has(`${field}-disclosure-misused`)
+ if (!missingSelected && !misusedSelected) continue
+
+ let missingMessage: string
+ let misusedMessage: string
+ switch (field) {
+ case 'ai':
+ missingMessage = missingAiMessage
+ misusedMessage = misusedAiMessage
+ break
+ case 'ai-functionality':
+ missingMessage = missingAiFunctionalityMessage
+ misusedMessage = misusedAiFunctionalityMessage
+ break
+ case 'ads':
+ missingMessage = missingAdsMessage
+ misusedMessage = misusedAdsMessage
+ break
+ case 'paid-features':
+ missingMessage = missingPaidFeaturesMessage
+ misusedMessage = misusedPaidFeaturesMessage
+ break
+ case 'telemetry':
+ missingMessage = missingTelemetryMessage
+ misusedMessage = misusedTelemetryMessage
+ break
+ case 'derivative-content':
+ missingMessage = missingDerivativeContentMessage
+ misusedMessage = misusedDerivativeContentMessage
+ break
+ case 'photosensitivity':
+ missingMessage = missingPhotosensitivityMessage
+ misusedMessage = misusedPhotosensitivityMessage
+ break
+ case 'system-interactions':
+ missingMessage = missingSystemInteractionsMessage
+ misusedMessage = misusedSystemInteractionsMessage
+ break
+ case 'archive':
+ missingMessage = missingArchiveMessage
+ misusedMessage = misusedArchiveMessage
+ break
+ default:
+ continue
+ }
+
+ if (missingSelected) {
+ missingParts.push(missingMessage)
+ if (field === 'ai') {
+ const usages: string[] = []
+ if (toggleIds.has('ai-disclosure-missing-code')) usages.push(missingAiCodeMessage)
+ if (toggleIds.has('ai-disclosure-missing-assets')) usages.push(missingAiAssetsMessage)
+ if (toggleIds.has('ai-disclosure-missing-text')) usages.push(missingAiTextMessage)
+ if (usages.length) missingParts.push(missingAiListIntroMessage, ...usages)
+ }
+ if (field === 'telemetry') {
+ if (toggleIds.has('telemetry-disclosure-missing-opt-in')) {
+ missingParts.push(missingTelemetryOptInMessage)
+ }
+ if (toggleIds.has('telemetry-disclosure-missing-opt-out')) {
+ missingParts.push(missingTelemetryOptOutMessage)
+ }
+ if (toggleIds.has('telemetry-disclosure-missing-always')) {
+ missingParts.push(missingTelemetryAlwaysMessage)
+ }
+ }
+ }
+
+ if (misusedSelected) {
+ misusedParts.push(misusedMessage)
+ if (field === 'ai') {
+ const usages: string[] = []
+ if (toggleIds.has('ai-disclosure-misused-code')) usages.push(misusedAiCodeMessage)
+ if (toggleIds.has('ai-disclosure-misused-assets')) usages.push(misusedAiAssetsMessage)
+ if (toggleIds.has('ai-disclosure-misused-text')) usages.push(misusedAiTextMessage)
+ if (usages.length) misusedParts.push(misusedAiListIntroMessage, ...usages)
+ }
+ }
+ }
+
+ const groups: string[] = []
+ if (
+ !missingParts.length &&
+ !misusedParts.length &&
+ (selected.toggleIds.includes('disclosures-missing') ||
+ selected.toggleIds.includes('disclosures-misused') ||
+ selected.toggleIds.length === 0)
+ ) {
+ return disclosuresHeaderListlessMessage
+ }
+ if (missingParts.length) {
+ const body = missingParts.map((part) => part.trimEnd()).join('\n')
+ groups.push(`${missingListIntroMessage.trim()}\n\n${body}`)
+ }
+ if (misusedParts.length) {
+ const body = misusedParts.map((part) => part.trimEnd()).join('\n')
+ groups.push(`${misusedListIntroMessage.trim()}\n\n${body}`)
+ }
+ return groups.length ? [disclosuresHeaderMessage.trim(), ...groups].join('\n\n') : ''
+ },
+ suggestedStatus: ({ selected }) => {
+ const toggleIds = new Set(selected.toggleIds)
+ const rejected = ['telemetry', 'derivative-content', 'photosensitivity', 'system-interactions']
+ if (
+ rejected.some(
+ (field) =>
+ toggleIds.has(`${field}-disclosure-missing`) ||
+ toggleIds.has(`${field}-disclosure-misused`),
+ )
+ )
+ return 'rejected'
+ if (
+ [...toggleIds].some((id) => id.includes('-disclosure-misused')) ||
+ ['ai', 'ai-functionality', 'ads', 'paid-features'].some((field) =>
+ toggleIds.has(`${field}-disclosure-missing`),
+ )
+ )
+ return 'flagged'
+ return undefined
+ },
+})
+
+export const disclosuresNonEnglishIssue = issue({
+ id: 'disclosures-non-english',
+ locations: [issueLocation('disclosures')],
+ title: 'Non-English disclosure information',
+ category: 'Disclosures',
+ message: nonEnglishMessage,
+ suggestedStatus: 'flagged',
+})
+
+export const aiDisclosureReviewPanel = panel({
+ title: 'AI Usage',
+ hint: 'Has this project accurately disclosed this content?',
+ icon: TriangleAlertIcon,
+ guidanceUrl: 'https://www.notion.so/2e15ee711bf080e4a41df61bbab49892',
+}).content(
+ toggle({
+ issue: disclosuresIssue,
+ id: 'ai-disclosure-missing',
+ label: 'Missing',
+ issueListLabel: 'AI Usage',
+ }),
+ toggle({
+ issue: disclosuresIssue,
+ id: 'ai-disclosure-misused',
+ shown: ({ disclosures }) =>
+ disclosures.some((disclosure) => disclosure.type === 'ai_content' && !disclosure.deleted_at),
+ label: 'Misused',
+ issueListLabel: 'AI Usage',
+ }),
+ section({
+ label: 'Type of missing disclosure?',
+ shown: ({ selected }) => selected.toggleIds.includes('ai-disclosure-missing'),
+ }).content(
+ toggle({
+ issue: disclosuresIssue,
+ id: 'ai-disclosure-missing-code',
+ label: 'Code',
+ issueListLabel: 'AI Usage: Code',
+ issueListGroup: 'Missing',
+ }),
+ toggle({
+ issue: disclosuresIssue,
+ id: 'ai-disclosure-missing-assets',
+ label: 'Assets',
+ issueListLabel: 'AI Usage: Assets',
+ issueListGroup: 'Missing',
+ }),
+ toggle({
+ issue: disclosuresIssue,
+ id: 'ai-disclosure-missing-text',
+ label: 'Text',
+ issueListLabel: 'AI Usage: Text',
+ issueListGroup: 'Missing',
+ }),
+ ),
+ section({
+ label: 'Type of misused disclosure?',
+ shown: ({ disclosures, selected }) =>
+ selected.toggleIds.includes('ai-disclosure-misused') &&
+ disclosures.some((disclosure) => disclosure.type === 'ai_content' && !disclosure.deleted_at),
+ }).content(
+ toggle({
+ issue: disclosuresIssue,
+ id: 'ai-disclosure-misused-code',
+ shown: ({ disclosures }) =>
+ disclosures.some(
+ (disclosure) =>
+ disclosure.type === 'ai_content' &&
+ !disclosure.deleted_at &&
+ disclosure.uses.includes('code'),
+ ),
+ label: 'Code',
+ issueListLabel: 'AI Usage: Code',
+ issueListGroup: 'Misused',
+ }),
+ toggle({
+ issue: disclosuresIssue,
+ id: 'ai-disclosure-misused-assets',
+ shown: ({ disclosures }) =>
+ disclosures.some(
+ (disclosure) =>
+ disclosure.type === 'ai_content' &&
+ !disclosure.deleted_at &&
+ disclosure.uses.includes('assets'),
+ ),
+ label: 'Assets',
+ issueListLabel: 'AI Usage: Assets',
+ issueListGroup: 'Misused',
+ }),
+ toggle({
+ issue: disclosuresIssue,
+ id: 'ai-disclosure-misused-text',
+ shown: ({ disclosures }) =>
+ disclosures.some(
+ (disclosure) =>
+ disclosure.type === 'ai_content' &&
+ !disclosure.deleted_at &&
+ disclosure.uses.includes('text'),
+ ),
+ label: 'Text',
+ issueListLabel: 'AI Usage: Text',
+ issueListGroup: 'Misused',
+ }),
+ ),
+)
+
+export const aiFunctionalityDisclosureReviewPanel = panel({
+ title: 'AI Functionality',
+ hint: 'Has this project accurately disclosed this content?',
+ icon: TriangleAlertIcon,
+ guidanceUrl: 'https://www.notion.so/2e15ee711bf080e4a41df61bbab49892',
+}).content(
+ toggle({
+ issue: disclosuresIssue,
+ id: 'ai-functionality-disclosure-missing',
+ label: 'Missing',
+ issueListLabel: 'AI Functionality',
+ }),
+ toggle({
+ issue: disclosuresIssue,
+ id: 'ai-functionality-disclosure-misused',
+ shown: ({ disclosures }) =>
+ disclosures.some(
+ (disclosure) => disclosure.type === 'ai_functionality' && !disclosure.deleted_at,
+ ),
+ label: 'Misused',
+ issueListLabel: 'AI Functionality',
+ }),
+)
+
+export const adsDisclosureReviewPanel = panel({
+ title: 'Advertisements',
+ hint: 'Has this project accurately disclosed this content?',
+ icon: TriangleAlertIcon,
+ guidanceUrl: 'https://www.notion.so/2e15ee711bf080e4a41df61bbab49892',
+}).content(
+ toggle({
+ issue: disclosuresIssue,
+ id: 'ads-disclosure-missing',
+ label: 'Missing',
+ issueListLabel: 'Advertisements',
+ }),
+ toggle({
+ issue: disclosuresIssue,
+ id: 'ads-disclosure-misused',
+ shown: ({ disclosures }) =>
+ disclosures.some(
+ (disclosure) => disclosure.type === 'advertisements' && !disclosure.deleted_at,
+ ),
+ label: 'Misused',
+ issueListLabel: 'Advertisements',
+ }),
+)
+
+export const paidFeaturesDisclosureReviewPanel = panel({
+ title: 'Paid Features',
+ hint: 'Has this project accurately disclosed this content?',
+ icon: TriangleAlertIcon,
+ guidanceUrl: 'https://www.notion.so/2e15ee711bf080e4a41df61bbab49892',
+}).content(
+ toggle({
+ issue: disclosuresIssue,
+ id: 'paid-features-disclosure-missing',
+ label: 'Missing',
+ issueListLabel: 'Paid Features',
+ }),
+ toggle({
+ issue: disclosuresIssue,
+ id: 'paid-features-disclosure-misused',
+ shown: ({ disclosures }) =>
+ disclosures.some(
+ (disclosure) => disclosure.type === 'paid_features' && !disclosure.deleted_at,
+ ),
+ label: 'Misused',
+ issueListLabel: 'Paid Features',
+ }),
+)
+
+export const telemetryDisclosureReviewPanel = panel({
+ title: 'Telemetry',
+ hint: 'Has this project accurately disclosed this content?',
+ icon: TriangleAlertIcon,
+ guidanceUrl: 'https://www.notion.so/2e15ee711bf080e4a41df61bbab49892',
+}).content(
+ toggle({
+ issue: disclosuresIssue,
+ id: 'telemetry-disclosure-missing',
+ label: 'Missing',
+ issueListLabel: 'Telemetry',
+ }),
+ toggle({
+ issue: disclosuresIssue,
+ id: 'telemetry-disclosure-misused',
+ shown: ({ disclosures }) =>
+ disclosures.some((disclosure) => disclosure.type === 'telemetry' && !disclosure.deleted_at),
+ label: 'Misused',
+ issueListLabel: 'Telemetry',
+ }),
+ section({
+ label: 'What is the telemetry’s consent model?',
+ shown: ({ selected }) => selected.toggleIds.includes('telemetry-disclosure-missing'),
+ }).content(
+ toggle({
+ issue: disclosuresIssue,
+ id: 'telemetry-disclosure-missing-opt-in',
+ label: 'Opt In',
+ issueListLabel: 'Telemetry: Opt In',
+ issueListGroup: 'Missing',
+ disabled: ({ selected }) =>
+ selected.toggleIds.includes('telemetry-disclosure-missing-opt-out') ||
+ selected.toggleIds.includes('telemetry-disclosure-missing-always'),
+ }),
+ toggle({
+ issue: disclosuresIssue,
+ id: 'telemetry-disclosure-missing-opt-out',
+ label: 'Opt Out',
+ issueListLabel: 'Telemetry: Opt Out',
+ issueListGroup: 'Missing',
+ disabled: ({ selected }) =>
+ selected.toggleIds.includes('telemetry-disclosure-missing-opt-in') ||
+ selected.toggleIds.includes('telemetry-disclosure-missing-always'),
+ }),
+ toggle({
+ issue: disclosuresIssue,
+ id: 'telemetry-disclosure-missing-always',
+ label: 'Always Online',
+ issueListLabel: 'Telemetry: Always Online',
+ issueListGroup: 'Missing',
+ disabled: ({ selected }) =>
+ selected.toggleIds.includes('telemetry-disclosure-missing-opt-in') ||
+ selected.toggleIds.includes('telemetry-disclosure-missing-opt-out'),
+ }),
+ ),
+)
+
+export const derivativeContentDisclosureReviewPanel = panel({
+ title: 'Derivative Content',
+ hint: 'Has this project accurately disclosed this content?',
+ icon: TriangleAlertIcon,
+ guidanceUrl: 'https://www.notion.so/2e15ee711bf080e4a41df61bbab49892',
+}).content(
+ toggle({
+ issue: disclosuresIssue,
+ id: 'derivative-content-disclosure-missing',
+ label: 'Missing',
+ issueListLabel: 'Derivative Content',
+ }),
+ toggle({
+ issue: disclosuresIssue,
+ id: 'derivative-content-disclosure-misused',
+ shown: ({ disclosures }) =>
+ disclosures.some(
+ (disclosure) => disclosure.type === 'derivative_work' && !disclosure.deleted_at,
+ ),
+ label: 'Misused',
+ issueListLabel: 'Derivative Content',
+ }),
+)
+
+export const photosensitivityDisclosureReviewPanel = panel({
+ title: 'Photosensitivity',
+ hint: 'Has this project accurately disclosed this content?',
+ icon: TriangleAlertIcon,
+ guidanceUrl: 'https://www.notion.so/2e15ee711bf080e4a41df61bbab49892',
+}).content(
+ toggle({
+ issue: disclosuresIssue,
+ id: 'photosensitivity-disclosure-missing',
+ label: 'Missing',
+ issueListLabel: 'Photosensitivity',
+ }),
+ toggle({
+ issue: disclosuresIssue,
+ id: 'photosensitivity-disclosure-misused',
+ shown: ({ disclosures }) =>
+ disclosures.some(
+ (disclosure) => disclosure.type === 'epilepsy_triggers' && !disclosure.deleted_at,
+ ),
+ label: 'Misused',
+ issueListLabel: 'Photosensitivity',
+ }),
+)
+
+export const systemInteractionsDisclosureReviewPanel = panel({
+ title: 'System Interactions',
+ hint: 'Has this project accurately disclosed this content?',
+ icon: TriangleAlertIcon,
+ guidanceUrl: 'https://www.notion.so/2e15ee711bf080e4a41df61bbab49892',
+}).content(
+ toggle({
+ issue: disclosuresIssue,
+ id: 'system-interactions-disclosure-missing',
+ label: 'Missing',
+ issueListLabel: 'System Interactions',
+ }),
+ toggle({
+ issue: disclosuresIssue,
+ id: 'system-interactions-disclosure-misused',
+ shown: ({ disclosures }) =>
+ disclosures.some(
+ (disclosure) => disclosure.type === 'system_interactions' && !disclosure.deleted_at,
+ ),
+ label: 'Misused',
+ issueListLabel: 'System Interactions',
+ }),
+)
+
+export const archiveDisclosureReviewPanel = panel({
+ title: 'Archive',
+ hint: 'Has this project accurately disclosed this content?',
+ icon: TriangleAlertIcon,
+ guidanceUrl: 'https://www.notion.so/2e15ee711bf080e4a41df61bbab49892',
+}).content(
+ toggle({
+ issue: disclosuresIssue,
+ id: 'archive-disclosure-missing',
+ label: 'Missing',
+ issueListLabel: 'Archive',
+ }),
+ toggle({
+ issue: disclosuresIssue,
+ id: 'archive-disclosure-misused',
+ shown: ({ disclosures }) =>
+ disclosures.some((disclosure) => disclosure.type === 'archived' && !disclosure.deleted_at),
+ label: 'Misused',
+ issueListLabel: 'Archive',
+ }),
+)
+
+export const disclosuresReviewPanel = panel({
+ title: 'Disclosures',
+ hint: 'Has this project selected all proper content disclosures?',
+ icon: TriangleAlertIcon,
+ guidanceUrl: 'https://www.notion.so/2e15ee711bf080e4a41df61bbab49892',
+}).content(
+ toggle({
+ issue: disclosuresIssue,
+ id: 'disclosures-missing',
+ label: 'Missing',
+ }),
+ toggle({
+ issue: disclosuresIssue,
+ id: 'disclosures-misused',
+ shown: ({ disclosures }) => disclosures.some((disclosure) => !disclosure.deleted_at),
+ label: 'Misused',
+ }),
+ toggle({
+ issue: disclosuresNonEnglishIssue,
+ label: 'Non-English',
+ shown: ({ disclosures }) => disclosures.length > 1,
+ }),
+)
diff --git a/packages/moderation/src/data/issues/gallery.ts b/packages/moderation/src/data/issues/gallery.ts
new file mode 100644
index 00000000000..d841bfbed65
--- /dev/null
+++ b/packages/moderation/src/data/issues/gallery.ts
@@ -0,0 +1,105 @@
+import type { Labrinth } from '@modrinth/api-client'
+import { ImageIcon } from '@modrinth/assets'
+import { md } from '@modrinth/utils'
+
+import insufficientMessage from '../messages/checklist/messages/gallery/insufficient.md'
+import notRelevantMessage from '../messages/checklist/messages/gallery/not-relevant.md'
+import showcaseClarityMessage from '../messages/checklist/messages/gallery/showcase-clarity.md'
+import { issue, panel, toggle } from './component-builders/builders'
+import { issueLocation } from './component-builders/locations'
+import { IssuePriority } from './component-builders/priority'
+import { rulesAiImagesIssue } from './rules'
+
+const escapeHtml = md().utils.escapeHtml
+
+export const galleryInsufficientIssue = issue({
+ id: 'gallery-insufficient',
+ title: 'Insufficient gallery images',
+ category: 'Gallery',
+ actions: [
+ ({ projectV3 }) => ({
+ type: 'add_gallery_images',
+ value: { original_count: projectV3.gallery.length },
+ }),
+ ],
+ message: insufficientMessage,
+ suggestedStatus: 'flagged',
+ priority: IssuePriority.Rules + 1,
+})
+
+export const galleryNotRelevantIssue = issue({
+ id: 'gallery-not-relevant',
+ title: 'Irrelevant gallery images',
+ category: 'Gallery',
+ actions: ({ projectV3, selected }) => {
+ const keys = new Set((selected.items['gallery-image'] ?? []).map(({ key }) => key))
+ const imageIds = projectV3.gallery.flatMap((image) =>
+ image.id !== undefined && keys.has(String(image.id)) ? [image.id] : [],
+ )
+ return imageIds.length
+ ? [() => ({ type: 'remove_gallery_images', value: { image_ids: imageIds } })]
+ : []
+ },
+ message: ({ projectV3, selected }) => {
+ const keys = new Set((selected.items['gallery-image'] ?? []).map(({ key }) => key))
+ const entries = projectV3.gallery
+ .filter((image) => image.id !== undefined && keys.has(String(image.id)))
+ .map(
+ (image) =>
+ ` `,
+ )
+ const list = entries.length ? `Please remove the following images:\n${entries.join('\n')}` : ''
+ return notRelevantMessage.replace('%GALLERY_IMAGES%', list).trimEnd()
+ },
+ suggestedStatus: 'flagged',
+})
+
+export const galleryShowcaseClarityIssue = issue({
+ id: 'gallery-showcase-clarity',
+ locations: [issueLocation('gallery')],
+ title: 'Unclear gallery showcase',
+ category: 'Gallery',
+ message: showcaseClarityMessage,
+ suggestedStatus: 'rejected',
+ priority: IssuePriority.Rules,
+})
+
+export function galleryImageReviewPanel(
+ image: Labrinth.Projects.v3.GalleryItem,
+ imageNumber: number,
+) {
+ if (!image.id) throw new Error('Gallery image review requires an image ID')
+
+ return panel({
+ parent: 'gallery',
+ title: image.name ?? `Gallery image ${imageNumber}`,
+ hint: "Are this project's gallery images sufficient?",
+ icon: ImageIcon,
+ guidanceUrl:
+ 'https://www.notion.so/2e15ee711bf080e4a41df61bbab49892#2e15ee711bf08096828bd1c3f24d8b8e',
+ }).content(
+ toggle({
+ issue: galleryNotRelevantIssue,
+ label: 'Not relevant',
+ }),
+
+ toggle({
+ issue: rulesAiImagesIssue,
+ label: 'AI Images',
+ }),
+ )
+}
+
+export const galleryReviewPanel = panel({
+ title: 'Gallery',
+ hint: "Are this project's gallery images sufficient?",
+ icon: ImageIcon,
+ guidanceUrl:
+ 'https://www.notion.so/2e15ee711bf080e4a41df61bbab49892#2e15ee711bf08096828bd1c3f24d8b8e',
+}).content(
+ toggle({ issue: galleryInsufficientIssue, label: 'Insufficient' }),
+ toggle({
+ issue: galleryShowcaseClarityIssue,
+ label: 'Showcase Clarity',
+ }),
+)
diff --git a/packages/moderation/src/data/issues/icon.ts b/packages/moderation/src/data/issues/icon.ts
new file mode 100644
index 00000000000..8d8cdc9bd99
--- /dev/null
+++ b/packages/moderation/src/data/issues/icon.ts
@@ -0,0 +1,19 @@
+import { ImageIcon } from '@modrinth/assets'
+
+import { panel, toggle } from './component-builders/builders'
+import { rulesAiImagesIssue } from './rules'
+
+export const iconReviewPanel = panel({
+ title: 'Icon',
+ hint: "Is this project's icon appropriate?",
+ icon: ImageIcon,
+ guidanceUrl:
+ 'https://www.notion.so/2e15ee711bf080e4a41df61bbab49892#2e35ee711bf080709084f6269835607f',
+}).content(
+ toggle({
+ issue: rulesAiImagesIssue,
+ id: 'icon-ai-images',
+ label: 'AI Images',
+ shown: ({ projectV3 }) => !!projectV3.icon_url,
+ }),
+)
diff --git a/packages/moderation/src/data/issues/index.ts b/packages/moderation/src/data/issues/index.ts
new file mode 100644
index 00000000000..ecbd1e860e0
--- /dev/null
+++ b/packages/moderation/src/data/issues/index.ts
@@ -0,0 +1,115 @@
+import { categoriesReviewPanel } from './categories'
+import { each } from './component-builders/builders'
+import type { PanelRegistration } from './component-builders/types'
+import { descriptionReviewPanel } from './description'
+import {
+ adsDisclosureReviewPanel,
+ aiDisclosureReviewPanel,
+ aiFunctionalityDisclosureReviewPanel,
+ archiveDisclosureReviewPanel,
+ derivativeContentDisclosureReviewPanel,
+ disclosuresReviewPanel,
+ paidFeaturesDisclosureReviewPanel,
+ photosensitivityDisclosureReviewPanel,
+ systemInteractionsDisclosureReviewPanel,
+ telemetryDisclosureReviewPanel,
+} from './disclosures'
+import { galleryImageReviewPanel, galleryReviewPanel } from './gallery'
+import { iconReviewPanel } from './icon'
+import { licenseReviewPanel } from './license'
+import {
+ bmacReviewPanel,
+ discordReviewPanel,
+ githubReviewPanel,
+ issuesReviewPanel,
+ koFiReviewPanel,
+ otherReviewPanel,
+ patreonReviewPanel,
+ paypalReviewPanel,
+ siteReviewPanel,
+ sourceReviewPanel,
+ storeReviewPanel,
+ wikiReviewPanel,
+} from './links'
+import { metadataReviewPanel } from './metadata'
+import { permissionsReviewPanel } from './permissions'
+import { postApprovalReviewPanel } from './post-approval'
+import { reReviewReviewPanel } from './re-review'
+import { reuploadReviewPanel } from './reupload'
+import { rulesReviewPanel } from './rules'
+import { serverReviewPanel } from './server'
+import { slugReviewPanel } from './slug'
+import { summaryReviewPanel } from './summary'
+import { titleReviewPanel } from './title'
+import { undefinedProjectReviewPanel } from './undefined-project'
+import { versionsReviewPanel } from './versions'
+
+export const reviewPanels = {
+ title: titleReviewPanel,
+ slug: slugReviewPanel,
+ summary: summaryReviewPanel,
+ description: descriptionReviewPanel,
+ 'issues-link': issuesReviewPanel,
+ 'source-link': sourceReviewPanel,
+ 'wiki-link': wikiReviewPanel,
+ 'discord-link': discordReviewPanel,
+ 'site-link': siteReviewPanel,
+ 'store-link': storeReviewPanel,
+ 'patreon-link': patreonReviewPanel,
+ 'bmac-link': bmacReviewPanel,
+ 'paypal-link': paypalReviewPanel,
+ 'github-link': githubReviewPanel,
+ 'ko-fi-link': koFiReviewPanel,
+ 'other-link': otherReviewPanel,
+ categories: categoriesReviewPanel,
+ disclosures: disclosuresReviewPanel,
+ 'ai-disclosure': aiDisclosureReviewPanel,
+ 'ai-functionality-disclosure': aiFunctionalityDisclosureReviewPanel,
+ 'ads-disclosure': adsDisclosureReviewPanel,
+ 'paid-features-disclosure': paidFeaturesDisclosureReviewPanel,
+ 'telemetry-disclosure': telemetryDisclosureReviewPanel,
+ 'derivative-content-disclosure': derivativeContentDisclosureReviewPanel,
+ 'photosensitivity-disclosure': photosensitivityDisclosureReviewPanel,
+ 'system-interactions-disclosure': systemInteractionsDisclosureReviewPanel,
+ 'archive-disclosure': archiveDisclosureReviewPanel,
+ gallery: galleryReviewPanel,
+ 'gallery-image': each(({ projectV3 }) =>
+ projectV3.gallery
+ .filter((image) => image.id !== undefined)
+ .map((image, index) => ({
+ key: String(image.id),
+ panel: galleryImageReviewPanel(image, index + 1),
+ })),
+ ),
+ icon: iconReviewPanel,
+ license: licenseReviewPanel,
+ metadata: metadataReviewPanel,
+ server: serverReviewPanel,
+ permissions: permissionsReviewPanel,
+ 're-review': reReviewReviewPanel,
+ misc: [
+ {
+ key: 'reupload',
+ label: 'Reupload',
+ panel: reuploadReviewPanel,
+ },
+ {
+ key: 'rules',
+ label: 'Rule Following',
+ panel: rulesReviewPanel,
+ },
+ {
+ key: 'post-approval',
+ label: 'Post-Approval',
+ panel: postApprovalReviewPanel,
+ },
+ ],
+ 'undefined-project': undefinedProjectReviewPanel,
+ versions: versionsReviewPanel,
+} satisfies Record
+
+export { IssuePriority } from './component-builders/priority'
+
+export type MiscReviewPanelKey = (typeof reviewPanels.misc)[number]['key']
+
+export type ReviewPanelKey = Exclude | MiscReviewPanelKey
diff --git a/packages/moderation/src/data/issues/license.ts b/packages/moderation/src/data/issues/license.ts
new file mode 100644
index 00000000000..1024a50d956
--- /dev/null
+++ b/packages/moderation/src/data/issues/license.ts
@@ -0,0 +1,82 @@
+import { BookTextIcon } from '@modrinth/assets'
+
+import { promptSourceRequired } from '../../utils'
+import invalidLinkMessage from '../messages/checklist/messages/license/invalid-link.md'
+import invalidLinkCustomLicenseMessage from '../messages/checklist/messages/license/invalid-link/custom-license.md'
+import noSourceMessage from '../messages/checklist/messages/license/no-source.md'
+import noSourceForkMessage from '../messages/checklist/messages/license/no-source-fork.md'
+import { issue, panel, section, toggle } from './component-builders/builders'
+import { issueLocation } from './component-builders/locations'
+import { issueTargets } from './component-builders/targets'
+
+export const licenseInvalidLinkIssue = issue({
+ id: 'license-invalid-link',
+ title: 'Invalid license link',
+ category: 'License',
+ actions: [issueTargets.modifyLicense()],
+ message: ({ selected }) =>
+ [
+ invalidLinkMessage,
+ selected.toggleIds.includes('license-custom-license') ? invalidLinkCustomLicenseMessage : '',
+ ].join('\n'),
+ suggestedStatus: 'flagged',
+})
+
+export const licenseNoSourceIssue = issue({
+ id: 'license-no-source',
+ locations: [issueLocation('links'), issueLocation('versions'), issueLocation('license')],
+ title: 'Missing source code',
+ category: 'License',
+ message: ({ selected }) =>
+ selected.toggleIds.includes('license-fork') ? noSourceForkMessage : noSourceMessage,
+ suggestedStatus: 'rejected',
+})
+
+export const licenseReviewPanel = panel({
+ title: 'License',
+ hint: 'Is this license and link valid?',
+ icon: BookTextIcon,
+ guidanceUrl:
+ 'https://www.notion.so/2e15ee711bf080e4a41df61bbab49892#2e15ee711bf080f8805df7d012a8f770',
+}).content(
+ toggle({
+ issue: licenseInvalidLinkIssue,
+ label: 'Invalid Link',
+ shown: (ctx) => !ctx.projectV3.minecraft_server && !!ctx.projectV3.license.url,
+ }),
+ section({
+ shown: (ctx) =>
+ !ctx.projectV3.minecraft_server &&
+ ctx.selected.issueIds.includes(licenseInvalidLinkIssue.id) &&
+ !!ctx.projectV3.license.url,
+ }).content(
+ toggle({
+ shown: (ctx) => !ctx.projectV3.minecraft_server,
+ issue: licenseInvalidLinkIssue,
+ label: 'Invalid Link: Custom License',
+ id: 'license-custom-license',
+ }),
+ ),
+ section({
+ shown: (ctx) =>
+ !ctx.projectV3.minecraft_server &&
+ promptSourceRequired(ctx.projectV3.license.id, ctx.projectV3.project_types),
+ }).content(
+ toggle({
+ shown: (ctx) => !ctx.projectV3.minecraft_server,
+ issue: licenseNoSourceIssue,
+ label: 'No Source',
+ }),
+ section({
+ shown: (ctx) =>
+ !ctx.projectV3.minecraft_server && ctx.selected.issueIds.includes(licenseNoSourceIssue.id),
+ }).content(
+ toggle({
+ shown: (ctx) => !ctx.projectV3.minecraft_server,
+ issue: licenseNoSourceIssue,
+ label: 'No Source: Fork',
+ id: 'license-fork',
+ }),
+ ),
+ ),
+)
diff --git a/packages/moderation/src/data/issues/links.ts b/packages/moderation/src/data/issues/links.ts
new file mode 100644
index 00000000000..efdbae8fe25
--- /dev/null
+++ b/packages/moderation/src/data/issues/links.ts
@@ -0,0 +1,403 @@
+import { LinkIcon } from '@modrinth/assets'
+
+import discordExpiring from '../messages/checklist/messages/links/discord/expiring.md'
+import discordInaccessible from '../messages/checklist/messages/links/discord/inaccessible.md'
+import linksHeader from '../messages/checklist/messages/links/header.md'
+import inaccessibleHeader from '../messages/checklist/messages/links/inaccessible-header.md'
+import issuesDisabled from '../messages/checklist/messages/links/issues/disabled.md'
+import misusedHeader from '../messages/checklist/messages/links/misused-header.md'
+import sourceEmpty from '../messages/checklist/messages/links/source/empty.md'
+import sourceInaccessible from '../messages/checklist/messages/links/source/inaccessible.md'
+import wikiDisabled from '../messages/checklist/messages/links/wiki/disabled.md'
+import { issue, panel, toggle } from './component-builders/builders'
+import { issueTargets } from './component-builders/targets'
+
+export const linksIssue = issue({
+ id: 'links',
+ title: 'Invalid project links',
+ category: 'Links',
+ actions: [issueTargets.modifySelectedLinks()],
+ suggestedStatus: 'flagged',
+ message: ({ projectV3, selected }) => {
+ const toggleIds = new Set(selected.toggleIds)
+ const inaccessibleItems: string[] = []
+ const misusedItems: string[] = []
+
+ for (const type of [
+ 'source',
+ 'discord',
+ 'issues',
+ 'wiki',
+ 'site',
+ 'store',
+ 'patreon',
+ 'bmac',
+ 'paypal',
+ 'github',
+ 'ko-fi',
+ 'other',
+ ] as const) {
+ const field = `${type}-link`
+ let inaccessibleSelected = toggleIds.has(`${field}:inaccessible`)
+ const misusedSelected = toggleIds.has(`${field}:misused`)
+ const notes: string[] = []
+ let label: string
+
+ switch (type) {
+ case 'source':
+ label = 'Source code'
+ if (inaccessibleSelected) notes.push(sourceInaccessible)
+ if (toggleIds.has('source-link:empty')) {
+ inaccessibleSelected = true
+ notes.push(sourceEmpty)
+ }
+ break
+ case 'discord':
+ label = 'Discord invite'
+ if (inaccessibleSelected) notes.push(discordInaccessible)
+ if (toggleIds.has('discord-link:expiring')) {
+ inaccessibleSelected = true
+ notes.push(discordExpiring)
+ }
+ break
+ case 'issues':
+ label = 'Issue tracker'
+ if (toggleIds.has('issues-link:disabled')) {
+ inaccessibleSelected = true
+ notes.push(issuesDisabled)
+ }
+ break
+ case 'wiki':
+ label = 'Wiki page'
+ if (toggleIds.has('wiki-link:disabled')) {
+ inaccessibleSelected = true
+ notes.push(wikiDisabled)
+ }
+ break
+ case 'site':
+ label = 'Website'
+ break
+ case 'store':
+ label = 'Store'
+ break
+ case 'patreon':
+ label = 'Patreon'
+ break
+ case 'bmac':
+ label = 'Buy Me A Coffee'
+ break
+ case 'paypal':
+ label = 'PayPal'
+ break
+ case 'github':
+ label = 'GitHub Sponsors'
+ break
+ case 'ko-fi':
+ label = 'Ko-fi'
+ break
+ case 'other':
+ label = 'Other'
+ break
+ default:
+ continue
+ }
+
+ if (!inaccessibleSelected && !misusedSelected) continue
+ const url = projectV3.link_urls[type]?.url
+ // "- Source code: `https://github.com/owner/repo`" or "- Source code"
+ const item = url ? `- ${label}: \`${url}\`` : `- ${label}`
+ if (inaccessibleSelected) {
+ const uniqueNotes = [...new Set(notes.map((note) => note.trim()).filter(Boolean))]
+ inaccessibleItems.push([item, ...uniqueNotes].join('\n - '))
+ }
+ if (misusedSelected) misusedItems.push(item)
+ }
+
+ const sections: string[] = []
+ if (inaccessibleItems.length) {
+ sections.push(inaccessibleHeader.trim(), inaccessibleItems.join('\n'))
+ }
+ if (misusedItems.length) {
+ sections.push(misusedHeader.trim(), misusedItems.join('\n'))
+ }
+ return sections.length ? [linksHeader.trim(), ...sections].join('\n\n') : ''
+ },
+})
+
+function isHttpUrl(value: string | undefined): boolean {
+ if (!value) return false
+ try {
+ const url = new URL(value)
+ return url.protocol === 'https:' || url.protocol === 'http:'
+ } catch {
+ return false
+ }
+}
+
+export const issuesReviewPanel = panel({
+ title: 'Issue tracker',
+ hint: "Is the project's link accurate and accessible?",
+ icon: LinkIcon,
+ guidanceUrl:
+ 'https://www.notion.so/2e15ee711bf080e4a41df61bbab49892#2e15ee711bf08013b36cd75cbf1a9177',
+ shown: ({ projectV3 }) => isHttpUrl(projectV3.link_urls.issues?.url),
+}).content(
+ toggle({
+ issue: linksIssue,
+ id: 'issues-link:misused',
+ label: 'Misused',
+ }),
+ toggle({
+ issue: linksIssue,
+ id: 'issues-link:inaccessible',
+ label: 'Inaccessible',
+ }),
+ toggle({
+ issue: linksIssue,
+ id: 'issues-link:disabled',
+ label: 'Disabled',
+ issueListGroup: 'Inaccessible',
+ issueListLabel: 'Issues: Disabled',
+ }),
+)
+
+export const sourceReviewPanel = panel({
+ title: 'Source code',
+ hint: "Is the project's link accurate and accessible?",
+ icon: LinkIcon,
+ guidanceUrl:
+ 'https://www.notion.so/2e15ee711bf080e4a41df61bbab49892#2e15ee711bf08013b36cd75cbf1a9177',
+ shown: ({ projectV3 }) => isHttpUrl(projectV3.link_urls.source?.url),
+}).content(
+ toggle({
+ issue: linksIssue,
+ id: 'source-link:misused',
+ label: 'Misused',
+ }),
+ toggle({
+ issue: linksIssue,
+ id: 'source-link:inaccessible',
+ label: 'Inaccessible',
+ }),
+ toggle({
+ issue: linksIssue,
+ id: 'source-link:empty',
+ label: 'Empty Repo',
+ issueListGroup: 'Inaccessible',
+ issueListLabel: 'Source: Empty Repo',
+ }),
+)
+
+export const wikiReviewPanel = panel({
+ title: 'Wiki page',
+ hint: "Is the project's link accurate and accessible?",
+ icon: LinkIcon,
+ guidanceUrl:
+ 'https://www.notion.so/2e15ee711bf080e4a41df61bbab49892#2e15ee711bf08013b36cd75cbf1a9177',
+ shown: ({ projectV3 }) => isHttpUrl(projectV3.link_urls.wiki?.url),
+}).content(
+ toggle({
+ issue: linksIssue,
+ id: 'wiki-link:misused',
+ label: 'Misused',
+ }),
+ toggle({
+ issue: linksIssue,
+ id: 'wiki-link:inaccessible',
+ label: 'Inaccessible',
+ }),
+ toggle({
+ issue: linksIssue,
+ id: 'wiki-link:disabled',
+ label: 'Disabled',
+ issueListGroup: 'Inaccessible',
+ issueListLabel: 'Wiki: Disabled',
+ }),
+)
+
+export const discordReviewPanel = panel({
+ title: 'Discord invite',
+ hint: "Is the project's link accurate and accessible?",
+ icon: LinkIcon,
+ guidanceUrl:
+ 'https://www.notion.so/2e15ee711bf080e4a41df61bbab49892#2e15ee711bf08013b36cd75cbf1a9177',
+ shown: ({ projectV3 }) => isHttpUrl(projectV3.link_urls.discord?.url),
+}).content(
+ toggle({
+ issue: linksIssue,
+ id: 'discord-link:misused',
+ label: 'Misused',
+ }),
+ toggle({
+ issue: linksIssue,
+ id: 'discord-link:inaccessible',
+ label: 'Inaccessible',
+ }),
+ toggle({
+ issue: linksIssue,
+ id: 'discord-link:expiring',
+ label: 'Expiring',
+ issueListGroup: 'Inaccessible',
+ issueListLabel: 'Discord: Expiring',
+ }),
+)
+
+export const siteReviewPanel = panel({
+ title: 'Website',
+ hint: "Is the project's link accurate and accessible?",
+ icon: LinkIcon,
+ guidanceUrl:
+ 'https://www.notion.so/2e15ee711bf080e4a41df61bbab49892#2e15ee711bf08013b36cd75cbf1a9177',
+ shown: ({ projectV3 }) => isHttpUrl(projectV3.link_urls.site?.url),
+}).content(
+ toggle({
+ issue: linksIssue,
+ id: 'site-link:misused',
+ label: 'Misused',
+ }),
+ toggle({
+ issue: linksIssue,
+ id: 'site-link:inaccessible',
+ label: 'Inaccessible',
+ }),
+)
+
+export const storeReviewPanel = panel({
+ title: 'Store',
+ hint: "Is the project's link accurate and accessible?",
+ icon: LinkIcon,
+ guidanceUrl:
+ 'https://www.notion.so/2e15ee711bf080e4a41df61bbab49892#2e15ee711bf08013b36cd75cbf1a9177',
+ shown: ({ projectV3 }) => isHttpUrl(projectV3.link_urls.store?.url),
+}).content(
+ toggle({
+ issue: linksIssue,
+ id: 'store-link:misused',
+ label: 'Misused',
+ }),
+ toggle({
+ issue: linksIssue,
+ id: 'store-link:inaccessible',
+ label: 'Inaccessible',
+ }),
+)
+
+export const patreonReviewPanel = panel({
+ title: 'Patreon',
+ hint: "Is the project's link accurate and accessible?",
+ icon: LinkIcon,
+ guidanceUrl:
+ 'https://www.notion.so/2e15ee711bf080e4a41df61bbab49892#2e15ee711bf08013b36cd75cbf1a9177',
+ shown: ({ projectV3 }) => isHttpUrl(projectV3.link_urls.patreon?.url),
+}).content(
+ toggle({
+ issue: linksIssue,
+ id: 'patreon-link:misused',
+ label: 'Misused',
+ }),
+ toggle({
+ issue: linksIssue,
+ id: 'patreon-link:inaccessible',
+ label: 'Inaccessible',
+ }),
+)
+
+export const bmacReviewPanel = panel({
+ title: 'Buy Me A Coffee',
+ hint: "Is the project's link accurate and accessible?",
+ icon: LinkIcon,
+ guidanceUrl:
+ 'https://www.notion.so/2e15ee711bf080e4a41df61bbab49892#2e15ee711bf08013b36cd75cbf1a9177',
+ shown: ({ projectV3 }) => isHttpUrl(projectV3.link_urls.bmac?.url),
+}).content(
+ toggle({
+ issue: linksIssue,
+ id: 'bmac-link:misused',
+ label: 'Misused',
+ }),
+ toggle({
+ issue: linksIssue,
+ id: 'bmac-link:inaccessible',
+ label: 'Inaccessible',
+ }),
+)
+
+export const paypalReviewPanel = panel({
+ title: 'PayPal',
+ hint: "Is the project's link accurate and accessible?",
+ icon: LinkIcon,
+ guidanceUrl:
+ 'https://www.notion.so/2e15ee711bf080e4a41df61bbab49892#2e15ee711bf08013b36cd75cbf1a9177',
+ shown: ({ projectV3 }) => isHttpUrl(projectV3.link_urls.paypal?.url),
+}).content(
+ toggle({
+ issue: linksIssue,
+ id: 'paypal-link:misused',
+ label: 'Misused',
+ }),
+ toggle({
+ issue: linksIssue,
+ id: 'paypal-link:inaccessible',
+ label: 'Inaccessible',
+ }),
+)
+
+export const githubReviewPanel = panel({
+ title: 'GitHub Sponsors',
+ hint: "Is the project's link accurate and accessible?",
+ icon: LinkIcon,
+ guidanceUrl:
+ 'https://www.notion.so/2e15ee711bf080e4a41df61bbab49892#2e15ee711bf08013b36cd75cbf1a9177',
+ shown: ({ projectV3 }) => isHttpUrl(projectV3.link_urls.github?.url),
+}).content(
+ toggle({
+ issue: linksIssue,
+ id: 'github-link:misused',
+ label: 'Misused',
+ }),
+ toggle({
+ issue: linksIssue,
+ id: 'github-link:inaccessible',
+ label: 'Inaccessible',
+ }),
+)
+
+export const koFiReviewPanel = panel({
+ title: 'Ko-fi',
+ hint: "Is the project's link accurate and accessible?",
+ icon: LinkIcon,
+ guidanceUrl:
+ 'https://www.notion.so/2e15ee711bf080e4a41df61bbab49892#2e15ee711bf08013b36cd75cbf1a9177',
+ shown: ({ projectV3 }) => isHttpUrl(projectV3.link_urls['ko-fi']?.url),
+}).content(
+ toggle({
+ issue: linksIssue,
+ id: 'ko-fi-link:misused',
+ label: 'Misused',
+ }),
+ toggle({
+ issue: linksIssue,
+ id: 'ko-fi-link:inaccessible',
+ label: 'Inaccessible',
+ }),
+)
+
+export const otherReviewPanel = panel({
+ title: 'Other',
+ hint: "Is the project's link accurate and accessible?",
+ icon: LinkIcon,
+ guidanceUrl:
+ 'https://www.notion.so/2e15ee711bf080e4a41df61bbab49892#2e15ee711bf08013b36cd75cbf1a9177',
+ shown: ({ projectV3 }) => isHttpUrl(projectV3.link_urls.other?.url),
+}).content(
+ toggle({
+ issue: linksIssue,
+ id: 'other-link:misused',
+ label: 'Misused',
+ }),
+ toggle({
+ issue: linksIssue,
+ id: 'other-link:inaccessible',
+ label: 'Inaccessible',
+ }),
+)
diff --git a/packages/moderation/src/data/issues/metadata.ts b/packages/moderation/src/data/issues/metadata.ts
new file mode 100644
index 00000000000..f29396ee6a6
--- /dev/null
+++ b/packages/moderation/src/data/issues/metadata.ts
@@ -0,0 +1,102 @@
+import type { Labrinth } from '@modrinth/api-client'
+import { DatabaseIcon } from '@modrinth/assets'
+import { ENVIRONMENTS_COPY } from '@modrinth/ui'
+
+import { requiresEnvironmentInfo } from '../../utils'
+import environmentCorrectionMessage from '../messages/checklist/messages/metadata/environment/correction.md'
+import environmentInaccurateMessage from '../messages/checklist/messages/metadata/environment/inaccurate.md'
+import environmentMixedMessage from '../messages/checklist/messages/metadata/environment/mixed.md'
+import gameVersionsMessage from '../messages/checklist/messages/metadata/game-versions.md'
+import loadersMessage from '../messages/checklist/messages/metadata/loaders.md'
+import { issue, panel, section, select, toggle } from './component-builders/builders'
+import { issueLocation } from './component-builders/locations'
+
+const environments = Object.keys(ENVIRONMENTS_COPY).filter(
+ (id) => id !== 'unknown',
+) as Labrinth.Projects.v3.Environment[]
+
+export const metadataEnvironmentIssue = issue({
+ id: 'metadata-environment',
+ locations: [issueLocation('versions')],
+ title: 'Incorrect environment',
+ category: 'Metadata',
+ message: ({ getSelectValue }) => {
+ const environment = getSelectValue('metadata-correct-environment')
+ const correction =
+ environment === 'mixed'
+ ? environmentMixedMessage
+ : environment !== 'unknown' && Object.hasOwn(ENVIRONMENTS_COPY, environment)
+ ? environmentCorrectionMessage
+ .replaceAll(
+ '%SUGGESTED_ENVIRONMENT%',
+ () =>
+ ENVIRONMENTS_COPY[environment as Labrinth.Projects.v3.Environment].title
+ .defaultMessage ?? environment,
+ )
+ .replaceAll('%SUGGESTED_ENVIRONMENT_ID%', () => encodeURIComponent(environment))
+ : ''
+ return environmentInaccurateMessage.replaceAll('%CORRECT%', () => correction)
+ },
+ suggestedStatus: 'flagged',
+})
+
+export const metadataGameVersionsIssue = issue({
+ id: 'metadata-game-versions',
+ locations: [issueLocation('versions')],
+ title: 'Incorrect game versions',
+ category: 'Metadata',
+ message: gameVersionsMessage,
+ suggestedStatus: 'flagged',
+})
+
+export const metadataLoadersIssue = issue({
+ id: 'metadata-loaders',
+ locations: [issueLocation('versions')],
+ title: 'Incorrect loaders',
+ category: 'Metadata',
+ message: loadersMessage,
+ suggestedStatus: 'rejected',
+})
+
+export const metadataReviewPanel = panel({
+ title: 'Metadata',
+ hint: "Are there any issues with this project's metadata?",
+ icon: DatabaseIcon,
+ guidanceUrl:
+ 'https://www.notion.so/2e15ee711bf080e4a41df61bbab49892#2e25ee711bf0802d9a9bdb82dce040eb',
+ shown: ({ projectV3 }) => !projectV3.minecraft_server,
+}).content(
+ toggle({
+ issue: metadataGameVersionsIssue,
+ label: 'Game Versions',
+ }),
+ toggle({
+ issue: metadataLoadersIssue,
+ label: 'Loaders',
+ }),
+ section({
+ shown: ({ projectV3 }) => requiresEnvironmentInfo(projectV3.project_types),
+ }).content(
+ toggle({
+ issue: metadataEnvironmentIssue,
+ label: 'Environment',
+ }),
+ section({
+ shown: (ctx) => ctx.selected.issueIds.includes(metadataEnvironmentIssue.id),
+ }).content(
+ select({
+ issue: metadataEnvironmentIssue,
+ id: 'metadata-correct-environment',
+ label: 'Correct Environment',
+ placeholder: 'Unknown',
+ options: [
+ ...environments.map((value) => ({
+ value,
+ label: ENVIRONMENTS_COPY[value].title.defaultMessage ?? value,
+ })),
+ { value: 'mixed', label: 'Mixed' },
+ ],
+ }),
+ ),
+ ),
+)
diff --git a/packages/moderation/src/data/issues/permissions.ts b/packages/moderation/src/data/issues/permissions.ts
new file mode 100644
index 00000000000..efadb5750c7
--- /dev/null
+++ b/packages/moderation/src/data/issues/permissions.ts
@@ -0,0 +1,74 @@
+import { SignatureIcon } from '@modrinth/assets'
+
+import invalidPermissionsMessage from '../messages/checklist/messages/permissions/invalid-permissions.md'
+import missingPermissionsMessage from '../messages/checklist/messages/permissions/missing-permissions.md'
+import nonCommercialExternalContentMessage from '../messages/checklist/messages/permissions/non-commercial-external-content.md'
+import prohibitedExternalContentMessage from '../messages/checklist/messages/permissions/prohibited-external-content.md'
+import { issue, panel, toggle } from './component-builders/builders'
+import { issueLocation } from './component-builders/locations'
+
+export const permissionsInvalidPermissionsIssue = issue({
+ id: 'permissions-invalid-permissions',
+ locations: [issueLocation('permissions')],
+ title: 'Invalid permissions',
+ category: 'Permissions',
+ message: invalidPermissionsMessage,
+ suggestedStatus: 'rejected',
+})
+
+export const permissionsProhibitedExternalContentIssue = issue({
+ id: 'permissions-prohibited-external-content',
+ locations: [issueLocation('permissions')],
+ title: 'Prohibited external content',
+ category: 'Permissions',
+ message: prohibitedExternalContentMessage,
+ suggestedStatus: 'rejected',
+})
+
+export const permissionsMissingPermissionsIssue = issue({
+ id: 'permissions-missing-permissions',
+ locations: [issueLocation('permissions')],
+ title: 'Missing permissions',
+ category: 'Permissions',
+ message: missingPermissionsMessage,
+ suggestedStatus: 'rejected',
+})
+
+export const permissionsNonCommercialExternalContentIssue = issue({
+ id: 'permissions-non-commercial-external-content',
+ locations: [issueLocation('permissions')],
+ title: 'Non-commercial external content',
+ category: 'Permissions',
+ message: nonCommercialExternalContentMessage,
+ suggestedStatus: 'rejected',
+})
+
+export const permissionsReviewPanel = panel({
+ title: 'Modpack Permissions',
+ hint: "Does this project's external content have any issues?",
+ icon: SignatureIcon,
+ guidanceUrl: 'https://www.notion.so/2e15ee711bf080e4a41df61bbab49892',
+ shown: ({ projectV3, permissions }) =>
+ projectV3.project_types.includes('modpack') &&
+ !projectV3.minecraft_server &&
+ permissions.loaded &&
+ permissions.unresolvedCount > 0,
+}).content(
+ toggle({
+ issue: permissionsInvalidPermissionsIssue,
+ label: 'Invalid permissions',
+ }),
+ toggle({
+ issue: permissionsProhibitedExternalContentIssue,
+ label: 'Prohibited externals',
+ }),
+ toggle({
+ issue: permissionsMissingPermissionsIssue,
+ label: 'Missing permissions',
+ }),
+ toggle({
+ issue: permissionsNonCommercialExternalContentIssue,
+ label: 'Non-commercial externals',
+ shown: ({ projectV3 }) => projectV3.monetization_status === 'monetized',
+ }),
+)
diff --git a/packages/moderation/src/data/issues/post-approval.ts b/packages/moderation/src/data/issues/post-approval.ts
new file mode 100644
index 00000000000..6c174449e6b
--- /dev/null
+++ b/packages/moderation/src/data/issues/post-approval.ts
@@ -0,0 +1,109 @@
+import { ScaleIcon } from '@modrinth/assets'
+
+import metadataIssueMessage from '../messages/checklist/messages/post-approval/metadata-issue.md'
+import metadataIssueDependenciesMessage from '../messages/checklist/messages/post-approval/metadata-issue/dependencies.md'
+import metadataIssueLicenseMessage from '../messages/checklist/messages/post-approval/metadata-issue/license.md'
+import metadataIssueLoadersMessage from '../messages/checklist/messages/post-approval/metadata-issue/loaders.md'
+import metadataIssueMcVersionsMessage from '../messages/checklist/messages/post-approval/metadata-issue/mc-versions.md'
+import { issue, panel, section, text, toggle } from './component-builders/builders'
+
+export const postApprovalMetadataIssueIssue = issue({
+ id: 'post-approval-metadata-issue',
+ title: 'Post-approval metadata issues',
+ category: 'Project wide',
+ message: ({ selected, getTextValue }) =>
+ [
+ metadataIssueMessage,
+ selected.toggleIds.includes('post-approval-dependencies')
+ ? metadataIssueDependenciesMessage
+ .replaceAll('%DEPENDENCY_NAME%', () => getTextValue('post-approval-name'))
+ .replaceAll('%DEPENDENCY_LINK%', () => getTextValue('post-approval-link'))
+ : '',
+ selected.toggleIds.includes('post-approval-mc-versions')
+ ? metadataIssueMcVersionsMessage.replaceAll('%SPECIFICS%', () =>
+ getTextValue('post-approval-mc-versions-specifics'),
+ )
+ : '',
+ selected.toggleIds.includes('post-approval-loaders')
+ ? metadataIssueLoadersMessage.replaceAll('%SPECIFICS%', () =>
+ getTextValue('post-approval-loaders-specifics'),
+ )
+ : '',
+ selected.toggleIds.includes('post-approval-license') ? metadataIssueLicenseMessage : '',
+ ].join('\n'),
+ suggestedStatus: 'approved',
+})
+
+export const postApprovalReviewPanel = panel({
+ title: 'Post-Approval',
+ hint: 'Issue warnings, notices, or takedowns?',
+ icon: ScaleIcon,
+ guidanceUrl:
+ 'https://www.notion.so/2e15ee711bf080e4a41df61bbab49892#3475ee711bf080c5a13cda0b1e4ae9ed',
+ shown: ({ projectV3 }) => projectV3.status === 'approved',
+}).content(
+ toggle({
+ issue: postApprovalMetadataIssueIssue,
+ label: 'Incorrect metadata',
+ }),
+ section({
+ shown: (ctx) => ctx.selected.issueIds.includes(postApprovalMetadataIssueIssue.id),
+ }).content(
+ toggle({
+ issue: postApprovalMetadataIssueIssue,
+ label: 'Missing Dependencies',
+ id: 'post-approval-dependencies',
+ }),
+ toggle({
+ issue: postApprovalMetadataIssueIssue,
+ label: 'Game versions',
+ id: 'post-approval-mc-versions',
+ }),
+ toggle({
+ issue: postApprovalMetadataIssueIssue,
+ label: 'Loaders',
+ id: 'post-approval-loaders',
+ }),
+ section({
+ shown: ({ selected }) => selected.toggleIds.includes('post-approval-dependencies'),
+ }).content(
+ text({
+ issue: postApprovalMetadataIssueIssue,
+ id: 'post-approval-name',
+ label: 'Dependency name',
+ required: true,
+ }),
+ text({
+ issue: postApprovalMetadataIssueIssue,
+ id: 'post-approval-link',
+ label: 'Dependency link',
+ required: true,
+ }),
+ ),
+ section({
+ shown: ({ selected }) => selected.toggleIds.includes('post-approval-mc-versions'),
+ }).content(
+ text({
+ issue: postApprovalMetadataIssueIssue,
+ id: 'post-approval-mc-versions-specifics',
+ label: 'More details about the game versions issue?',
+ required: false,
+ }),
+ ),
+ section({
+ shown: ({ selected }) => selected.toggleIds.includes('post-approval-loaders'),
+ }).content(
+ text({
+ issue: postApprovalMetadataIssueIssue,
+ id: 'post-approval-loaders-specifics',
+ label: 'More details about the loaders issue?',
+ required: false,
+ }),
+ ),
+ toggle({
+ issue: postApprovalMetadataIssueIssue,
+ label: 'Inconsistent Licensing',
+ id: 'post-approval-license',
+ }),
+ ),
+)
diff --git a/packages/moderation/src/data/issues/priorities.ts b/packages/moderation/src/data/issues/priorities.ts
new file mode 100644
index 00000000000..95be33cf217
--- /dev/null
+++ b/packages/moderation/src/data/issues/priorities.ts
@@ -0,0 +1 @@
+export { IssuePriority } from './component-builders/priority'
diff --git a/packages/moderation/src/data/issues/re-review.ts b/packages/moderation/src/data/issues/re-review.ts
new file mode 100644
index 00000000000..5a2b0edd136
--- /dev/null
+++ b/packages/moderation/src/data/issues/re-review.ts
@@ -0,0 +1,37 @@
+import { RefreshCwIcon } from '@modrinth/assets'
+
+import ignoredMessage from '../messages/checklist/messages/re-review/ignored.md'
+import ignoredWarningMessage from '../messages/checklist/messages/re-review/ignored/warning.md'
+import { issue, panel, toggle } from './component-builders/builders'
+
+export const reReviewIgnoredIssue = issue({
+ id: 're-review-ignored',
+ title: 'Previous review ignored',
+ category: 'Project wide',
+ message: ({ selected }) =>
+ [
+ ignoredMessage,
+ selected.toggleIds.includes('re-review-warning') ? ignoredWarningMessage : '',
+ ].join('\n'),
+ suggestedStatus: ({ selected }) =>
+ selected.toggleIds.includes('re-review-warning') ? 'rejected' : 'flagged',
+})
+
+export const reReviewReviewPanel = panel({
+ title: 'Re-Review',
+ hint: 'Did the author ignore previous review messages?',
+ icon: RefreshCwIcon,
+ guidanceUrl: 'https://www.notion.so/2e15ee711bf080e4a41df61bbab49892',
+ shown: ({ wasReviewed }) => wasReviewed,
+}).content(
+ toggle({
+ issue: reReviewIgnoredIssue,
+ label: 'Ignored issues',
+ }),
+ toggle({
+ issue: reReviewIgnoredIssue,
+ label: 'Multiple times in a row',
+ id: 're-review-warning',
+ shown: (ctx) => ctx.selected.issueIds.includes(reReviewIgnoredIssue.id),
+ }),
+)
diff --git a/packages/moderation/src/data/issues/reupload.ts b/packages/moderation/src/data/issues/reupload.ts
new file mode 100644
index 00000000000..bd3bd5dc05a
--- /dev/null
+++ b/packages/moderation/src/data/issues/reupload.ts
@@ -0,0 +1,278 @@
+import { CopyrightIcon } from '@modrinth/assets'
+
+import { projectHasCustomServerModpack } from '../../utils'
+import customPackProhibitedMessage from '../messages/checklist/messages/reupload/custom-pack-prohibited.md'
+import customPackVerificationMessage from '../messages/checklist/messages/reupload/custom-pack-verification.md'
+import customPackVerificationListMessage from '../messages/checklist/messages/reupload/custom-pack-verification/list.md'
+import identityVerificationMessage from '../messages/checklist/messages/reupload/identity-verification.md'
+import identityVerificationServerMessage from '../messages/checklist/messages/reupload/identity-verification-server.md'
+import insufficientForkMessage from '../messages/checklist/messages/reupload/insufficient-fork.md'
+import missingAttributionMessage from '../messages/checklist/messages/reupload/missing-attribution.md'
+import requestProofMessage from '../messages/checklist/messages/reupload/request-proof.md'
+import requestProofServerMessage from '../messages/checklist/messages/reupload/request-proof-server.md'
+import reuploadMessage from '../messages/checklist/messages/reupload/reupload.md'
+import unclearForkMessage from '../messages/checklist/messages/reupload/unclear-fork.md'
+import { issue, markdown, panel, section, text, toggle } from './component-builders/builders'
+import { issueLocation } from './component-builders/locations'
+import { issueTargets } from './component-builders/targets'
+
+export const reuploadReuploadIssue = issue({
+ id: 'reupload-reupload',
+ title: 'Reuploads are forbidden',
+ category: 'Project wide',
+ message: ({ getTextValue }) => {
+ const msg = reuploadMessage
+
+ if (!getTextValue('reupload-original-project') && !getTextValue('reupload-original-author')) {
+ return msg.replaceAll('%ORIGINAL_PROJECT%%ORIGINAL_AUTHOR%', 'from other creators')
+ } else if (
+ getTextValue('reupload-original-project') &&
+ !getTextValue('reupload-original-author')
+ ) {
+ return msg
+ .replaceAll('%ORIGINAL_PROJECT%', () => `from ${getTextValue('reupload-original-project')}`)
+ .replaceAll('%ORIGINAL_AUTHOR%', '')
+ } else if (
+ !getTextValue('reupload-original-project') &&
+ getTextValue('reupload-original-author')
+ ) {
+ return msg
+ .replaceAll('%ORIGINAL_PROJECT%', '')
+ .replaceAll('%ORIGINAL_AUTHOR%', () => `by ${getTextValue('reupload-original-author')}`)
+ } else {
+ return msg
+ .replaceAll('%ORIGINAL_PROJECT%', () => `from ${getTextValue('reupload-original-project')}`)
+ .replaceAll('%ORIGINAL_AUTHOR%', () => ` by ${getTextValue('reupload-original-author')}`)
+ }
+ },
+ suggestedStatus: 'rejected',
+})
+
+export const reuploadUnclearForkIssue = issue({
+ id: 'reupload-unclear-fork',
+ title: 'Unclear fork',
+ category: 'Project wide',
+ message: unclearForkMessage,
+ suggestedStatus: 'rejected',
+ actions: [issueTargets.acknowledge('reply')],
+})
+
+export const reuploadInsufficientForkIssue = issue({
+ id: 'reupload-insufficient-fork',
+ title: 'Insufficient fork',
+ category: 'Project wide',
+ message: insufficientForkMessage,
+ suggestedStatus: 'rejected',
+})
+
+export const reuploadRequestProofIssue = issue({
+ id: 'reupload-request-proof',
+ title: 'Proof of permissions',
+ category: 'Project wide',
+ message: requestProofMessage,
+ suggestedStatus: 'rejected',
+})
+
+export const reuploadIdentityVerificationIssue = issue({
+ id: 'reupload-identity-verification',
+ title: 'Identity verification',
+ category: 'Project wide',
+ message: ({ getTextValue }) =>
+ identityVerificationMessage.replaceAll('%PLATFORM%', () => getTextValue('reupload-platform')),
+ suggestedStatus: 'rejected',
+})
+
+export const reuploadIdentityVerificationServerIssue = issue({
+ id: 'reupload-identity-verification-server',
+ title: 'Server identity verification',
+ category: 'Project wide',
+ message: ({ getTextValue }) => {
+ const msg = identityVerificationServerMessage
+ const contact = getTextValue('server-contact') || getTextValue('reupload-contact')
+ if (contact.length > 0) {
+ return msg.replaceAll('%CONTACT%', () => `, such as \`${contact}\``)
+ }
+ return msg.replaceAll('%CONTACT%', '')
+ },
+ suggestedStatus: 'rejected',
+})
+
+export const reuploadRequestProofServerIssue = issue({
+ id: 'reupload-request-proof-server',
+ title: 'Server proof of permissions',
+ category: 'Project wide',
+ message: requestProofServerMessage,
+ suggestedStatus: 'rejected',
+})
+
+export const reuploadCustomPackProhibitedIssue = issue({
+ id: 'reupload-custom-pack-prohibited',
+ title: 'Prohibited custom modpack',
+ category: 'Project wide',
+ message: ({ getMarkdownValue }) =>
+ customPackProhibitedMessage.replaceAll(
+ '%OVERRIDES%',
+ () => getMarkdownValue('server-overrides') || getMarkdownValue('reupload-overrides'),
+ ),
+ suggestedStatus: 'rejected',
+})
+
+export const reuploadMissingAttributionIssue = issue({
+ id: 'reupload-missing-attribution',
+ locations: [issueLocation('disclosures')],
+ title: 'Missing attribution',
+ category: 'Project wide',
+ message: missingAttributionMessage,
+ suggestedStatus: 'rejected',
+})
+
+export const reuploadCustomPackVerificationIssue = issue({
+ id: 'reupload-custom-pack-verification',
+ title: 'Custom modpack verification',
+ category: 'Project wide',
+ message: ({ selected, getMarkdownValue }) =>
+ [
+ customPackVerificationMessage,
+ selected.toggleIds.includes('reupload-list') || selected.toggleIds.includes('server-list')
+ ? customPackVerificationListMessage.replaceAll(
+ '%OVERRIDES%',
+ () => getMarkdownValue('server-overrides') || getMarkdownValue('reupload-overrides'),
+ )
+ : '',
+ ].join('\n'),
+ suggestedStatus: 'rejected',
+})
+
+export const reuploadReviewPanel = panel({
+ title: 'Reupload',
+ hint: 'Does the author have proper permissions to post this project?',
+ icon: CopyrightIcon,
+ guidanceUrl:
+ 'https://www.notion.so/2e15ee711bf080e4a41df61bbab49892#2e35ee711bf080d1a0a2cda3ff2ce997',
+}).content(
+ toggle({
+ issue: reuploadReuploadIssue,
+ label: 'Re-upload',
+ shown: (ctx) => !ctx.projectV3.minecraft_server,
+ }),
+
+ toggle({
+ issue: reuploadUnclearForkIssue,
+ label: 'Unclear Fork',
+ shown: (ctx) => !ctx.projectV3.minecraft_server,
+ }),
+ toggle({
+ issue: reuploadInsufficientForkIssue,
+ label: 'Insufficient Fork',
+ shown: (ctx) => !ctx.projectV3.minecraft_server,
+ }),
+ toggle({
+ issue: reuploadRequestProofIssue,
+ label: 'Proof of permissions',
+ }),
+ toggle({
+ issue: reuploadIdentityVerificationIssue,
+ label: 'Verify Identity',
+ shown: (ctx) => !ctx.projectV3.minecraft_server,
+ }),
+ toggle({
+ issue: reuploadIdentityVerificationServerIssue,
+ label: 'Verify Identity',
+ shown: (ctx) => !!ctx.projectV3.minecraft_server,
+ }),
+ toggle({
+ issue: reuploadRequestProofServerIssue,
+ label: 'Reuploaded pack',
+ shown: (ctx) => projectHasCustomServerModpack(ctx.projectV3),
+ }),
+ toggle({
+ issue: reuploadCustomPackProhibitedIssue,
+ label: 'Forbidden Overrides',
+ shown: (ctx) => projectHasCustomServerModpack(ctx.projectV3),
+ }),
+ toggle({
+ issue: reuploadMissingAttributionIssue,
+ label: 'Missing Attribution',
+ }),
+ toggle({
+ issue: reuploadCustomPackVerificationIssue,
+ label: 'Override verification',
+ shown: (ctx) => projectHasCustomServerModpack(ctx.projectV3),
+ }),
+ section({
+ shown: (ctx) =>
+ ctx.selected.issueIds.includes(reuploadReuploadIssue.id) && !ctx.projectV3.minecraft_server,
+ }).content(
+ text({
+ issue: reuploadReuploadIssue,
+ id: 'reupload-original-project',
+ label: 'Original Project Title',
+ required: false,
+ }),
+ text({
+ issue: reuploadReuploadIssue,
+ id: 'reupload-original-author',
+ label: 'Original project Author',
+ required: false,
+ }),
+ ),
+ section({
+ shown: (ctx) =>
+ ctx.selected.issueIds.includes(reuploadIdentityVerificationIssue.id) &&
+ !ctx.projectV3.minecraft_server,
+ }).content(
+ text({
+ issue: reuploadIdentityVerificationIssue,
+ id: 'reupload-platform',
+ label: 'Where else can the project be found?',
+ required: true,
+ }),
+ ),
+ section({
+ shown: (ctx) =>
+ ctx.selected.issueIds.includes(reuploadIdentityVerificationServerIssue.id) &&
+ !!ctx.projectV3.minecraft_server,
+ }).content(
+ text({
+ issue: reuploadIdentityVerificationServerIssue,
+ id: 'reupload-contact',
+ label: 'Known public contact method',
+ required: false,
+ }),
+ ),
+
+ section({
+ shown: (ctx) =>
+ ctx.selected.issueIds.includes(reuploadCustomPackProhibitedIssue.id) &&
+ projectHasCustomServerModpack(ctx.projectV3),
+ }).content(
+ markdown({
+ issue: reuploadCustomPackProhibitedIssue,
+ id: 'reupload-overrides',
+ label: 'Forbidden overrides list',
+ required: true,
+ }),
+ ),
+
+ section({
+ shown: (ctx) =>
+ ctx.selected.issueIds.includes(reuploadCustomPackVerificationIssue.id) &&
+ projectHasCustomServerModpack(ctx.projectV3),
+ }).content(
+ toggle({
+ issue: reuploadCustomPackVerificationIssue,
+ label: 'List overrides?',
+ id: 'reupload-list',
+ }),
+ section({
+ shown: ({ selected }) => selected.toggleIds.includes('reupload-list'),
+ }).content(
+ markdown({
+ issue: reuploadCustomPackVerificationIssue,
+ id: 'reupload-overrides',
+ label: 'Add list of overrides.',
+ required: false,
+ }),
+ ),
+ ),
+)
diff --git a/packages/moderation/src/data/issues/rules.ts b/packages/moderation/src/data/issues/rules.ts
new file mode 100644
index 00000000000..616a1bf7c8c
--- /dev/null
+++ b/packages/moderation/src/data/issues/rules.ts
@@ -0,0 +1,317 @@
+import { ListBulletedIcon } from '@modrinth/assets'
+import { md } from '@modrinth/utils'
+
+import aiGeneratedMessage from '../messages/checklist/messages/rules/ai-generated.md'
+import aiImagesMessage from '../messages/checklist/messages/rules/ai-images.md'
+import aiImagesTargetedMessage from '../messages/checklist/messages/rules/ai-images-targeted.md'
+import cheatOrHackAdvertisingMessage from '../messages/checklist/messages/rules/cheat-or-hack-advertising.md'
+import paidAccessServerMessage from '../messages/checklist/messages/rules/paid-access-server.md'
+import prohibitedContentDiscriminatoryMessage from '../messages/checklist/messages/rules/prohibited-content/discriminatory.md'
+import prohibitedContentFalseEndorsementMessage from '../messages/checklist/messages/rules/prohibited-content/false-endorsement.md'
+import prohibitedContentHarmfulMessage from '../messages/checklist/messages/rules/prohibited-content/harmful.md'
+import prohibitedContentIllegalActivityMessage from '../messages/checklist/messages/rules/prohibited-content/illegal-activity.md'
+import prohibitedContentImpersonationMessage from '../messages/checklist/messages/rules/prohibited-content/impersonation.md'
+import prohibitedContentIpInfringementMessage from '../messages/checklist/messages/rules/prohibited-content/ip-infringement.md'
+import prohibitedContentLegalRightsMessage from '../messages/checklist/messages/rules/prohibited-content/legal-rights.md'
+import prohibitedContentMisleadingMessage from '../messages/checklist/messages/rules/prohibited-content/misleading.md'
+import prohibitedContentMojangBypassMessage from '../messages/checklist/messages/rules/prohibited-content/mojang-bypass.md'
+import prohibitedContentObjectionableMessage from '../messages/checklist/messages/rules/prohibited-content/objectionable.md'
+import prohibitedContentProfanityMessage from '../messages/checklist/messages/rules/prohibited-content/profanity.md'
+import prohibitedContentUndisclosedUploadMessage from '../messages/checklist/messages/rules/prohibited-content/undisclosed-upload.md'
+import prohibitedContentHeaderMessage from '../messages/checklist/messages/rules/prohibited-content-header.md'
+import serverSideOptInAimBotMessage from '../messages/checklist/messages/rules/server-side-opt-in/aim-bot.md'
+import serverSideOptInHidingModsMessage from '../messages/checklist/messages/rules/server-side-opt-in/hiding-mods.md'
+import serverSideOptInItemDuplicationMessage from '../messages/checklist/messages/rules/server-side-opt-in/item-duplication.md'
+import serverSideOptInMovementMessage from '../messages/checklist/messages/rules/server-side-opt-in/movement.md'
+import serverSideOptInPvpMessage from '../messages/checklist/messages/rules/server-side-opt-in/pvp.md'
+import serverSideOptInXRayMessage from '../messages/checklist/messages/rules/server-side-opt-in/x-ray.md'
+import serverSideOptInHeaderMessage from '../messages/checklist/messages/rules/server-side-opt-in-header.md'
+import serverSideOptOutMessage from '../messages/checklist/messages/rules/server-side-opt-out.md'
+import { issue, panel, section, toggle } from './component-builders/builders'
+import { IssuePriority } from './component-builders/priority'
+import type { IssueAction } from './component-builders/types'
+
+const escapeHtml = md().utils.escapeHtml
+
+export const rulesPaidAccessServerIssue = issue({
+ id: 'rules-paid-access-server',
+ title: 'Paid access server',
+ category: 'Project wide',
+ message: paidAccessServerMessage,
+ suggestedStatus: 'rejected',
+})
+
+export const rulesCheatOrHackAdvertisingIssue = issue({
+ id: 'rules-cheat-or-hack-advertising',
+ title: 'Cheat or hack advertising',
+ category: 'Project wide',
+ message: cheatOrHackAdvertisingMessage,
+ suggestedStatus: 'rejected',
+})
+
+export const rulesServerSideOptOutIssue = issue({
+ id: 'rules-server-side-opt-out',
+ title: 'Server-side opt-out required',
+ category: 'Project wide',
+ message: serverSideOptOutMessage,
+ suggestedStatus: 'flagged',
+})
+
+export const rulesAiGeneratedIssue = issue({
+ id: 'rules-ai-generated',
+ priority: IssuePriority.Alerts,
+ title: 'AI-generated content',
+ category: 'Project wide',
+ message: aiGeneratedMessage,
+ suggestedStatus: 'flagged',
+})
+
+export const rulesAiImagesIssue = issue({
+ id: 'rules-ai-images',
+ title: 'Prohibited images',
+ category: 'Project wide',
+ actions: ({ projectV3, selected }) => {
+ const keys = new Set((selected.items['gallery-image'] ?? []).map(({ key }) => key))
+ const imageIds = projectV3.gallery.flatMap((image) =>
+ image.id !== undefined && keys.has(String(image.id)) ? [image.id] : [],
+ )
+ const actions: IssueAction[] = []
+ if (selected.toggleIds.includes('description-ai-images')) {
+ actions.push(() => ({
+ type: 'modify_description',
+ value: { original: projectV3.description },
+ }))
+ }
+ if (selected.toggleIds.includes('icon-ai-images')) {
+ actions.push(() => ({
+ type: 'remove_icon',
+ }))
+ }
+ if (imageIds.length) {
+ actions.push(() => ({ type: 'remove_gallery_images', value: { image_ids: imageIds } }))
+ }
+ return actions
+ },
+ message: ({ projectV3, selected }) => {
+ const entries: string[] = []
+ const settingsUrl = `https://modrinth.com/project/${encodeURIComponent(projectV3.id)}/settings`
+ if (selected.toggleIds.includes('icon-ai-images')) {
+ const image = projectV3.icon_url
+ ? ` `
+ : ''
+ entries.push(`[Project icon](${settingsUrl})\n${image}`)
+ }
+ const keys = new Set((selected.items['gallery-image'] ?? []).map(({ key }) => key))
+ const images = projectV3.gallery.filter(
+ (image) => image.id !== undefined && keys.has(String(image.id)),
+ )
+ if (images.length) {
+ const previews = images.map(
+ (image) =>
+ ` `,
+ )
+ entries.push(`[Gallery](${settingsUrl}/gallery)\n${previews.join('\n')}`)
+ }
+ if (selected.toggleIds.includes('description-ai-images')) {
+ entries.push(
+ `[Description](${settingsUrl}/description)\n\nRemove any prohibited images in the description.`,
+ )
+ }
+ if (!entries.length) return aiImagesMessage.trimEnd()
+ const locations = `:\n\n${entries.join('\n\n')}`
+ return aiImagesTargetedMessage.replace('%AI_IMAGE_LOCATIONS%', locations).trimEnd()
+ },
+ suggestedStatus: 'flagged',
+})
+
+const prohibitedContentMessages: Record = {
+ 'rules-prohibited-content-objectionable': prohibitedContentObjectionableMessage,
+ 'rules-prohibited-content-discriminatory': prohibitedContentDiscriminatoryMessage,
+ 'rules-prohibited-content-ip-infringement': prohibitedContentIpInfringementMessage,
+ 'rules-prohibited-content-legal-rights': prohibitedContentLegalRightsMessage,
+ 'rules-prohibited-content-illegal-activity': prohibitedContentIllegalActivityMessage,
+ 'rules-prohibited-content-harmful': prohibitedContentHarmfulMessage,
+ 'rules-prohibited-content-misleading': prohibitedContentMisleadingMessage,
+ 'rules-prohibited-content-impersonation': prohibitedContentImpersonationMessage,
+ 'rules-prohibited-content-false-endorsement': prohibitedContentFalseEndorsementMessage,
+ 'rules-prohibited-content-profanity': prohibitedContentProfanityMessage,
+ 'rules-prohibited-content-undisclosed-upload': prohibitedContentUndisclosedUploadMessage,
+ 'rules-prohibited-content-mojang-bypass': prohibitedContentMojangBypassMessage,
+}
+
+export const rulesProhibitedContentIssue = issue({
+ id: 'rules-prohibited-content',
+ title: 'Prohibited content',
+ category: 'Project wide',
+ message: ({ selected }) =>
+ [
+ prohibitedContentHeaderMessage,
+ ...Object.entries(prohibitedContentMessages)
+ .filter(([id]) => selected.toggleIds.includes(id))
+ .map(([, message]) => message),
+ ].join('\n'),
+ suggestedStatus: 'rejected',
+})
+
+const serverSideOptInMessages: Record = {
+ 'rules-server-side-opt-in-x-ray': serverSideOptInXRayMessage,
+ 'rules-server-side-opt-in-aim-bot': serverSideOptInAimBotMessage,
+ 'rules-server-side-opt-in-movement': serverSideOptInMovementMessage,
+ 'rules-server-side-opt-in-pvp': serverSideOptInPvpMessage,
+ 'rules-server-side-opt-in-hiding-mods': serverSideOptInHidingModsMessage,
+ 'rules-server-side-opt-in-item-duplication': serverSideOptInItemDuplicationMessage,
+}
+
+export const rulesServerSideOptInIssue = issue({
+ id: 'rules-server-side-opt-in',
+ title: 'Server-side opt-in required',
+ category: 'Project wide',
+ message: ({ selected }) =>
+ [
+ serverSideOptInHeaderMessage,
+ ...Object.entries(serverSideOptInMessages)
+ .filter(([id]) => selected.toggleIds.includes(id))
+ .map(([, message]) => message),
+ ].join('\n'),
+ suggestedStatus: 'flagged',
+})
+
+export const rulesReviewPanel = panel({
+ title: 'Rule Following',
+ hint: 'Does this project violate the rules?',
+ icon: ListBulletedIcon,
+ guidanceUrl:
+ 'https://www.notion.so/2e15ee711bf080e4a41df61bbab49892#2e35ee711bf080709084f6269835607f',
+}).content(
+ section().content(
+ toggle({
+ issue: rulesPaidAccessServerIssue,
+ label: 'Paid access server',
+ shown: ({ projectV3 }) => !!projectV3.minecraft_server,
+ }),
+ toggle({
+ issue: rulesCheatOrHackAdvertisingIssue,
+ label: 'Hacks',
+ }),
+ toggle({
+ issue: rulesServerSideOptOutIssue,
+ label: 'Opt-out',
+ }),
+ toggle({
+ issue: rulesAiGeneratedIssue,
+ label: 'AI Generated',
+ }),
+ toggle({
+ issue: rulesAiImagesIssue,
+ label: 'AI Images',
+ }),
+ toggle({
+ issue: rulesProhibitedContentIssue,
+ label: 'Prohibited Content',
+ }),
+ toggle({
+ issue: rulesServerSideOptInIssue,
+ label: 'Opt-in',
+ }),
+ ),
+ section({
+ label: 'Which Prohibited Content rules does this project violate?',
+ shown: ({ selected }) => selected.issueIds.includes(rulesProhibitedContentIssue.id),
+ }).content(
+ toggle({
+ issue: rulesProhibitedContentIssue,
+ label: 'Objectionable',
+ id: 'rules-prohibited-content-objectionable',
+ }),
+ toggle({
+ issue: rulesProhibitedContentIssue,
+ label: 'Discriminatory or Explicit',
+ id: 'rules-prohibited-content-discriminatory',
+ }),
+ toggle({
+ issue: rulesProhibitedContentIssue,
+ label: 'IP Infringement',
+ id: 'rules-prohibited-content-ip-infringement',
+ }),
+ toggle({
+ issue: rulesProhibitedContentIssue,
+ label: 'Rights Violation',
+ id: 'rules-prohibited-content-legal-rights',
+ }),
+ toggle({
+ issue: rulesProhibitedContentIssue,
+ label: 'Illegal Activity',
+ id: 'rules-prohibited-content-illegal-activity',
+ }),
+ toggle({
+ issue: rulesProhibitedContentIssue,
+ label: 'Harmful or Deceptive',
+ id: 'rules-prohibited-content-harmful',
+ }),
+ toggle({
+ issue: rulesProhibitedContentIssue,
+ label: 'Misleading claims',
+ id: 'rules-prohibited-content-misleading',
+ }),
+ toggle({
+ issue: rulesProhibitedContentIssue,
+ label: 'Impersonation',
+ id: 'rules-prohibited-content-impersonation',
+ }),
+ toggle({
+ issue: rulesProhibitedContentIssue,
+ label: 'False Endorsement',
+ id: 'rules-prohibited-content-false-endorsement',
+ }),
+ toggle({
+ issue: rulesProhibitedContentIssue,
+ label: 'Profanity',
+ id: 'rules-prohibited-content-profanity',
+ }),
+ toggle({
+ issue: rulesProhibitedContentIssue,
+ label: 'Undisclosed Data Upload',
+ id: 'rules-prohibited-content-undisclosed-upload',
+ }),
+ toggle({
+ issue: rulesProhibitedContentIssue,
+ label: 'Mojang Bypass',
+ id: 'rules-prohibited-content-mojang-bypass',
+ }),
+ ),
+ section({
+ label: 'Which features require a Server-side Opt-in?',
+ shown: ({ selected }) => selected.issueIds.includes(rulesServerSideOptInIssue.id),
+ }).content(
+ toggle({
+ issue: rulesServerSideOptInIssue,
+ label: 'X-ray',
+ id: 'rules-server-side-opt-in-x-ray',
+ }),
+ toggle({
+ issue: rulesServerSideOptInIssue,
+ label: 'Aim Assist',
+ id: 'rules-server-side-opt-in-aim-bot',
+ }),
+ toggle({
+ issue: rulesServerSideOptInIssue,
+ label: 'Movement',
+ id: 'rules-server-side-opt-in-movement',
+ }),
+ toggle({
+ issue: rulesServerSideOptInIssue,
+ label: 'PvP',
+ id: 'rules-server-side-opt-in-pvp',
+ }),
+ toggle({
+ issue: rulesServerSideOptInIssue,
+ label: 'Anti 3.x',
+ id: 'rules-server-side-opt-in-hiding-mods',
+ }),
+ toggle({
+ issue: rulesServerSideOptInIssue,
+ label: 'Dupe',
+ id: 'rules-server-side-opt-in-item-duplication',
+ }),
+ ),
+)
diff --git a/packages/moderation/src/data/issues/server.ts b/packages/moderation/src/data/issues/server.ts
new file mode 100644
index 00000000000..9f09d6f244e
--- /dev/null
+++ b/packages/moderation/src/data/issues/server.ts
@@ -0,0 +1,109 @@
+import { ServerIcon } from '@modrinth/assets'
+
+import { projectHasCustomServerModpack } from '../../utils'
+import excessiveLanguagesMessage from '../messages/checklist/messages/rules/excessive-languages.md'
+import { issue, markdown, panel, section, text, toggle } from './component-builders/builders'
+import { issueTargets } from './component-builders/targets'
+import { metadataGameVersionsIssue } from './metadata'
+import {
+ reuploadCustomPackProhibitedIssue,
+ reuploadCustomPackVerificationIssue,
+ reuploadIdentityVerificationServerIssue,
+ reuploadRequestProofServerIssue,
+} from './reupload'
+
+export const serversExcessiveLanguagesIssue = issue({
+ id: 'servers-excessive-languages',
+ title: 'Excessive languages',
+ category: 'Server details',
+ actions: [issueTargets.modifyServerLanguages()],
+ message: excessiveLanguagesMessage,
+ suggestedStatus: 'flagged',
+})
+
+export const serverReviewPanel = panel({
+ title: 'Server details',
+ hint: "Are there any issues with this project's server details?",
+ icon: ServerIcon,
+ guidanceUrl: '',
+ shown: ({ projectV3 }) => !!projectV3.minecraft_server,
+}).content(
+ toggle({
+ issue: serversExcessiveLanguagesIssue,
+ label: 'Excessive languages',
+ id: 'server-excessive-languages',
+ }),
+ toggle({
+ issue: reuploadIdentityVerificationServerIssue,
+ label: 'Verify Identity',
+ }),
+ toggle({
+ issue: metadataGameVersionsIssue,
+ label: 'Game Versions',
+ }),
+ section({
+ shown: (ctx) =>
+ ctx.selected.issueIds.includes(reuploadIdentityVerificationServerIssue.id) &&
+ !!ctx.projectV3.minecraft_server,
+ }).content(
+ text({
+ issue: reuploadIdentityVerificationServerIssue,
+ id: 'server-contact',
+ label: 'Known public contact method',
+ required: false,
+ }),
+ ),
+ section({
+ label: 'Custom Modpack',
+ shown: (ctx) => projectHasCustomServerModpack(ctx.projectV3),
+ }).content(
+ toggle({
+ issue: reuploadRequestProofServerIssue,
+ label: 'Reuploaded pack',
+ shown: (ctx) => projectHasCustomServerModpack(ctx.projectV3),
+ }),
+ toggle({
+ issue: reuploadCustomPackProhibitedIssue,
+ label: 'Forbidden Overrides',
+ shown: (ctx) => projectHasCustomServerModpack(ctx.projectV3),
+ }),
+ toggle({
+ issue: reuploadCustomPackVerificationIssue,
+ label: 'Override verification',
+ shown: (ctx) => projectHasCustomServerModpack(ctx.projectV3),
+ }),
+ section({
+ shown: (ctx) =>
+ ctx.selected.issueIds.includes(reuploadCustomPackProhibitedIssue.id) &&
+ projectHasCustomServerModpack(ctx.projectV3),
+ }).content(
+ markdown({
+ issue: reuploadCustomPackProhibitedIssue,
+ id: 'server-overrides',
+ label: 'Forbidden overrides list',
+ required: true,
+ }),
+ ),
+ section({
+ shown: (ctx) =>
+ ctx.selected.issueIds.includes(reuploadCustomPackVerificationIssue.id) &&
+ projectHasCustomServerModpack(ctx.projectV3),
+ }).content(
+ toggle({
+ issue: reuploadCustomPackVerificationIssue,
+ label: 'List overrides?',
+ id: 'server-list',
+ }),
+ section({
+ shown: ({ selected }) => selected.toggleIds.includes('server-list'),
+ }).content(
+ markdown({
+ issue: reuploadCustomPackVerificationIssue,
+ id: 'server-overrides',
+ label: 'Add list of overrides.',
+ required: false,
+ }),
+ ),
+ ),
+ ),
+)
diff --git a/packages/moderation/src/data/issues/slug.ts b/packages/moderation/src/data/issues/slug.ts
new file mode 100644
index 00000000000..e8be53ab7c7
--- /dev/null
+++ b/packages/moderation/src/data/issues/slug.ts
@@ -0,0 +1,49 @@
+import { LinkIcon } from '@modrinth/assets'
+
+import { generateUrlSlug } from '../../utils'
+import misused from '../messages/checklist/messages/title-slug/slug/misused.md'
+import { issue, panel, section, text, toggle } from './component-builders/builders'
+import { issueTargets } from './component-builders/targets'
+
+export const misusedSlugIssue = issue({
+ id: 'slug-misused',
+ title: 'Misused project URL',
+ category: 'Slug',
+ actions: [
+ issueTargets.modifySlug(({ getTextValue }) => {
+ const suggestion = getTextValue('correct-slug').toLowerCase()
+ return suggestion.trim() ? suggestion : undefined
+ }),
+ ],
+ message: ({ getTextValue }) => {
+ const suggestion = getTextValue('correct-slug').toLowerCase()
+ return misused
+ .replaceAll('%CORRECT%', () =>
+ suggestion.trim() ? `We suggest using the slug \`${suggestion}\`.` : '',
+ )
+ .trim()
+ },
+})
+
+export const slugReviewPanel = panel({
+ title: 'Slug',
+ hint: "Is the project's URL accurate and appropriate?",
+ icon: LinkIcon,
+ guidanceUrl:
+ 'https://www.notion.so/2e15ee711bf080e4a41df61bbab49892#2e15ee711bf0803c9660e90f0fead705',
+}).content(
+ toggle({
+ label: 'Misused slug',
+ issue: misusedSlugIssue,
+ shown: ({ projectV3 }) => generateUrlSlug(projectV3.name) !== projectV3.slug,
+ }),
+ section({
+ shown: ({ selected }) => selected.issueIds.includes(misusedSlugIssue.id),
+ }).content(
+ text({
+ issue: misusedSlugIssue,
+ id: 'correct-slug',
+ label: 'Correct project URL',
+ }),
+ ),
+)
diff --git a/packages/moderation/src/data/issues/summary.ts b/packages/moderation/src/data/issues/summary.ts
new file mode 100644
index 00000000000..7f46f66aeaa
--- /dev/null
+++ b/packages/moderation/src/data/issues/summary.ts
@@ -0,0 +1,136 @@
+import { AlignLeftIcon } from '@modrinth/assets'
+
+import formatting from '../messages/checklist/messages/summary/formatting.md'
+import insufficient from '../messages/checklist/messages/summary/insufficient.md'
+import nonEnglish from '../messages/checklist/messages/summary/non-english.md'
+import repeatIp from '../messages/checklist/messages/summary/repeat-ip.md'
+import { issue, panel, section, textarea, toggle } from './component-builders/builders'
+import { issueTargets } from './component-builders/targets'
+import { messageWithOptionalSuggestion } from '../../utils'
+
+const panelCategory = 'Summary'
+
+export const insufficientSummaryIssue = issue({
+ id: 'summary-insufficient',
+ title: 'Insufficient summary',
+ category: panelCategory,
+ actions: [
+ issueTargets.modifySummary(({ getTextValue }) => {
+ const suggestion = getTextValue('summary-suggestion')
+ return suggestion.trim() ? suggestion : undefined
+ }),
+ ],
+ suggestedStatus: 'flagged',
+ message: ({ getTextValue }) =>
+ messageWithOptionalSuggestion(
+ insufficient,
+ panelCategory.toLowerCase(),
+ getTextValue('summary-suggestion'),
+ ),
+})
+
+export const summaryFormattingIssue = issue({
+ id: 'summary-formatting',
+ title: 'Invalid summary formatting',
+ category: panelCategory,
+ actions: [
+ issueTargets.modifySummary(({ getTextValue }) => {
+ const suggestion = getTextValue('summary-suggestion')
+ return suggestion.trim() ? suggestion : undefined
+ }),
+ ],
+ suggestedStatus: 'flagged',
+ message: ({ getTextValue }) =>
+ messageWithOptionalSuggestion(
+ formatting,
+ panelCategory.toLowerCase(),
+ getTextValue('summary-suggestion'),
+ ),
+})
+
+export const nonEnglishSummaryIssue = issue({
+ id: 'summary-non-english',
+ title: 'Non-English summary',
+ category: panelCategory,
+ actions: [
+ issueTargets.modifySummary(({ getTextValue }) => {
+ const suggestion = getTextValue('summary-suggestion')
+ return suggestion.trim() ? suggestion : undefined
+ }),
+ ],
+ suggestedStatus: 'flagged',
+ message: ({ getTextValue }) =>
+ messageWithOptionalSuggestion(
+ nonEnglish,
+ panelCategory.toLowerCase(),
+ getTextValue('summary-suggestion'),
+ ),
+})
+
+export const summaryRepeatsIpIssue = issue({
+ id: 'summary-repeat-ip',
+ title: 'Summary repeats the server address',
+ category: panelCategory,
+ actions: [
+ issueTargets.modifySummary(({ getTextValue }) => {
+ const suggestion = getTextValue('summary-suggestion')
+ return suggestion.trim() ? suggestion : undefined
+ }),
+ ],
+ suggestedStatus: 'flagged',
+ message: ({ getTextValue }) =>
+ messageWithOptionalSuggestion(
+ repeatIp,
+ panelCategory.toLowerCase(),
+ getTextValue('summary-suggestion'),
+ ),
+})
+
+export const summaryReviewPanel = panel({
+ title: panelCategory,
+ hint: "Is the project's summary sufficient?",
+ icon: AlignLeftIcon,
+ guidanceUrl:
+ 'https://www.notion.so/2e15ee711bf080e4a41df61bbab49892#2e15ee711bf080bfb5e5c7c6211c693b',
+}).content(
+ section().content(
+ toggle({
+ label: 'Insufficient',
+ issue: insufficientSummaryIssue,
+ }),
+ toggle({
+ label: 'Formatting',
+ issue: summaryFormattingIssue,
+ }),
+ toggle({
+ label: 'Non-english',
+ issue: nonEnglishSummaryIssue,
+ shown: (ctx) =>
+ !ctx.projectV3.minecraft_java_server ||
+ !!ctx.projectV3.minecraft_server?.languages?.includes('en'),
+ }),
+ toggle({
+ label: 'Repeat of IP',
+ issue: summaryRepeatsIpIssue,
+ shown: (ctx) => !!ctx.projectV3.minecraft_server,
+ }),
+ ),
+ section({
+ shown: ({ selected }) =>
+ selected.issueIds.some((id) =>
+ [
+ insufficientSummaryIssue.id,
+ summaryFormattingIssue.id,
+ nonEnglishSummaryIssue.id,
+ summaryRepeatsIpIssue.id,
+ ].includes(id),
+ ),
+ }).content(
+ textarea({
+ id: 'summary-suggestion',
+ label: 'Suggestion',
+ maxlength: 256,
+ rows: 3,
+ }),
+ ),
+)
diff --git a/packages/moderation/src/data/issues/title.ts b/packages/moderation/src/data/issues/title.ts
new file mode 100644
index 00000000000..4f9243496df
--- /dev/null
+++ b/packages/moderation/src/data/issues/title.ts
@@ -0,0 +1,140 @@
+import { BookOpenIcon } from '@modrinth/assets'
+
+import minecraftBranding from '../messages/checklist/messages/title-slug/title/minecraft-branding.md'
+import similarities from '../messages/checklist/messages/title-slug/title/similarities.md'
+import forkSimilarities from '../messages/checklist/messages/title-slug/title/similarities/fork.md'
+import modpackSimilarities from '../messages/checklist/messages/title-slug/title/similarities/modpack.md'
+import uselessInfo from '../messages/checklist/messages/title-slug/title/useless-info.md'
+import { issue, panel, section, text, toggle } from './component-builders/builders'
+import { issueTargets } from './component-builders/targets'
+
+export const issueTargetModifyTitleWithSuggestion = issueTargets.modifyTitle(({ getTextValue }) => {
+ const suggestion = getTextValue('title-suggestion')
+ return suggestion.trim() ? suggestion : undefined
+})
+
+export const titleSuggestion = issue({
+ id: 'title-suggestion',
+ title: 'Title suggestion',
+ category: 'Title',
+ actions: [
+ issueTargets.modifyTitle(({ getTextValue }) => {
+ const suggestion = getTextValue('title-suggestion')
+ return suggestion.trim() ? suggestion : undefined
+ }),
+ ],
+ suggestedStatus: 'flagged',
+ message: 'Consider suggesting a better title for the project.',
+})
+
+export const titleUselessInfoIssue = issue({
+ id: 'title-useless-info',
+ title: 'Unnecessary title information',
+ category: 'Title',
+ actions: [
+ issueTargets.modifyTitle(({ getTextValue }) => {
+ const suggestion = getTextValue('title-suggestion')
+ return suggestion.trim() ? suggestion : undefined
+ }),
+ ],
+ suggestedStatus: 'flagged',
+ message: uselessInfo,
+})
+
+export const minecraftTitleIssue = issue({
+ id: 'title-minecraft-branding',
+ title: 'Minecraft branding in title',
+ category: 'Title',
+ actions: [
+ issueTargets.modifyTitle(({ getTextValue }) => {
+ const suggestion = getTextValue('title-suggestion')
+ return suggestion.trim() ? suggestion : undefined
+ }),
+ ],
+ suggestedStatus: 'flagged',
+ message: minecraftBranding,
+})
+
+export const titleSimilaritiesIssue = issue({
+ id: 'title-similarities',
+ title: 'Misuse of project name',
+ category: 'Title',
+ actions: [
+ issueTargets.modifyTitle(({ getTextValue }) => {
+ const suggestion = getTextValue('title-suggestion')
+ return suggestion.trim() ? suggestion : undefined
+ }),
+ ],
+ suggestedStatus: 'flagged',
+ message: ({ selected }) => {
+ if (selected.toggleIds.includes('title-similarities-fork')) {
+ return [similarities.trim(), forkSimilarities.trim()].join('\n\n')
+ } else {
+ return similarities
+ }
+ },
+})
+
+export const modpackTitleSimilaritiesIssue = issue({
+ id: 'title-similarities-modpack',
+ title: 'Modpack uses another project’s name',
+ category: 'Title',
+ actions: [
+ issueTargets.modifyTitle(({ getTextValue }) => {
+ const suggestion = getTextValue('title-suggestion')
+ return suggestion.trim() ? suggestion : undefined
+ }),
+ ],
+ suggestedStatus: 'flagged',
+ message: [similarities.trim(), modpackSimilarities.trim()].join('\n\n'),
+})
+
+export const titleReviewPanel = panel({
+ title: 'Title',
+ hint: "Is the project's name accurate and appropriate?",
+ icon: BookOpenIcon,
+ guidanceUrl:
+ 'https://www.notion.so/2e15ee711bf080e4a41df61bbab49892#2e15ee711bf0803c9660e90f0fead705',
+}).content(
+ section().content(
+ toggle({
+ label: 'Contains Useless Info',
+ issue: titleUselessInfoIssue,
+ }),
+ toggle({
+ label: 'Minecraft Title',
+ issue: minecraftTitleIssue,
+ }),
+ toggle({
+ label: 'Title Similarities',
+ issue: titleSimilaritiesIssue,
+ }),
+ ),
+ section({
+ label: 'Similarities Additional Info',
+ shown: (ctx) => ctx.selected.issueIds.includes('title-similarities'),
+ }).content(
+ toggle({
+ label: 'Forked Project',
+ id: 'title-similarities-fork',
+ issue: titleSimilaritiesIssue,
+ shown: (ctx) => !ctx.projectV3.minecraft_server,
+ }),
+ toggle({
+ label: 'Modpack Named After Mod',
+ id: 'title-similarities-modpack',
+ issue: modpackTitleSimilaritiesIssue,
+ shown: (ctx) => ctx.projectV3.project_types.includes('modpack'),
+ }),
+ ),
+ section({
+ label: 'Name Suggestions',
+ shown: (ctx) => ctx.selected.issueIds.includes('title-useless-info'),
+ }).content(
+ text({
+ label: 'Suggest a Better Name',
+ id: 'title-name-suggestion',
+ issue: titleSuggestion,
+ }),
+ ),
+)
diff --git a/packages/moderation/src/data/issues/undefined-project.ts b/packages/moderation/src/data/issues/undefined-project.ts
new file mode 100644
index 00000000000..12b434dd090
--- /dev/null
+++ b/packages/moderation/src/data/issues/undefined-project.ts
@@ -0,0 +1,26 @@
+import { XIcon } from '@modrinth/assets'
+
+import noVersionsMessage from '../messages/checklist/messages/undefined-project/no-versions.md'
+import { issue, panel, toggle } from './component-builders/builders'
+
+export const undefinedProjectNoVersionsIssue = issue({
+ id: 'undefined-project-no-versions',
+ title: 'No project versions',
+ category: 'Project wide',
+ message: noVersionsMessage,
+ suggestedStatus: 'rejected',
+})
+
+export const undefinedProjectReviewPanel = panel({
+ title: 'Undefined Project',
+ hint: 'This project is undefined!',
+ icon: XIcon,
+ guidanceUrl:
+ 'https://www.notion.so/2e15ee711bf080e4a41df61bbab49892#3475ee711bf080018bf3d822a2f51a35',
+ shown: ({ projectV3 }) => !projectV3.minecraft_server && projectV3.versions.length === 0,
+}).content(
+ toggle({
+ issue: undefinedProjectNoVersionsIssue,
+ label: 'No Versions',
+ }),
+)
diff --git a/packages/moderation/src/data/issues/versions.ts b/packages/moderation/src/data/issues/versions.ts
new file mode 100644
index 00000000000..97fa9d57534
--- /dev/null
+++ b/packages/moderation/src/data/issues/versions.ts
@@ -0,0 +1,217 @@
+import { VersionIcon } from '@modrinth/assets'
+
+import dependenciesMessage from '../messages/checklist/messages/metadata/dependencies.md'
+import alternateVersionsAdditionalMessage from '../messages/checklist/messages/versions/alternate-versions/additional.md'
+import alternateVersionsMonoMessage from '../messages/checklist/messages/versions/alternate-versions/mono.md'
+import alternateVersionsPrimaryMessage from '../messages/checklist/messages/versions/alternate-versions/primary.md'
+import alternateVersionsServerMessage from '../messages/checklist/messages/versions/alternate-versions/server.md'
+import alternateVersionsServerAdditionalMessage from '../messages/checklist/messages/versions/alternate-versions/server-additional.md'
+import alternateVersionsZipMessage from '../messages/checklist/messages/versions/alternate-versions/zip.md'
+import duplicatePrimaryFilesMessage from '../messages/checklist/messages/versions/duplicate-primary-files.md'
+import incorrectAdditionalFilesMessage from '../messages/checklist/messages/versions/incorrect-additional-files.md'
+import incorrectProjectTypeDatapackMessage from '../messages/checklist/messages/versions/incorrect-project-type/datapack.md'
+import incorrectProjectTypeModpackMessage from '../messages/checklist/messages/versions/incorrect-project-type/modpack.md'
+import incorrectProjectTypeResourcepackMessage from '../messages/checklist/messages/versions/incorrect-project-type/resourcepack.md'
+import redistLibsMessage from '../messages/checklist/messages/versions/redist-libs.md'
+import unsupportedMessage from '../messages/checklist/messages/versions/unsupported.md'
+import vanillaAssetsMessage from '../messages/checklist/messages/versions/vanilla-assets.md'
+import { issue, panel, section, select, text, toggle } from './component-builders/builders'
+import { issueLocation } from './component-builders/locations'
+
+export const versionsIncorrectAdditionalFilesIssue = issue({
+ id: 'versions-incorrect-additional-files',
+ title: 'Incorrect additional files',
+ category: 'Versions',
+ locations: [issueLocation('versions')],
+ message: incorrectAdditionalFilesMessage,
+ suggestedStatus: 'flagged',
+})
+
+export const versionsVanillaAssetsIssue = issue({
+ id: 'versions-vanilla-assets',
+ title: 'Vanilla assets',
+ category: 'Versions',
+ locations: [issueLocation('versions')],
+ message: vanillaAssetsMessage,
+ suggestedStatus: 'rejected',
+})
+
+export const versionsRedistLibsIssue = issue({
+ id: 'versions-redist-libs',
+ title: 'Redistributed libraries',
+ category: 'Versions',
+ locations: [issueLocation('versions')],
+ message: redistLibsMessage,
+ suggestedStatus: 'rejected',
+})
+
+export const versionsDuplicatePrimaryFilesIssue = issue({
+ id: 'versions-duplicate-primary-files',
+ title: 'Duplicate primary files',
+ category: 'Versions',
+ locations: [issueLocation('versions')],
+ message: duplicatePrimaryFilesMessage,
+ suggestedStatus: 'flagged',
+})
+
+const incorrectProjectTypeMessages: Record = {
+ modpack: incorrectProjectTypeModpackMessage,
+ resourcepack: incorrectProjectTypeResourcepackMessage,
+ datapack: incorrectProjectTypeDatapackMessage,
+}
+
+export const versionsIncorrectProjectTypeIssue = issue({
+ id: 'versions-incorrect-project-type',
+ title: 'Incorrect project type',
+ category: 'Versions',
+ locations: [issueLocation('versions')],
+ message: ({ getSelectValue }) =>
+ incorrectProjectTypeMessages[getSelectValue('versions-type')] ?? '',
+ suggestedStatus: 'rejected',
+})
+
+const alternateVersionsMessages: Record