Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
b4c5d62
feat: start modview layout
tdgao Sep 15, 2026
001a7f1
feat: project info panel
tdgao Sep 16, 2026
ee8d420
rename: workspace to layout
tdgao Sep 16, 2026
20232bd
feat: add default layout configuration + double click divider to reset
tdgao Sep 16, 2026
d083fce
feat: polish layout and styles/transitions
tdgao Sep 16, 2026
5b1dd7f
feat: scaffold out the main content panels
tdgao Sep 16, 2026
bc1fd1c
refactor: clean up bg
tdgao Sep 17, 2026
1eef504
feat: style project info panel
tdgao Sep 17, 2026
2f7f4d4
pnpm prepr
tdgao Sep 17, 2026
496c9aa
fix: missing m-0 for p tags
tdgao Sep 17, 2026
ccb354a
small style fix
tdgao Sep 17, 2026
ef0bfe2
small style fix on member
tdgao Sep 17, 2026
f8b5151
again
tdgao Sep 17, 2026
decd377
feat: popover panels for review stages
tdgao Sep 18, 2026
0b3b366
feat: polish version cards
tdgao Sep 18, 2026
73b28ab
polish
tdgao Sep 18, 2026
27598d3
feat: hook up rest of stages from existing checklist
tdgao Sep 18, 2026
9a6d7d7
feat: generate message when buttons are toggled
tdgao Sep 18, 2026
9650f7a
feat: add new issues framework
tdgao Sep 21, 2026
519324c
remove: side panel transition
tdgao Sep 21, 2026
7e7e143
feat: add pin panel and polish conversation thread
tdgao Sep 21, 2026
6d629a4
fix: review panel hover race
tdgao Sep 21, 2026
f093aee
feat: add ai icon message
tdgao Sep 21, 2026
8ebabd8
feat: implement disclosures for modview
tdgao Sep 21, 2026
88f9bf7
feat: polish version cards content
tdgao Sep 22, 2026
0abb490
feat: polish panels and spacing
tdgao Sep 22, 2026
3a064b3
feat: polish panel and more styles
tdgao Sep 22, 2026
94a81e2
feat: polish layout, spacing, and sizing
tdgao Sep 22, 2026
9db6d8f
feat: add modpack permissions to project review
tdgao Sep 22, 2026
d9370d6
feat: add private note tab
tdgao Sep 22, 2026
29858a4
fix: review panel hides when selecting option in combobox
tdgao Sep 22, 2026
aac54ad
fix: missing external link button
tdgao Sep 22, 2026
162e840
feat: gap in main content
tdgao Sep 22, 2026
f81f626
fix: review panel padding
tdgao Sep 22, 2026
648c600
fix: toggle transition all
tdgao Sep 22, 2026
3421a1f
feat: add keyboard shortcuts in project review
tdgao Sep 22, 2026
dba7b6d
refactor: move review panel registry to moderation package owned
tdgao Sep 22, 2026
2277ca3
fix: wrong close panel arrow
tdgao Sep 22, 2026
00c7856
fix: muted active tags
tdgao Sep 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/frontend/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import { fileURLToPath } from 'url'
import svgLoader from 'vite-svg-loader'

import { GenericModrinthClient, type Labrinth } from '../../packages/api-client/src/index.ts'
import moderationMessages, {
moderationMessagesDirectory,
} from '../../packages/moderation/vite-plugin'

const STAGING_API_URL = 'https://staging-api.modrinth.com/v2/'
const STAGING_SHARED_INSTANCES_API_URL = 'https://staging-shared-instances.modrinth.com'
Expand All @@ -26,6 +29,7 @@ export default defineNuxtConfig({
srcDir: 'src/',
alias: {
'@modrinth/api-client': API_CLIENT_SOURCE,
'#messages': moderationMessagesDirectory,
},
app: {
head: {
Expand Down Expand Up @@ -81,6 +85,7 @@ export default defineNuxtConfig({
dedupe: ['vue'],
},
plugins: [
moderationMessages(),
svgLoader({
svgoConfig: {
plugins: [
Expand Down
1 change: 1 addition & 0 deletions apps/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
"ansi-to-html": "^0.7.2",
"chart.js": "^4.5.1",
"dayjs": "^1.11.7",
"dockview-vue": "^8.2.0",
"dompurify": "^3.1.7",
"@floating-ui/vue": "^2.0.1",
"fuse.js": "^6.6.2",
Expand Down
42 changes: 42 additions & 0 deletions apps/frontend/src/components/project-review/KbdChip.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<template>
<kbd
v-for="(shortcut, index) in shortcuts"
:key="index"
:aria-label="shortcut.label"
class="flex h-[18px] min-w-[18px] shrink-0 items-center justify-center rounded border border-solid border-surface-4 bg-surface-3 px-0.5 font-sans text-[10px] font-medium text-primary"
v-bind="$attrs"
>
<ArrowBigUpIcon v-if="shortcut.shift" class="size-3" aria-hidden="true" />
{{ shortcut.text }}
</kbd>
</template>

<script setup lang="ts">
import { ArrowBigUpIcon } from '@modrinth/assets'
import { moderationSettings } from '@modrinth/moderation'
import { computed } from 'vue'

import { useModerationKeybinds, useModerationSettings } from '~/composables/moderation'

defineOptions({ inheritAttrs: false })
const props = defineProps<{ keybind: string }>()
const keybinds = useModerationKeybinds()
const settings = useModerationSettings()
const shortcuts = computed(() => {
if (!settings.value.get(moderationSettings.General.ShowShortcutKeybindHints)) return []
const binding = [...keybinds.value].find(([id]) => id === props.keybind)?.[1]
return (
binding?.keybind.map((definition) => {
const modifiers = []
if (definition.ctrl || definition.meta) modifiers.push('Ctrl/Cmd')
if (definition.alt) modifiers.push('Alt')
const key = definition.key.toUpperCase()
return {
shift: definition.shift,
label: [...modifiers, ...(definition.shift ? ['Shift'] : []), key].join('+'),
text: [...modifiers, key].join(' '),
}
}) ?? []
)
})
</script>
218 changes: 218 additions & 0 deletions apps/frontend/src/components/project-review/conversation.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
<template>
<div class="flex h-full min-h-0 min-w-0 flex-col gap-2.5">
<ProjectActions />
<div
v-if="project"
ref="scrollContainer"
class="flex min-h-0 min-w-0 flex-1 flex-col overflow-y-auto overscroll-contain"
@scroll="updateScrollPosition"
>
<div ref="conversationContent" class="mt-auto shrink-0">
<ConversationThread
v-if="thread"
v-model:reply-body="draft"
:thread="thread"
:generating-message="generating"
:project="project"
:auth="auth"
:set-status="setStatus"
:before-send-reply="beforeSendReply"
initial-preview
project-review
review-keybinds
class="rounded-none border-none bg-transparent p-0 text-xs"
@update-thread="updateThread"
@open-editor="openEditor"
/>
<div v-else-if="isError" class="flex flex-col gap-3 p-4">
<p class="m-0 text-red" role="alert">
{{ formatMessage(messages.loadError) }}
</p>
<Button class="w-fit" @click="() => refetch()">
{{ formatMessage(messages.retry) }}
</Button>
</div>
<p v-else class="m-0 p-4 text-secondary" role="status">
{{ formatMessage(messages.loading) }}
</p>
</div>
</div>
</div>
</template>

<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
Button,
commonMessages,
defineMessages,
injectModrinthClient,
injectNotificationManager,
useVIntl,
} from '@modrinth/ui'
import { useMutation, useQueryClient } from '@tanstack/vue-query'
import { useResizeObserver } from '@vueuse/core'
import { nextTick, ref, watch } from 'vue'

import ConversationThread from '~/components/ui/thread/ConversationThread.vue'
import { injectProjectReviewPageContext } from '~/providers/project-review'
import { injectReviewMessages } from '~/providers/project-review/review-messages'
import { injectReviewPanels } from '~/providers/project-review/review-panels'

import { projectReviewMessages as messages } from './messages'
import ProjectActions from './project-actions.vue'
import { injectProjectReviewContext } from './layout/context'

const { formatMessage } = useVIntl()
const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const auth = useAuthState()
const { draft, generating } = injectReviewMessages()
const panels = injectReviewPanels()
const { project, threadQuery, disclosures } = injectProjectReviewPageContext()
const { data: thread, isError, refetch } = threadQuery

const scrollContainer = ref<HTMLElement>()
const conversationContent = ref<HTMLElement>()
const isAtBottom = ref(true)
const { rightVisible, toggleSidebar } = injectProjectReviewContext()

async function openEditor() {
if (!rightVisible.value) toggleSidebar('right')
isAtBottom.value = true
await nextTick()
scrollToBottom()
}

function updateScrollPosition() {
const container = scrollContainer.value
if (!container) return
isAtBottom.value = container.scrollHeight - container.scrollTop - container.clientHeight <= 1
}

function scrollToBottom() {
const container = scrollContainer.value
if (container) container.scrollTop = container.scrollHeight
}

useResizeObserver([scrollContainer, conversationContent], () => {
if (isAtBottom.value) scrollToBottom()
})

watch(
[scrollContainer, () => thread.value?.id],
() => {
isAtBottom.value = true
scrollToBottom()
},
{ flush: 'post' },
)

function updateThread(updatedThread: Labrinth.Threads.v3.Thread | null | undefined) {
if (!updatedThread) return
queryClient.setQueryData(['thread', updatedThread.id], updatedThread)
}

const correctionMessages = defineMessages({
unsavedDisclosures: {
id: 'project-review.disclosures.unsaved',
defaultMessage:
'Save or reset your disclosure changes before sending a reply or changing the project status.',
},
missing: {
id: 'project-review.corrections.missing-fields',
defaultMessage: 'Complete the required review fields before sending your reply.',
},
conflict: {
id: 'project-review.corrections.conflicts',
defaultMessage: 'Resolve conflicting corrections before sending your reply.',
},
projectChanged: {
id: 'project-review.corrections.project-changed',
defaultMessage: 'The selected project changed. Review the corrections again.',
},
})

const correctionMutation = useMutation({
mutationFn: async ({ id, plan }: { id: string; plan: typeof panels.corrections.value }) => {
if (Object.keys(plan.project).length) await client.labrinth.projects_v3.edit(id, plan.project)
for (const [versionId, patch] of Object.entries(plan.versions)) {
if (Object.keys(patch).length)
await client.labrinth.versions_v3.modifyVersion(versionId, patch)
}
},
onSettled: async (_, __, { id, plan }) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['project', 'v3', id] }),
queryClient.invalidateQueries({ queryKey: ['project', 'v2', id] }),
queryClient.invalidateQueries({ queryKey: ['project', id] }),
...Object.keys(plan.versions).map((id) =>
queryClient.invalidateQueries({ queryKey: ['version', id] }),
),
])
},
})

async function beforeSendReply({ privateMessage }: { privateMessage: boolean }) {
if (privateMessage) return
if (disclosures.hasChanges.value || disclosures.saving.value)
throw new Error(formatMessage(correctionMessages.unsavedDisclosures))
if (panels.validationErrors.value.length)
throw new Error(formatMessage(correctionMessages.missing))
if (!panels.correctionsRequested.value) return
const current = project.value
if (!current) throw new Error(formatMessage(correctionMessages.projectChanged))
const plan = panels.corrections.value
if (plan.conflicts.length) throw new Error(formatMessage(correctionMessages.conflict))
if (Object.keys(plan.versions).some((id) => !current.versions.includes(id)))
throw new Error(formatMessage(correctionMessages.projectChanged))
if (!plan.issueIds.length) return
const reply = draft.value
try {
await correctionMutation.mutateAsync({ id: current.id, plan })
} finally {
if (project.value?.id === current.id) draft.value = reply
}
if (project.value?.id !== current.id)
throw new Error(formatMessage(correctionMessages.projectChanged))
}

const statusMutation = useMutation({
mutationFn: ({
id,
status,
}: {
id: string
threadId: string
status: Labrinth.Projects.v2.ProjectStatus
}) => client.labrinth.projects_v3.edit(id, { status }),
onSuccess: 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] }),
])
},
})

async function setStatus(status: Labrinth.Projects.v2.ProjectStatus) {
if (!project.value) return
try {
if (disclosures.hasChanges.value || disclosures.saving.value)
throw new Error(formatMessage(correctionMessages.unsavedDisclosures))
await statusMutation.mutateAsync({
id: project.value.id,
threadId: project.value.thread_id,
status,
})
} catch (error) {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: error instanceof Error ? error.message : String(error),
type: 'error',
})
}
}
</script>
37 changes: 37 additions & 0 deletions apps/frontend/src/components/project-review/description/index.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<template>
<div class="flex h-full min-h-0 flex-col gap-2.5 overflow-hidden">
<ReviewPanel mode="inline" :target="{ kind: 'description' }" :disabled="isLoading || !!error" />
<div class="min-h-0 flex-1 overflow-auto">
<p v-if="!selection" class="m-0 text-secondary">
{{ formatMessage(messages.empty) }}
</p>
<p v-else-if="isLoading" role="status" class="m-0">
{{ formatMessage(messages.loading) }}
</p>
<div v-else-if="error" role="alert">
<p class="m-0">{{ formatMessage(messages.loadError) }}</p>
<Button @click="refresh">{{ formatMessage(messages.retry) }}</Button>
</div>
<ProjectPageDescription
v-else-if="project?.description?.trim()"
class="text-sm leading-tight"
:description="project.description"
/>
<p v-else class="m-0 text-secondary">
{{ formatMessage(messages.emptyDescription) }}
</p>
</div>
</div>
</template>

<script setup lang="ts">
import { Button, ProjectPageDescription, useVIntl } from '@modrinth/ui'

import { injectProjectReviewPageContext } from '~/providers/project-review'

import { projectReviewMessages as messages } from '../messages'
import ReviewPanel from '../review-panel/index.vue'

const { formatMessage } = useVIntl()
const { selection, project, isLoading, error, refresh } = injectProjectReviewPageContext()
</script>
Loading