@@ -100,7 +103,7 @@
@@ -155,8 +158,12 @@ import { useQueryClient } from '@tanstack/vue-query'
import dayjs from 'dayjs'
import { computed, watch } from 'vue'
+import ProjectIssueCard from '~/components/ui/project-issue-card/index.vue'
import ConversationThread from '~/components/ui/thread/ConversationThread.vue'
-import { canSubmitProjectForReview } from '~/composables/link-network-validation/submission'
+import {
+ canResubmitProjectForReview,
+ canSubmitProjectForReview,
+} from '~/composables/link-network-validation/submission'
import { getProjectLink, isApproved, isRejected, isUnderReview } from '~/helpers/projects.js'
defineEmits(['on-download', 'delete-version'])
@@ -229,7 +236,8 @@ const reviewSubmissionDisabled = computed(
!canSubmitProjectForReview(
projectValidation.value,
projectValidationLoading.value || projectLinksNetworkValidationLoading.value,
- ),
+ ) ||
+ (isRejected(project.value) && !canResubmitProjectForReview(thread.value)),
)
const THREADS_RELEASE_DATE = '2023-08-05T12:00:00-07:00'
@@ -252,6 +260,11 @@ const prefixedThread = computed(() => {
return thread.value
})
+const visibleIssues = computed(() =>
+ (thread.value?.issues ?? []).filter((issue) => !issue.moderator_verified),
+)
+
+const projectApproved = computed(() => isApproved(project.value))
const canAccess = computed(() => !!currentMember.value)
const staff = computed(() => isStaff(currentMember.value?.user))
const userFacingUiVisible = computed(
@@ -319,7 +332,7 @@ const moderationAdmonition = computed<{
}
}
- if (isApproved(currentProject) && approvedAdmonitionMessage.value) {
+ if (projectApproved.value && approvedAdmonitionMessage.value) {
return {
type: 'success',
header: defineMessage({
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({
-
+
diff --git a/apps/frontend/src/pages/[type]/[project]/settings/description.vue b/apps/frontend/src/pages/[type]/[project]/settings/description.vue
index 36e912d523b..00ce0e8c95d 100644
--- a/apps/frontend/src/pages/[type]/[project]/settings/description.vue
+++ b/apps/frontend/src/pages/[type]/[project]/settings/description.vue
@@ -27,6 +27,7 @@
class="mt-2"
/>
+
import {
+ Button,
commonMessages,
ConfirmLeaveModal,
- defineMessages,
EmptyState,
- injectModrinthClient,
injectProjectPageContext,
IntlFormatted,
- isDisclosureCompatibleWithProjectTypes,
- type MessageDescriptor,
normalizeChildren,
UnsavedChangesPopup,
usePageLeaveSafety,
- useSavable,
useVIntl,
} from '@modrinth/ui'
import { isAdmin, isStaff, TeamMemberPermission } from '@modrinth/utils'
-import { useQuery, useQueryClient } from '@tanstack/vue-query'
-import { computed, watch } from 'vue'
+import { computed } from 'vue'
-import {
- AdvertisingDisclosureCard,
- AiDisclosureCard,
- AiFunctionalityDisclosureCard,
- ArchivedDisclosureCard,
- DerivativeDisclosureCard,
- type DisclosureFormIssue,
- type DisclosureLockStatus,
- disclosuresToForm,
- type DisclosureType,
- type DisclosureUpdatedByUser,
- findDisclosureData,
- getDisclosureFormIssues,
- getDisclosureFormSnapshot,
- PaidFeaturesDisclosureCard,
- PhotosensitivityDisclosureCard,
- SystemInteractionsDisclosureCard,
- TelemetryDisclosureCard,
- toModifyRequests,
-} from '~/components/ui/project-settings/disclosures'
+import ProjectIssueCard from '~/components/ui/project-issue-card/index.vue'
+import DisclosureCards from '~/components/ui/project-settings/disclosures/DisclosureCards.vue'
+import { disclosureStatusMessages as statusMessages } from '~/components/ui/project-settings/disclosures/messages'
+import { useDisclosureEditor } from '~/components/ui/project-settings/disclosures/use-disclosure-editor'
import ValidationMessage from '~/components/ValidationMessage.vue'
-import { useAuth } from '~/composables/auth'
import { useProjectNagMessages } from '~/composables/project-nag-validation'
-import { useProjectSaveValidation } from '~/composables/project-save-validation'
-
-const DISCLOSURE_QUERY_STALE_TIME = 1000 * 60 * 5
-const { formatMessage } = useVIntl()
-const { labrinth } = injectModrinthClient()
const {
projectV2: project,
projectV3,
currentMember,
refreshProjectValidation,
} = injectProjectPageContext()
-const queryClient = useQueryClient()
const flags = useFeatureFlags()
-const auth = await useAuth()
-
-const projectTypes = computed(() => projectV3.value?.project_types ?? [project.value.project_type])
-
-const isServerProject = computed(() => projectV3.value?.minecraft_server != null)
-
-const canEditDisclosures = computed(
- () => project.value.versions.length > 0 || isServerProject.value,
-)
-
-function isDisclosureVisible(type: DisclosureType) {
- return isDisclosureCompatibleWithProjectTypes(type, projectTypes.value)
-}
-
-const isActingAsModerator = computed(
- () => isStaff(currentMember.value?.user) && !flags.value.showModeratorProjectMemberUi,
-)
-
-const messages = defineMessages({
- title: {
- id: 'project.settings.disclosures.content-disclosures',
- defaultMessage: 'Content disclosures',
- },
- description: {
- id: 'project.settings.disclosures.description',
- defaultMessage: `You must add any applicable content disclosures to your project in compliance with Modrinth's Content Rules.`,
- },
- description2: {
- id: 'project.settings.disclosures.description.2',
- defaultMessage: `Unsure how to apply content disclosure to your project? Check out our Content Disclosures FAQ.`,
- },
- noPermission: {
- id: 'project.settings.disclosures.save-blocked.no-permission',
- defaultMessage: `You don't have permission to edit this project's disclosures.`,
- },
- uploadVersionFirstHeading: {
- id: 'project.settings.disclosures.upload-version-first.heading',
- defaultMessage: 'Upload versions before adding disclosures',
- },
-})
-
-const issueMessages = defineMessages({
- 'advertising-note': {
- id: 'project.settings.disclosures.save-blocked.advertising-note',
- defaultMessage: 'Advertising disclosure requires explanation.',
- },
- 'paid-features-empty': {
- id: 'project.settings.disclosures.save-blocked.paid-features-empty',
- defaultMessage: 'Paid features disclosure must list at least one paid feature.',
- },
- 'telemetry-empty': {
- id: 'project.settings.disclosures.save-blocked.telemetry-empty',
- defaultMessage: 'Telemetry disclosure must list at least one type of data collected.',
- },
- 'derivative-empty': {
- id: 'project.settings.disclosures.save-blocked.derivative-empty',
- defaultMessage: 'Derivative works disclosure must list at least one source work.',
- },
- 'derivative-source-label': {
- id: 'project.settings.disclosures.save-blocked.derivative-source-label',
- defaultMessage: 'Derivative work sources must have a name.',
- },
- 'photosensitivity-note': {
- id: 'project.settings.disclosures.save-blocked.photosensitivity-note',
- defaultMessage: 'Photosensitivity warning disclosure must include a description.',
- },
- 'system-interactions-note': {
- id: 'project.settings.disclosures.save-blocked.system-interactions-note',
- defaultMessage: 'External system interactions disclosure must include a description.',
- },
-}) satisfies Record
-
-useProjectSettingsHeadTitle(messages.title)
-
-const disclosuresQueryKey = computed(
- () => ['project', 'disclosures', 'v3', project.value.id] as const,
-)
-
-const { data: disclosuresResponse } = useQuery({
- queryKey: disclosuresQueryKey,
- queryFn: () => labrinth.projects_v3.getDisclosures(project.value.id),
- staleTime: DISCLOSURE_QUERY_STALE_TIME,
+const editor = useDisclosureEditor({
+ projectId: computed(() => project.value.id),
+ projectTypes: computed(() => projectV3.value?.project_types ?? [project.value.project_type]),
+ canEditDisclosures: computed(
+ () => project.value.versions.length > 0 || projectV3.value?.minecraft_server != null,
+ ),
+ hasPermission: computed(
+ () =>
+ isAdmin(currentMember.value?.user) ||
+ !!((currentMember.value?.permissions ?? 0) & TeamMemberPermission.EDIT_DETAILS),
+ ),
+ isActingAsModerator: computed(
+ () => isStaff(currentMember.value?.user) && !flags.value.showModeratorProjectMemberUi,
+ ),
+ isAdminUser: computed(() => isAdmin(currentMember.value?.user)),
+ onSaved: refreshProjectValidation,
})
-
-const updaterUserIds = computed(() => {
- const ids = new Set()
- for (const disclosure of disclosuresResponse.value?.disclosures ?? []) {
- if (disclosure.updated_by) {
- ids.add(disclosure.updated_by)
- }
- }
- return [...ids]
-})
-
-const { data: updaterUsers } = useQuery({
- queryKey: computed(() => ['users', 'disclosures', updaterUserIds.value] as const),
- queryFn: () => labrinth.users_v2.getMultiple(updaterUserIds.value),
- enabled: computed(() => updaterUserIds.value.length > 0),
- staleTime: DISCLOSURE_QUERY_STALE_TIME,
-})
-
-const updaterUsersById = computed(() => {
- const map = new Map[number]>()
- for (const user of updaterUsers.value ?? []) {
- map.set(user.id, user)
- }
- return map
-})
-
-const currentUser = computed((): DisclosureUpdatedByUser | null => {
- const user = auth.value?.user
- if (!user?.id || !user.username) {
- return null
- }
- return {
- id: user.id,
- username: user.username,
- avatar_url: user.avatar_url,
- }
-})
-
-function resolveUpdatedBy(userId: string | null | undefined): DisclosureUpdatedByUser | null {
- if (!userId) return null
- return (
- updaterUsersById.value.get(userId) ??
- (currentUser.value?.id === userId ? currentUser.value : null)
- )
-}
-
-const isAdminUser = computed(() => isAdmin(currentMember.value?.user))
-const hasPermission = computed(
- () =>
- isAdminUser.value ||
- !!((currentMember.value?.permissions ?? 0) & TeamMemberPermission.EDIT_DETAILS),
-)
-
-const {
- saved,
- current,
- saving,
- reset: resetForm,
- save: saveForm,
-} = useSavable(
- () => disclosuresToForm(disclosuresResponse.value?.disclosures ?? []),
- async () => {
- if (!hasPermission.value || !canSave.value) {
- throw new Error('Disclosures form is not valid')
- }
-
- const previous = disclosuresToForm(disclosuresResponse.value?.disclosures ?? [])
- const requests = toModifyRequests(current.value, previous)
-
- for (const request of requests) {
- await labrinth.projects_v3.modifyDisclosures(project.value.id, request)
- }
-
- await queryClient.invalidateQueries({ queryKey: disclosuresQueryKey.value })
- },
-)
-
-const savedSnapshot = computed(() => getDisclosureFormSnapshot(saved.value))
-const currentSnapshot = computed(() => getDisclosureFormSnapshot(current.value))
-const hasChanges = computed(
- () => JSON.stringify(savedSnapshot.value) !== JSON.stringify(currentSnapshot.value),
-)
-
-const saveValidation = useProjectSaveValidation(() => currentSnapshot.value)
-
-async function save() {
- if (!hasChanges.value || !canSave.value || saving.value) return
- const submittedState = saveValidation.snapshot()
- try {
- await saveForm()
- saveValidation.clear()
- await refreshProjectValidation()
- } catch (error) {
- if (!saveValidation.capture(error, submittedState)) throw error
- }
-}
-
-function reset() {
- resetForm()
- saveValidation.clear()
-}
-
-function disclosureUpdateProps(type: DisclosureType) {
- const disclosure = findDisclosureData(disclosuresResponse.value?.disclosures, type)
- const savedLockStatus = disclosure?.lock_status ?? 'unlocked'
- const lockStatus = current.value.lockStatuses[type] ?? savedLockStatus
- const lockedForAuthor = !isActingAsModerator.value
-
- return {
- disabled:
- saving.value ||
- !hasPermission.value ||
- (lockedForAuthor && savedLockStatus === 'fully_locked'),
- toggleDisabled: saving.value || (lockedForAuthor && savedLockStatus !== 'unlocked'),
- updatedAt: disclosure?.updated_at,
- updatedBy: resolveUpdatedBy(disclosure?.updated_by),
- setByModerator: !!disclosure?.set_by_moderator,
- lockStatus,
- showLockControls: isActingAsModerator.value,
- }
-}
-
-function setDisclosureLockStatus(type: DisclosureType, lockStatus: DisclosureLockStatus) {
- current.value.lockStatuses = {
- ...current.value.lockStatuses,
- [type]: lockStatus,
- }
-}
-
-watch(
- () => disclosuresResponse.value,
- (value, previous) => {
- if (value && (!previous || !hasChanges.value)) {
- reset()
- }
- },
-)
-
-const issues = computed(() => getDisclosureFormIssues(current.value, projectTypes.value))
+useProjectSettingsHeadTitle(editor.messages.title)
const disclosureTextValidation = useProjectNagMessages('disclosure-text')
const disclosureValidation = useProjectNagMessages('disclosures')
+const { confirmLeaveModal } = usePageLeaveSafety(editor.hasChanges)
-const canSave = computed(
- () =>
- !saveValidation.hasErrors.value &&
- hasPermission.value &&
- (isAdminUser.value || issues.value.length === 0),
-)
-
-const saveDisabledReason = computed(() => {
- if (!hasPermission.value) {
- // should never come up but y'never know
- return formatMessage(messages.noPermission)
- }
- return [...issues.value.map((issue) => formatMessage(issueMessages[issue]))]
-})
-
-const { confirmLeaveModal } = usePageLeaveSafety(hasChanges)
+const { formatMessage } = useVIntl()
+const {
+ messages,
+ savedSnapshot,
+ currentSnapshot,
+ saving,
+ reset,
+ save,
+ canSave,
+ saveDisabledReason,
+ canEditDisclosures,
+ disclosuresQuery,
+ saveError,
+} = editor
+const { isPending, isError, refetch } = disclosuresQuery
-
- {{ formatMessage(messages.title) }}
-
+
+
{{ formatMessage(messages.title) }}
@@ -328,103 +95,38 @@ const { confirmLeaveModal } = usePageLeaveSafety(hasChanges)
+
{{ formatMessage(statusMessages.loading) }}
+
+
{{ formatMessage(statusMessages.loadError) }}
+
+
-
-
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 @@
+
+
@@ -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 5ea4cb0e28c..de6e8f30e9d 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"
/>
+
-
+
@@ -49,6 +50,8 @@
autocomplete="off"
:disabled="!hasPermission"
wrapper-class="w-full"
+ @focusin="onSlugSuggestionFocusIn"
+ @focusout="onSlugSuggestionFocusOut"
>
@@ -57,6 +60,7 @@
+
+
@@ -129,6 +134,7 @@
class="mt-2"
/>
+
@@ -355,6 +361,7 @@ 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'
diff --git a/apps/frontend/src/pages/[type]/[project]/settings/license.vue b/apps/frontend/src/pages/[type]/[project]/settings/license.vue
index 7f355f523aa..eac3722e2e8 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 +115,7 @@
@@ -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..961cd0aa908 100644
--- a/apps/frontend/src/pages/[type]/[project]/settings/tags.vue
+++ b/apps/frontend/src/pages/[type]/[project]/settings/tags.vue
@@ -21,6 +21,7 @@ import {
import { capitalizeString, 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 { useProjectNagMessages } from '~/composables/project-nag-validation'
import { useProjectSaveValidation } from '~/composables/project-save-validation'
@@ -429,6 +430,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..a994bcecd93
--- /dev/null
+++ b/apps/frontend/src/providers/project-review/project.ts
@@ -0,0 +1,177 @@
+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'
+
+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) =>
+ message.body.type === 'status_change' && 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 === '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-messages.ts b/apps/frontend/src/providers/project-review/review-messages.ts
new file mode 100644
index 00000000000..d1a62cb980b
--- /dev/null
+++ b/apps/frontend/src/providers/project-review/review-messages.ts
@@ -0,0 +1,61 @@
+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) {
+ return overrides.get(id) ?? issueMessages.value.get(id) ?? defaults.get(id) ?? ''
+ }
+ function editIssueMessage(id: string, message: string) {
+ 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)
+ }
+ watch(
+ () => project.value?.id,
+ () => {
+ overrides.clear()
+ defaults.clear()
+ },
+ { flush: 'sync' },
+ )
+
+ return {
+ generating,
+ issueMessage,
+ setIssueDefault: (id: string, message: string) => {
+ defaults.set(id, message)
+ },
+ editIssueMessage,
+ resetIssueMessage,
+ 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..7800b2c1c5e
--- /dev/null
+++ b/apps/frontend/src/providers/project-review/review-panels.ts
@@ -0,0 +1,679 @@
+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 { resolveIssueFacets } from '@modrinth/moderation/src/data/issues/component-builders/targets'
+import type {
+ Issue,
+ 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 interface ReviewIssueSelection {
+ active: boolean
+ toggle_ids: string[]
+ text_values: Record
+ select_values: Record
+}
+
+export interface ReviewIssueControl {
+ binding: ReviewPanelBinding
+ control: ResolvedIssueControl
+}
+
+export interface ReviewIssue {
+ id: string
+ title: string
+ category: string
+ priority?: Issue['priority']
+ controls: ReviewIssueControl[]
+}
+
+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 }
+ >,
+ definitions: Record = reviewPanels,
+) {
+ 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
+ }
+
+ 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) => (issueId ? (textValues(projectV3.id, issueId)[id] ?? '') : ''),
+ getSelectValue: (id, issueId) =>
+ issueId ? (readSelectValues(projectV3.id, issueId, id)[0] ?? '') : '',
+ getSelectValues: (id, issueId) =>
+ issueId ? readSelectValues(projectV3.id, issueId, id) : [],
+ getMarkdownValue: (id, issueId) =>
+ issueId ? (textValues(projectV3.id, issueId)[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 = itemIssues.get(node.issue.id) ?? node.issue
+ const issueId = issue.id
+ const issueContext: ReviewContext = {
+ ...context,
+ getMarkdownValue: (id, scope = issueId) => textValues(projectV3.id, scope)[id] ?? '',
+ getTextValue: (id, scope = issueId) => textValues(projectV3.id, scope)[id] ?? '',
+ getSelectValue: (id, scope = issueId) =>
+ readSelectValues(projectV3.id, scope, id)[0] ?? '',
+ getSelectValues: (id, scope = issueId) => readSelectValues(projectV3.id, scope, id),
+ selected: {
+ items: {},
+ issueIds: selectedIssueIds.value,
+ toggleIds: [...selectedToggleIds(projectV3.id, issueId)],
+ },
+ }
+ 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) {
+ 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) 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 [...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 { active: false, toggle_ids: [], text_values: {}, select_values: {} }
+ const selects = session.read(projectId, 'issue-select')[id]
+ return {
+ active: session.read(projectId, 'issue-active')[id] === true,
+ 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 = JSON.stringify({ id, ...issueSelection(id) })
+ return Object.values(session.read(projectId, 'previous-issue-selection')).some(
+ (saved) => saved === selection,
+ )
+ }
+
+ function removeIssue(id: string) {
+ if (!project.value) return
+ for (const scope of ['issue-active', 'issues', 'issue-text', 'issue-select', 'issue-order']) {
+ session.write(project.value.id, scope, id, undefined)
+ }
+ }
+
+ const activeIssues = computed(() => {
+ const projectV3 = project.value
+ if (!projectV3) return []
+ const issues = new Map()
+ for (const entry of availableIssues.value) {
+ if (session.read(projectV3.id, 'issue-active')[entry.id] !== true) continue
+ issues.set(entry.id, {
+ issue: 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.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] ?? '',
+ getTextValue: (key, scope = id) => issues.get(scope)?.textValues[key] ?? '',
+ getSelectValue: (key, scope = id) => issues.get(scope)?.selectValues[key]?.[0] ?? '',
+ getSelectValues: (key, scope = id) => issues.get(scope)?.selectValues[key] ?? [],
+ }
+ const controls = availableIssues.value.find((entry) => entry.id === id)?.controls ?? []
+ if (
+ !keys.size &&
+ !controls.some(({ control }) => control.type === 'toggle' && control.id === undefined)
+ ) {
+ missing.push('toggle')
+ }
+ return {
+ id,
+ missing: [...new Set(missing)],
+ locations: [...(resolveWithContext(issue.locations, context) ?? [])],
+ facets: resolveIssueFacets(issue.facets, 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 }))),
+ )
+
+ 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),
+ ),
+ ),
+ ),
+ availableIssues,
+ addIssue,
+ issueSelection,
+ 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..40d467d8d2a
--- /dev/null
+++ b/apps/frontend/src/providers/project-review/review-previous-issues.ts
@@ -0,0 +1,401 @@
+import type { Labrinth } from '@modrinth/api-client'
+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
+
+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 why = issue.why
+ const id = why && typeof why === 'object' && 'issue_id' in why ? why.issue_id : undefined
+ return panels.availableIssues.value.find((entry) => 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 id = reviewIssue(issue)?.id
+ return id && isApplicable(issue) && panels.activeIssues.value.some((active) => active.id === id)
+ ? messages.issueMessage(id)
+ : issueMessage(issue)
+ }
+
+ function isApplicable(issue: ThreadIssue) {
+ const projectId = project.value?.id
+ if (!projectId) return false
+ return session.read(projectId, 'previous-issue-applicability')[issue.id] === true
+ }
+
+ 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 ?? 'acknowledge'])),
+ category: '',
+ 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_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 === '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)
+ 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)
+ const definition = reviewIssue(issue)
+ if (!definition) return
+ if (panels.activeIssues.value.some(({ id }) => id === definition.id)) {
+ session.write(
+ projectId,
+ 'previous-issue-selection',
+ issue.id,
+ JSON.stringify({ id: definition.id, ...panels.issueSelection(definition.id) }),
+ )
+ return
+ }
+ panels.addIssue(definition.id)
+ const stored = issueDetails(issue).selection
+ const selection =
+ stored && typeof stored === 'object' && !Array.isArray(stored)
+ ? (stored as Record)
+ : undefined
+ const toggleIds = Array.isArray(selection?.toggle_ids)
+ ? new Set(selection.toggle_ids.filter((value): value is string => typeof value === 'string'))
+ : undefined
+ const bindings = toggleIds
+ ? [...new Map(definition.controls.map(({ binding }) => [binding.key, binding])).values()]
+ : issueBindings(issue)
+ for (const binding of bindings) {
+ const toggles = binding.panel.sections
+ .flatMap((section) => section.controls)
+ .filter(
+ (control) =>
+ control.issueId === definition.id && control.type === 'toggle' && !control.disabled,
+ )
+ for (const control of toggles) {
+ if (control.type !== 'toggle') continue
+ if (
+ toggleIds ? control.id !== undefined && toggleIds.has(control.id) : toggles.length === 1
+ )
+ panels.write(binding, control, true)
+ }
+ }
+ for (const { binding, control } of reviewIssue(issue)?.controls ?? []) {
+ if (control.type === 'toggle' || control.disabled) continue
+ const values = control.type === 'select' ? selection?.select_values : selection?.text_values
+ if (!values || typeof values !== 'object' || Array.isArray(values)) continue
+ const value = (values as Record)[control.key]
+ if (
+ control.type === 'select' &&
+ Array.isArray(value) &&
+ value.every((entry) => typeof entry === 'string')
+ )
+ panels.write(binding, control, value)
+ else if (control.type !== 'select' && typeof value === 'string')
+ panels.write(binding, control, value)
+ }
+ session.write(
+ projectId,
+ 'previous-issue-selection',
+ issue.id,
+ JSON.stringify({ id: definition.id, ...panels.issueSelection(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
+ ? selection?.active === true
+ : Array.isArray(selection?.toggle_ids) && selection.toggle_ids.includes(control.id)
+ }
+
+ const reReviewIssues = computed(() =>
+ issues.value.filter((issue) => !isApplicable(issue) && !isResolved(issue)),
+ )
+ const resolvedIssues = computed(() =>
+ issues.value.filter((issue) => !isApplicable(issue) && isResolved(issue)),
+ )
+
+ 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),
+ ),
+ )
+ const recreatedIssues = computed(() =>
+ appliedIssues.value
+ .filter(isResolved)
+ .flatMap((issue): Labrinth.Threads.v3.NewThreadIssue[] => {
+ const id = reviewIssue(issue)?.id
+ const active = panels.activeIssues.value.find((active) => active.id === id)
+ const facets = active?.facets ?? issue.facets.map(({ what }) => ({ what }))
+ const [first, ...rest] = facets
+ if (!first) return []
+ return [
+ {
+ why: {
+ ...issueDetails(issue),
+ message: reviewMessage(issue),
+ ...(active
+ ? { selection: panels.issueSelection(active.id), locations: active.locations }
+ : {}),
+ },
+ facets: [first, ...rest],
+ },
+ ]
+ }),
+ )
+ const issueUpdates = computed(() =>
+ issues.value
+ .filter((issue) => !issue.moderator_verified || isApplicable(issue))
+ .map((issue) => {
+ if (isResolved(issue)) return { id: issue.id, data: { moderator_verified: true } }
+ const applicable = isApplicable(issue)
+ const id = reviewIssue(issue)?.id
+ const active = panels.activeIssues.value.find((active) => active.id === id)
+ const changed = applicable && !!active && !panels.isRestoredIssue(active.id)
+ const data: Labrinth.Threads.v3.EditThreadIssue = applicable
+ ? { user_addressed: false }
+ : { moderator_verified: true }
+ if ((applicable && active) || messages.hasIssueOverride(messageKey(issue))) {
+ data.why = {
+ ...issueDetails(issue),
+ message: reviewMessage(issue),
+ ...(changed
+ ? { selection: panels.issueSelection(active.id), locations: active.locations }
+ : {}),
+ }
+ }
+ if (changed && active.facets) data.facets = active.facets
+ return { id: issue.id, data }
+ }),
+ )
+
+ 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,
+ ),
+ issueUpdates,
+ 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..1a3cb6ea83b
--- /dev/null
+++ b/apps/frontend/src/providers/project-review/review-submission.ts
@@ -0,0 +1,281 @@
+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 { useAuthState } from '~/composables/auth'
+import { isStaff } from '~/helpers/users.js'
+
+import { injectProjectReviewPageContext } from './index'
+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
+type IssueUpdate = { id: string; data: Labrinth.Threads.v3.EditThreadIssue }
+
+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 pendingDecision = ref<{
+ id: string
+ status: ProjectStatus
+ issues?: NewThreadIssues
+ issueUpdates: IssueUpdate[]
+ }>()
+ const uploadedImages = ref([])
+ let disposed = false
+ watch(
+ () => project.value?.id,
+ () => {
+ draft.value = ''
+ uploadedImages.value = []
+ pendingDecision.value = undefined
+ },
+ { flush: 'sync' },
+ )
+ onScopeDispose(() => {
+ disposed = true
+ })
+
+ const errors = defineMessages({
+ 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
+ selected.push({
+ why: {
+ issue_id: id,
+ 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,
+ threadId,
+ body,
+ images = [],
+ privateMessage = false,
+ status,
+ statusAlreadyApplied = false,
+ issues,
+ issueUpdates = [],
+ }: {
+ id: string
+ threadId: string
+ body?: string
+ images?: string[]
+ privateMessage?: boolean
+ status?: ProjectStatus
+ statusAlreadyApplied?: boolean
+ issues?: NewThreadIssues
+ issueUpdates?: IssueUpdate[]
+ }) => {
+ assertCurrent(id)
+ if (status && !statusAlreadyApplied) {
+ if (disclosures.hasChanges.value || disclosures.saving.value)
+ throw new Error(formatMessage(errors.unsaved))
+ if (panels.validationErrors.value.length) throw new Error(formatMessage(errors.missing))
+ }
+ assertCurrent(id)
+ if (status && !statusAlreadyApplied) {
+ await client.labrinth.projects_v3.edit(id, { status })
+ pendingDecision.value = { id, status, issues, issueUpdates }
+ }
+ assertCurrent(id)
+ if (body?.trim()) {
+ await client.labrinth.threads_v3.sendMessage(threadId, {
+ body: {
+ type: 'text',
+ body,
+ private: privateMessage,
+ associated_images: images,
+ },
+ })
+ if (!disposed && project.value?.id === id) {
+ draft.value = ''
+ uploadedImages.value = []
+ }
+ }
+ assertCurrent(id)
+ if (status) {
+ for (const issue of issueUpdates) {
+ assertCurrent(id)
+ await client.labrinth.threads_v3.editIssue(issue.id, issue.data)
+ assertCurrent(id)
+ if (pendingDecision.value) {
+ pendingDecision.value.issueUpdates = pendingDecision.value.issueUpdates.filter(
+ ({ id }) => id !== issue.id,
+ )
+ }
+ }
+ }
+ assertCurrent(id)
+ if (status && issues) await client.labrinth.threads_v3.createIssues(threadId, issues)
+ if (status) pendingDecision.value = undefined
+ if (status) session.clearProject(id)
+ },
+ 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
+ },
+ })
+ 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 &&
+ !pending.value,
+ )
+ const loadingAction = computed(() =>
+ submission.isPending.value
+ ? (submission.variables.value?.status ??
+ (submission.variables.value?.privateMessage ? 'note' : 'reply'))
+ : undefined,
+ )
+ const pendingDecisionStatus = computed(() => {
+ const currentPendingDecision = pendingDecision.value
+ return currentPendingDecision?.id === project.value?.id
+ ? currentPendingDecision?.status
+ : undefined
+ })
+
+ async function submit(mode: ReviewEditorMode = 'reply') {
+ const current = project.value
+ if (!canSubmit.value || !current || !draft.value.trim()) return
+ await submission
+ .mutateAsync({
+ id: current.id,
+ threadId: current.thread_id,
+ body: draft.value,
+ images: [...uploadedImages.value],
+ privateMessage: mode === 'note',
+ })
+ .catch(() => undefined)
+ }
+
+ async function submitDecision(status: ProjectStatus) {
+ const current = project.value
+ if (!canSubmit.value || !current || messages.generating.value) return
+ const currentPendingDecision = pendingDecision.value
+ const statusAlreadyApplied =
+ currentPendingDecision?.id === current.id && currentPendingDecision.status === status
+ try {
+ await submission.mutateAsync({
+ id: current.id,
+ threadId: current.thread_id,
+ status,
+ body: draft.value,
+ images: [...uploadedImages.value],
+ statusAlreadyApplied,
+ issues: statusAlreadyApplied ? currentPendingDecision.issues : selectedIssues(),
+ issueUpdates: statusAlreadyApplied
+ ? currentPendingDecision.issueUpdates
+ : previousIssues.issueUpdates.value,
+ })
+ return true
+ } catch {
+ return false
+ }
+ }
+
+ return {
+ draft,
+ pending,
+ canSubmit,
+ loadingAction,
+ pendingDecisionStatus,
+ submit,
+ submitDecision,
+ 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..d0fde6881fd
--- /dev/null
+++ b/apps/frontend/src/providers/project-review/review.ts
@@ -0,0 +1,25 @@
+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 }
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
-
+
-
+
-
+
{{ formatMessage(messages.editorPreviewToggleLabel) }}
+
-
+
-
-
+
-
+
+
import { history, historyKeymap, indentWithTab } from '@codemirror/commands'
import { markdown } from '@codemirror/lang-markdown'
-import { Compartment, EditorState } from '@codemirror/state'
+import { Compartment, EditorState, type Extension } from '@codemirror/state'
import { EditorView, keymap, placeholder as cm_placeholder } from '@codemirror/view'
import {
AlignLeftIcon,
@@ -326,7 +333,17 @@ import {
} from '@modrinth/assets'
import { markdownCommands, modrinthMarkdownEditorKeymap } from '@modrinth/utils/codemirror'
import { renderHighlightedString } from '@modrinth/utils/highlightjs/index'
-import { type Component, computed, onBeforeUnmount, onMounted, ref, toRef, useId, watch } from 'vue'
+import {
+ type Component,
+ computed,
+ nextTick,
+ onBeforeUnmount,
+ onMounted,
+ ref,
+ toRef,
+ useId,
+ watch,
+} from 'vue'
import Button from '#ui/components/base/buttons/Button.vue'
import IconButton from '#ui/components/base/buttons/IconButton.vue'
@@ -542,8 +559,12 @@ const messages = defineMessages({
const props = withDefaults(
defineProps<{
modelValue: string
+ extensions?: Extension[]
disabled?: boolean
headingButtons?: boolean
+ hideFormattingButtons?: boolean
+ initialPreview?: boolean
+ hideMarkdownHint?: boolean
/**
* @param file The file to upload
* @throws If the file is invalid or the upload fails
@@ -558,6 +579,9 @@ const props = withDefaults(
modelValue: '',
disabled: false,
headingButtons: true,
+ hideFormattingButtons: false,
+ initialPreview: false,
+ hideMarkdownHint: false,
onImageUpload: undefined,
placeholder: undefined,
maxLength: undefined,
@@ -570,10 +594,30 @@ const editorRef = ref
()
let editor: EditorView | null = null
let isDisabledCompartment: Compartment | null = null
let editorThemeCompartment: Compartment | null = null
+const extensionsCompartment = new Compartment()
const previewId = useId()
-const emit = defineEmits(['update:modelValue'])
+function focusEditorFrame(event: MouseEvent) {
+ if (
+ event.button !== 0 ||
+ props.disabled ||
+ previewMode.value ||
+ !editor ||
+ editor.contentDOM.contains(event.target as Node)
+ )
+ return
+ const position =
+ editor.posAtCoords({ x: event.clientX, y: event.clientY }) ?? editor.state.doc.length
+ editor.dispatch({ selection: { anchor: position } })
+ editor.focus()
+ event.preventDefault()
+}
+
+const emit = defineEmits<{
+ 'update:modelValue': [value: string]
+ ready: [view: EditorView]
+}>()
const resolvedPlaceholder = computed(
() => props.placeholder ?? formatMessage(messages.editorPlaceholder),
)
@@ -588,7 +632,7 @@ function createEditorTheme(disabled = props.disabled) {
outline: 'none',
},
'.cm-content': {
- minHeight: props.minHeight ? `${props.minHeight}px` : '200px',
+ minHeight: props.minHeight ? `max(100%, ${props.minHeight}px)` : 'max(100%, 200px)',
padding: '0',
caretColor: 'var(--color-contrast)',
width: '100%',
@@ -691,6 +735,8 @@ onMounted(() => {
const editorState = EditorState.create({
extensions: [
+ EditorView.lineWrapping,
+ extensionsCompartment.of(props.extensions ?? []),
eventHandlers,
updateListener,
keymap.of([indentWithTab]),
@@ -721,8 +767,16 @@ onMounted(() => {
insert: props.modelValue,
},
})
+ emit('ready', editor)
})
+watch(
+ () => props.extensions,
+ (extensions) => {
+ editor?.dispatch({ effects: extensionsCompartment.reconfigure(extensions ?? []) })
+ },
+)
+
onBeforeUnmount(() => {
editor?.destroy()
})
@@ -870,7 +924,16 @@ const updateCurrentValue = (newValue: string) => {
emit('update:modelValue', newValue)
}
-const previewMode = ref(false)
+const previewMode = ref(props.initialPreview)
+
+async function focus() {
+ if (props.disabled) return
+ previewMode.value = false
+ await nextTick()
+ editor?.focus()
+}
+
+defineExpose({ focus })
const linkText = ref('')
const linkUrl = ref('')
@@ -1195,4 +1258,8 @@ function openVideoModal() {
pointer-events: none;
cursor: not-allowed;
}
+
+:deep(.cm-line) {
+ padding: 0 !important;
+}
diff --git a/packages/ui/src/components/base/MultiSelect.vue b/packages/ui/src/components/base/MultiSelect.vue
index 184999e1ed6..cf744306b92 100644
--- a/packages/ui/src/components/base/MultiSelect.vue
+++ b/packages/ui/src/components/base/MultiSelect.vue
@@ -266,7 +266,10 @@
>
{{ item.label }}
diff --git a/packages/ui/src/components/external_files/AttributionEditor.vue b/packages/ui/src/components/external_files/AttributionEditor.vue
index b9d3fb40d62..50d61ed5dcb 100644
--- a/packages/ui/src/components/external_files/AttributionEditor.vue
+++ b/packages/ui/src/components/external_files/AttributionEditor.vue
@@ -12,7 +12,7 @@ import {
} from '@modrinth/assets'
import { defaultLicenseIds } from '@modrinth/utils'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
-import { computed, ref, watch } from 'vue'
+import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
import { Chips, Combobox, type ComboboxOption, Input, Textarea } from '#ui/components'
import { FileInput } from '#ui/components/base'
@@ -169,6 +169,7 @@ const selectedKind = ref
>('customLicenseInputRef')
const linkInput = ref('')
const notesInput = ref('')
const inputError = ref(null)
@@ -214,9 +215,12 @@ function resetInputs() {
resetInputs()
-watch(licenseIdInput, (value) => {
+watch(licenseIdInput, async (value) => {
if (value !== CUSTOM_LICENSE_VALUE) {
customLicenseInput.value = ''
+ } else {
+ await nextTick()
+ customLicenseInputRef.value?.focus()
}
})
@@ -546,6 +550,7 @@ function cancelEditing() {
}}
(),
{
isModerator: false,
+ pixelated: undefined,
},
)
@@ -86,7 +90,8 @@ const { formatMessage } = useVIntl()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const { addNotification } = injectNotificationManager()
-const { allMembers } = injectProjectPageContext()
+const projectContext = injectProjectPageContext(null)
+const allMembers = computed(() => props.members ?? projectContext?.allMembers.value ?? [])
const attributionModeration = injectAttributionModeration(null)
const attributorMember = computed(() => {
@@ -126,6 +131,10 @@ const messages = defineMessages({
id: 'external-files.permissions-card.included-files',
defaultMessage: 'Included files:',
},
+ fileDatabaseId: {
+ id: 'external-files.permissions-card.file-database-id',
+ defaultMessage: 'Database ID',
+ },
notUsedInVersions: {
id: 'external-files.permissions-card.not-used-in-versions',
defaultMessage: 'These files are not currently used by any version.',
@@ -573,10 +582,21 @@ const visibleQuickReplies = computed(() => {
}"
>
-
+
{{ file.name.split('/').pop() }}
+
+ SHA-1
+
+
+ {{ formatMessage(messages.fileDatabaseId) }}
+
+
+
(() => {
:attributor-label="attributorLabel"
:attributor-avatar-url="attributorMember?.user.avatar_url"
:moderator="isModerator"
+ :pixelated="pixelated"
>
+
+
+
diff --git a/packages/ui/src/components/external_files/OriginalPageLink.vue b/packages/ui/src/components/external_files/OriginalPageLink.vue
index dfcc1267885..57630fb986c 100644
--- a/packages/ui/src/components/external_files/OriginalPageLink.vue
+++ b/packages/ui/src/components/external_files/OriginalPageLink.vue
@@ -20,6 +20,7 @@ const label = defineMessage({
target="_blank"
rel="noopener"
:href="href"
+ @click.stop
>
{{ formatMessage(label) }}
diff --git a/packages/ui/src/components/image-viewer-editor/editor.vue b/packages/ui/src/components/image-viewer-editor/editor.vue
index 6edbaae1503..6254d600613 100644
--- a/packages/ui/src/components/image-viewer-editor/editor.vue
+++ b/packages/ui/src/components/image-viewer-editor/editor.vue
@@ -35,6 +35,7 @@ const props = defineProps<{
index: number
count: number
canEdit: boolean
+ pixelated: boolean
saving: boolean
loadData: (item: ImageViewerEditorItem) => Promise
}>()
@@ -111,7 +112,7 @@ const canViewZoomIn = computed(() => canZoomIn.value && viewZoom.value < MAX_VIE
const nativeImageView = computed(
() =>
props.mode === 'view' &&
- [props.item.src, props.item.editorSource?.path].some(isGifOrWebpSource),
+ (props.pixelated || [props.item.src, props.item.editorSource?.path].some(isGifOrWebpSource)),
)
let resizeObserver: ResizeObserver | undefined
@@ -388,6 +389,7 @@ defineExpose({ markSaved })
class="editor-viewport absolute inset-0 flex min-h-0 min-w-0 select-none overflow-auto px-6 pb-[5.75rem] pt-[4.75rem] max-[900px]:px-4 max-[900px]:pb-[8.5rem] max-[900px]:pt-[4.5rem]"
:class="{
'is-view': mode === 'view',
+ 'is-pixelated': mode === 'view' && pixelated,
'is-view-zoomed': mode === 'view' && viewZoomed,
'is-panning': panning,
'is-pan-ready': mode === 'edit' && spacePressed && !panning,
@@ -523,6 +525,10 @@ defineExpose({ markSaved })