diff --git a/apps/frontend/COPYING.md b/apps/frontend/COPYING.md index 9a54fca3a2e..07c2bc471b1 100644 --- a/apps/frontend/COPYING.md +++ b/apps/frontend/COPYING.md @@ -21,6 +21,8 @@ This includes, but may not be limited to, the following files: - src/public/favicon-light-32x32.png - src/public/favicon-settings-32x32.png - src/public/favicon-light-settings-32x32.png +- src/public/favicon-review.svg +- src/public/favicon-light-review.svg - src/public/dev-favicons/\* - src/public/news/\* diff --git a/apps/frontend/nuxt.config.ts b/apps/frontend/nuxt.config.ts index 50c2b5044b7..a161d196fe9 100644 --- a/apps/frontend/nuxt.config.ts +++ b/apps/frontend/nuxt.config.ts @@ -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' @@ -26,6 +29,7 @@ export default defineNuxtConfig({ srcDir: 'src/', alias: { '@modrinth/api-client': API_CLIENT_SOURCE, + '#messages': moderationMessagesDirectory, }, app: { head: { @@ -81,6 +85,7 @@ export default defineNuxtConfig({ dedupe: ['vue'], }, plugins: [ + moderationMessages(), svgLoader({ svgoConfig: { plugins: [ diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 638a52f8e8a..7bb71cc061c 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -38,6 +38,9 @@ "wrangler": "^4.115.0" }, "dependencies": { + "@codemirror/commands": "^6.3.2", + "@codemirror/language": "^6.9.3", + "@codemirror/view": "^6.22.1", "@contentauth/c2pa-web": "^0.13.1", "@formatjs/intl-localematcher": "^0.5.4", "@ltd/j-toml": "^1.38.0", @@ -59,6 +62,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", diff --git a/apps/frontend/src/components/ProjectStatusStats.vue b/apps/frontend/src/components/ProjectStatusStats.vue new file mode 100644 index 00000000000..27bf7c5313c --- /dev/null +++ b/apps/frontend/src/components/ProjectStatusStats.vue @@ -0,0 +1,117 @@ + + + diff --git a/apps/frontend/src/components/project-review/description/index.vue b/apps/frontend/src/components/project-review/description/index.vue new file mode 100644 index 00000000000..192ee524bce --- /dev/null +++ b/apps/frontend/src/components/project-review/description/index.vue @@ -0,0 +1,102 @@ + + + diff --git a/apps/frontend/src/components/project-review/disclosures/index.vue b/apps/frontend/src/components/project-review/disclosures/index.vue new file mode 100644 index 00000000000..8498b47625f --- /dev/null +++ b/apps/frontend/src/components/project-review/disclosures/index.vue @@ -0,0 +1,70 @@ + + + diff --git a/apps/frontend/src/components/project-review/gallery/index.vue b/apps/frontend/src/components/project-review/gallery/index.vue new file mode 100644 index 00000000000..f54ca4af977 --- /dev/null +++ b/apps/frontend/src/components/project-review/gallery/index.vue @@ -0,0 +1,120 @@ + + + + + diff --git a/apps/frontend/src/components/project-review/image-viewer-actions.vue b/apps/frontend/src/components/project-review/image-viewer-actions.vue new file mode 100644 index 00000000000..a8ba9128f05 --- /dev/null +++ b/apps/frontend/src/components/project-review/image-viewer-actions.vue @@ -0,0 +1,87 @@ + + + diff --git a/apps/frontend/src/components/project-review/index.vue b/apps/frontend/src/components/project-review/index.vue new file mode 100644 index 00000000000..dd0ba997732 --- /dev/null +++ b/apps/frontend/src/components/project-review/index.vue @@ -0,0 +1,286 @@ + + + diff --git a/apps/frontend/src/components/project-review/issue-list/index.vue b/apps/frontend/src/components/project-review/issue-list/index.vue new file mode 100644 index 00000000000..a5f23f58d86 --- /dev/null +++ b/apps/frontend/src/components/project-review/issue-list/index.vue @@ -0,0 +1,134 @@ + + + diff --git a/apps/frontend/src/components/project-review/issue-list/issue-card.vue b/apps/frontend/src/components/project-review/issue-list/issue-card.vue new file mode 100644 index 00000000000..107d95a27c7 --- /dev/null +++ b/apps/frontend/src/components/project-review/issue-list/issue-card.vue @@ -0,0 +1,438 @@ + + + + + diff --git a/apps/frontend/src/components/project-review/issue-list/issue-picker.vue b/apps/frontend/src/components/project-review/issue-list/issue-picker.vue new file mode 100644 index 00000000000..acb4850444f --- /dev/null +++ b/apps/frontend/src/components/project-review/issue-list/issue-picker.vue @@ -0,0 +1,129 @@ + + + diff --git a/apps/frontend/src/components/project-review/issue-list/issue-toggles.vue b/apps/frontend/src/components/project-review/issue-list/issue-toggles.vue new file mode 100644 index 00000000000..315cd2a606b --- /dev/null +++ b/apps/frontend/src/components/project-review/issue-list/issue-toggles.vue @@ -0,0 +1,260 @@ + + + diff --git a/apps/frontend/src/components/project-review/issue-list/re-review-issues.ts b/apps/frontend/src/components/project-review/issue-list/re-review-issues.ts new file mode 100644 index 00000000000..12c6a991394 --- /dev/null +++ b/apps/frontend/src/components/project-review/issue-list/re-review-issues.ts @@ -0,0 +1,52 @@ +import { injectNotificationManager } from '@modrinth/ui' +import { type Ref, ref } from 'vue' + +import type { ProjectReviewPageContext } from '~/providers/project-review' +import type { createReviewMessages } from '~/providers/project-review/review-messages' +import type { createReviewSession } from '~/providers/project-review/review-session' + +export function useReReviewIssues( + { project, threadQuery }: Pick, + session: ReturnType, + messages: ReturnType, + pending: Ref, +) { + const { handleError } = injectNotificationManager() + const resetting = ref(false) + + async function resetIssues() { + const current = project.value + if (!current || pending.value || resetting.value) return + resetting.value = true + try { + if (current.thread_id) { + const result = await threadQuery.refetch({ throwOnError: true }) + if (result.data?.id !== current.thread_id) return + } + if (project.value?.id !== current.id || pending.value) return + for (const scope of [ + 'issue-active', + 'issues', + 'issue-text', + 'issue-select', + 'issue-order', + 'previous-issue-applicability', + 'previous-issue-selection', + ]) { + for (const id of Object.keys(session.read(current.id, scope))) { + session.write(current.id, scope, id, undefined) + if (scope !== 'previous-issue-applicability') messages.resetIssueMessage(id) + } + } + for (const issue of threadQuery.data.value?.issues ?? []) { + messages.resetIssueMessage(`previous:${issue.id}`) + } + } catch (error) { + handleError(error) + } finally { + resetting.value = false + } + } + + return { resetting, resetIssues } +} diff --git a/apps/frontend/src/components/project-review/kdb-chip.vue b/apps/frontend/src/components/project-review/kdb-chip.vue new file mode 100644 index 00000000000..ffbd749f495 --- /dev/null +++ b/apps/frontend/src/components/project-review/kdb-chip.vue @@ -0,0 +1,42 @@ + + + diff --git a/apps/frontend/src/components/project-review/layout/center.vue b/apps/frontend/src/components/project-review/layout/center.vue new file mode 100644 index 00000000000..fbd2728bff4 --- /dev/null +++ b/apps/frontend/src/components/project-review/layout/center.vue @@ -0,0 +1,28 @@ + + + diff --git a/apps/frontend/src/components/project-review/layout/columns.vue b/apps/frontend/src/components/project-review/layout/columns.vue new file mode 100644 index 00000000000..76b2df7edcc --- /dev/null +++ b/apps/frontend/src/components/project-review/layout/columns.vue @@ -0,0 +1,20 @@ + + + diff --git a/apps/frontend/src/components/project-review/layout/context.ts b/apps/frontend/src/components/project-review/layout/context.ts new file mode 100644 index 00000000000..be4febb17ab --- /dev/null +++ b/apps/frontend/src/components/project-review/layout/context.ts @@ -0,0 +1,15 @@ +import { createContext } from '@modrinth/ui' +import type { Ref } from 'vue' + +import type { ProjectReviewSlot, ProjectReviewSlots } from './types' +import type { useProjectReviewLayout } from './use-layout' + +type ProjectReviewContext = ReturnType & { + slots: ProjectReviewSlots +} + +export const [injectProjectReviewContext, provideProjectReviewContext] = + createContext('ProjectReview') + +export const [injectReviewSlot, provideReviewSlot] = + createContext>('ProjectReviewSlot') diff --git a/apps/frontend/src/components/project-review/layout/controls/left-control.vue b/apps/frontend/src/components/project-review/layout/controls/left-control.vue new file mode 100644 index 00000000000..3957a9b2905 --- /dev/null +++ b/apps/frontend/src/components/project-review/layout/controls/left-control.vue @@ -0,0 +1,12 @@ + + + diff --git a/apps/frontend/src/components/project-review/layout/controls/right-control.vue b/apps/frontend/src/components/project-review/layout/controls/right-control.vue new file mode 100644 index 00000000000..02aad2d1b2f --- /dev/null +++ b/apps/frontend/src/components/project-review/layout/controls/right-control.vue @@ -0,0 +1,12 @@ + + + diff --git a/apps/frontend/src/components/project-review/layout/controls/sidebar-control.vue b/apps/frontend/src/components/project-review/layout/controls/sidebar-control.vue new file mode 100644 index 00000000000..b0dbf06a3ef --- /dev/null +++ b/apps/frontend/src/components/project-review/layout/controls/sidebar-control.vue @@ -0,0 +1,48 @@ + + + diff --git a/apps/frontend/src/components/project-review/layout/index.client.vue b/apps/frontend/src/components/project-review/layout/index.client.vue new file mode 100644 index 00000000000..f620a87f868 --- /dev/null +++ b/apps/frontend/src/components/project-review/layout/index.client.vue @@ -0,0 +1,139 @@ + + + + + diff --git a/apps/frontend/src/components/project-review/layout/layout-storage.ts b/apps/frontend/src/components/project-review/layout/layout-storage.ts new file mode 100644 index 00000000000..2604d4a90c6 --- /dev/null +++ b/apps/frontend/src/components/project-review/layout/layout-storage.ts @@ -0,0 +1,98 @@ +import type { SerializedDockview } from 'dockview-vue' + +import { type ProjectReviewTab, projectReviewTabs } from './types' + +const STORAGE_KEY = 'moderation.project-review.layout.v1' + +export const workspacePanelSizes = { + left: { default: 400, minimum: 200 }, + right: { default: 400, minimum: 200 }, + bottom: { default: 200, minimum: 50 }, + center: { minimum: 100 }, + tabs: { minimum: 100 }, +} as const + +export interface SavedWorkspaceLayout { + leftWidth: number + rightWidth: number + bottomHeight: number + bottomVisible?: boolean + leftVisible: boolean + rightVisible: boolean + tabs: SerializedDockview + tabLayouts?: Record +} + +export function workspaceTabLayoutKey(tabs: readonly ProjectReviewTab[]) { + return projectReviewTabs.filter((tab) => tabs.includes(tab)).join(',') +} + +function isValidTabLayout(layout: SerializedDockview) { + if ( + !layout?.grid || + !layout.panels || + Object.keys(layout.panels).some( + (tab) => !projectReviewTabs.includes(tab as ProjectReviewTab), + ) || + layout.floatingGroups?.length || + layout.popoutGroups?.length || + layout.edgeGroups + ) { + return false + } + + for (const tab of projectReviewTabs) { + const panel = layout.panels[tab] + if (tab === 'permissions' && !panel) continue + if ( + panel?.id !== tab || + panel.contentComponent !== 'ProjectReviewPanel' || + panel.params?.tab !== tab || + panel.params?.slot !== tab + ) { + return false + } + } + + return true +} + +export function readWorkspaceLayout(): SavedWorkspaceLayout | undefined { + try { + const saved: SavedWorkspaceLayout | null = JSON.parse( + localStorage.getItem(STORAGE_KEY) ?? 'null', + ) + if ( + !saved || + ![saved.leftWidth, saved.rightWidth, saved.bottomHeight].every( + (size) => typeof size === 'number' && Number.isFinite(size) && size > 0, + ) || + (saved.bottomVisible !== undefined && typeof saved.bottomVisible !== 'boolean') || + typeof saved.leftVisible !== 'boolean' || + typeof saved.rightVisible !== 'boolean' || + !isValidTabLayout(saved.tabs) || + (saved.tabLayouts !== undefined && + (typeof saved.tabLayouts !== 'object' || + saved.tabLayouts === null || + Object.entries(saved.tabLayouts).some( + ([key, layout]) => + !isValidTabLayout(layout) || + key !== workspaceTabLayoutKey(Object.keys(layout.panels) as ProjectReviewTab[]), + ))) + ) { + return + } + + return saved + } catch { + return + } +} + +export function saveWorkspaceLayout(layout: SavedWorkspaceLayout) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(layout)) + } catch { + // no-op + } +} diff --git a/apps/frontend/src/components/project-review/layout/panel.vue b/apps/frontend/src/components/project-review/layout/panel.vue new file mode 100644 index 00000000000..39d8aa157fd --- /dev/null +++ b/apps/frontend/src/components/project-review/layout/panel.vue @@ -0,0 +1,93 @@ + + + + + diff --git a/apps/frontend/src/components/project-review/layout/tabs/tab.vue b/apps/frontend/src/components/project-review/layout/tabs/tab.vue new file mode 100644 index 00000000000..c4d2533a7e8 --- /dev/null +++ b/apps/frontend/src/components/project-review/layout/tabs/tab.vue @@ -0,0 +1,50 @@ + + + diff --git a/apps/frontend/src/components/project-review/layout/tabs/tabs.vue b/apps/frontend/src/components/project-review/layout/tabs/tabs.vue new file mode 100644 index 00000000000..a9394c4fce8 --- /dev/null +++ b/apps/frontend/src/components/project-review/layout/tabs/tabs.vue @@ -0,0 +1,118 @@ + + + + + diff --git a/apps/frontend/src/components/project-review/layout/types.ts b/apps/frontend/src/components/project-review/layout/types.ts new file mode 100644 index 00000000000..4b7811358e9 --- /dev/null +++ b/apps/frontend/src/components/project-review/layout/types.ts @@ -0,0 +1,16 @@ +import type { VNode } from 'vue' + +export const projectReviewTabs = [ + 'description', + 'gallery', + 'disclosures', + 'versions', + 'permissions', + 'tech-review', +] as const + +export type ProjectReviewTab = (typeof projectReviewTabs)[number] +export type ProjectReviewSlot = ProjectReviewTab | 'left' | 'right' | 'bottom' +export type ProjectReviewSlots = { + [Key in ProjectReviewSlot | 'footer']?: () => VNode[] +} diff --git a/apps/frontend/src/components/project-review/layout/use-layout.ts b/apps/frontend/src/components/project-review/layout/use-layout.ts new file mode 100644 index 00000000000..fdcb7c8cedc --- /dev/null +++ b/apps/frontend/src/components/project-review/layout/use-layout.ts @@ -0,0 +1,351 @@ +import type { + DockviewApi, + DockviewIDisposable, + DockviewReadyEvent, + DockviewWillDropEvent, + DockviewWillShowOverlayLocationEvent, + SplitviewApi, + SplitviewReadyEvent, +} from 'dockview-vue' +import { getGridLocation, LayoutPriority } from 'dockview-vue' +import { onBeforeUnmount, ref, watchEffect } from 'vue' + +import { + readWorkspaceLayout, + saveWorkspaceLayout, + workspacePanelSizes, + workspaceTabLayoutKey, +} from './layout-storage' +import { type ProjectReviewSlot, type ProjectReviewTab, projectReviewTabs } from './types' + +export function useProjectReviewLayout( + getTitle: (tab: ProjectReviewTab) => string, + getTabs: () => readonly ProjectReviewTab[], +) { + const savedLayout = readWorkspaceLayout() + const leftVisible = ref(savedLayout?.leftVisible ?? true) + const rightVisible = ref(savedLayout?.rightVisible ?? true) + const bottomVisible = ref(savedLayout?.bottomVisible ?? true) + let leftWidth = savedLayout?.leftWidth ?? workspacePanelSizes.left.default + let rightWidth = savedLayout?.rightWidth ?? workspacePanelSizes.right.default + let bottomHeight = savedLayout?.bottomHeight ?? workspacePanelSizes.bottom.default + const topLeftGroupId = ref() + const topRightGroupId = ref() + let columns: SplitviewApi | undefined + let rows: SplitviewApi | undefined + let tabsReady = false + let saveTimer: ReturnType | undefined + let tabs: DockviewApi | undefined + let currentTabLayoutKey: string | undefined + const tabLayouts = new Map(Object.entries(savedLayout?.tabLayouts ?? {})) + if (savedLayout) { + tabLayouts.set( + workspaceTabLayoutKey(Object.keys(savedLayout.tabs.panels) as ProjectReviewTab[]), + savedLayout.tabs, + ) + } + const subscriptions: DockviewIDisposable[] = [] + + function saveLayout() { + clearTimeout(saveTimer) + if (!columns || !rows || !tabs || !tabsReady) return + const currentLeftWidth = columns.getPanel('left')?.api.width ?? 0 + const currentRightWidth = columns.getPanel('right')?.api.width ?? 0 + const currentBottomHeight = rows.getPanel('bottom')?.api.height ?? 0 + if (leftVisible.value && currentLeftWidth > 0) leftWidth = currentLeftWidth + if (rightVisible.value && currentRightWidth > 0) rightWidth = currentRightWidth + if (bottomVisible.value && currentBottomHeight > 0) bottomHeight = currentBottomHeight + const currentTabs = tabs.toJSON() + if (currentTabLayoutKey) tabLayouts.set(currentTabLayoutKey, currentTabs) + saveWorkspaceLayout({ + leftWidth, + rightWidth, + bottomHeight, + bottomVisible: bottomVisible.value, + leftVisible: leftVisible.value, + rightVisible: rightVisible.value, + tabs: currentTabs, + tabLayouts: Object.fromEntries(tabLayouts), + }) + } + + function scheduleSave() { + clearTimeout(saveTimer) + saveTimer = setTimeout(saveLayout, 200) + } + + function onColumnsReady({ api }: SplitviewReadyEvent) { + columns = api + api.addPanel({ + id: 'left', + component: 'ProjectReviewPanel', + params: { slot: 'left' }, + minimumSize: workspacePanelSizes.left.minimum, + size: leftWidth, + }) + api.addPanel({ + id: 'center', + component: 'ProjectReviewCenter', + minimumSize: workspacePanelSizes.center.minimum, + priority: LayoutPriority.High, + }) + api.addPanel({ + id: 'right', + component: 'ProjectReviewPanel', + params: { slot: 'right' }, + minimumSize: workspacePanelSizes.right.minimum, + size: rightWidth, + }) + api.getPanel('left')?.api.setSize({ size: leftWidth }) + api.getPanel('right')?.api.setSize({ size: rightWidth }) + api.getPanel('left')?.api.setVisible(leftVisible.value) + api.getPanel('right')?.api.setVisible(rightVisible.value) + subscriptions.push(api.onDidLayoutChange(scheduleSave)) + } + + function onRowsReady({ api }: SplitviewReadyEvent) { + rows = api + api.addPanel({ + id: 'tabs', + component: 'ProjectReviewTabs', + minimumSize: workspacePanelSizes.tabs.minimum, + priority: LayoutPriority.High, + }) + api.addPanel({ + id: 'bottom', + component: 'ProjectReviewPanel', + params: { slot: 'bottom' }, + minimumSize: workspacePanelSizes.bottom.minimum, + size: bottomHeight, + }) + api.getPanel('bottom')?.api.setVisible(bottomVisible.value) + subscriptions.push(api.onDidLayoutChange(scheduleSave)) + } + + function constrainDrop(event: DockviewWillDropEvent | DockviewWillShowOverlayLocationEvent) { + if (!event.getData()) { + event.preventDefault() + } + } + + function updateCornerGroups() { + const groups = + tabs?.groups.map((group) => ({ + id: group.id, + bounds: group.element.getBoundingClientRect(), + })) ?? [] + const top = Math.min(...groups.map(({ bounds }) => bounds.top)) + const topGroups = groups.filter(({ bounds }) => Math.abs(bounds.top - top) < 1) + topGroups.sort((a, b) => a.bounds.left - b.bounds.left) + topLeftGroupId.value = topGroups[0]?.id + topRightGroupId.value = topGroups[topGroups.length - 1]?.id + } + + function onTabsReady({ api }: DockviewReadyEvent) { + tabs = api + subscriptions.push( + api.onWillShowOverlay(constrainDrop), + api.onWillDrop(constrainDrop), + api.onDidLayoutChange(updateCornerGroups), + api.onDidLayoutChange(scheduleSave), + api.onDidActivePanelChange(scheduleSave), + ) + currentTabLayoutKey = workspaceTabLayoutKey(getTabs()) + let restored = false + const savedTabLayout = tabLayouts.get(currentTabLayoutKey) + if (savedTabLayout) { + try { + api.fromJSON(savedTabLayout) + restored = true + } catch { + api.clear() + tabLayouts.delete(currentTabLayoutKey) + } + } + tabsReady = true + syncTabs() + if (!restored) api.getPanel('description')?.api.setActive() + updateCornerGroups() + } + + function openTab(tab: ProjectReviewTab) { + if (!getTabs().includes(tab)) return + tabs?.getPanel(tab)?.api.setActive() + } + + function revealSlot(slot: ProjectReviewSlot) { + if (slot === 'left' || slot === 'right') { + const visible = slot === 'left' ? leftVisible : rightVisible + if (!visible.value) toggleSidebar(slot) + } else if (slot === 'bottom') { + if (!bottomVisible.value) toggleToolsPanel() + } else { + openTab(slot) + } + } + + function resetActiveTabs() { + for (const group of tabs?.groups ?? []) { + const firstPanel = group.panels[0] + if (firstPanel) { + group.model.openPanel(firstPanel, { + skipSetGroupActive: true, + }) + } + } + } + + function toggleSidebar(side: 'left' | 'right') { + const visible = side === 'left' ? leftVisible : rightVisible + const panel = columns?.getPanel(side) + if (!panel || !columns) return + if (visible.value) { + if (side === 'left') leftWidth = panel.api.width + else rightWidth = panel.api.width + } + visible.value = !visible.value + panel.api.setVisible(visible.value) + scheduleSave() + } + + function toggleToolsPanel() { + const panel = rows?.getPanel('bottom') + if (!panel || !rows) return + if (bottomVisible.value) bottomHeight = panel.api.height + bottomVisible.value = !bottomVisible.value + panel.api.setVisible(bottomVisible.value) + scheduleSave() + } + + function onDividerDoubleClick(event: MouseEvent) { + if (!(event.target instanceof HTMLElement)) return + const sash = event.target.closest('.dv-sash:not(.dv-disabled)') + const container = sash?.parentElement + const split = container?.parentElement + if (!sash || !container || !split) return + const index = Array.from(container.children).indexOf(sash) + const columnSplit = split + .closest('.project-review-columns') + ?.querySelector('.dv-split-view-container') + const rowSplit = split + .closest('.project-review-rows') + ?.querySelector('.dv-split-view-container') + if (split === columnSplit && columns) { + const side = index === 0 ? 'left' : 'right' + const panel = columns.getPanel(side) + if (!panel?.api.isVisible) return + const otherSide = columns.getPanel(side === 'left' ? 'right' : 'left') + const otherWidth = otherSide?.api.isVisible ? otherSide.api.width : 0 + panel.api.setSize({ + size: Math.max( + workspacePanelSizes[side].minimum, + Math.min( + workspacePanelSizes[side].default, + columns.width - otherWidth - workspacePanelSizes.center.minimum, + ), + ), + }) + } else if (split === rowSplit && rows) { + rows.getPanel('bottom')?.api.setSize({ + size: Math.max( + workspacePanelSizes.bottom.minimum, + Math.min( + workspacePanelSizes.bottom.default, + rows.height - workspacePanelSizes.tabs.minimum, + ), + ), + }) + } else if (tabs && split.closest('.project-review-tabs')) { + const branch = split.parentElement + if (!branch?.classList.contains('dv-branch-node')) return + const layout = tabs.toJSON() + let node = layout.grid.root + for (const childIndex of getGridLocation(branch)) { + if (!Array.isArray(node.data) || !node.data[childIndex]) return + node = node.data[childIndex] + } + if (!Array.isArray(node.data)) return + const before = node.data[index] + const after = node.data[index + 1] + if (!before?.size || !after?.size) return + const total = before.size + after.size + before.size = Math.round(total / 2) + after.size = total - before.size + tabs.fromJSON(layout, { reuseExistingPanels: true }) + updateCornerGroups() + } else { + return + } + event.preventDefault() + event.stopPropagation() + scheduleSave() + } + + function syncTabs() { + const visibleTabs = getTabs() + const nextTabLayoutKey = workspaceTabLayoutKey(visibleTabs) + const titles = new Map(visibleTabs.map((tab) => [tab, getTitle(tab)])) + if (!tabs || !tabsReady) return + if (currentTabLayoutKey !== nextTabLayoutKey) { + if (currentTabLayoutKey) tabLayouts.set(currentTabLayoutKey, tabs.toJSON()) + currentTabLayoutKey = nextTabLayoutKey + const savedTabLayout = tabLayouts.get(nextTabLayoutKey) + if (savedTabLayout) { + try { + tabs.fromJSON(savedTabLayout) + } catch { + tabs.clear() + tabLayouts.delete(nextTabLayoutKey) + } + } + } + for (const tab of projectReviewTabs) { + const panel = tabs.getPanel(tab) + if (!visibleTabs.includes(tab)) { + if (panel) tabs.removePanel(panel) + continue + } + if (!panel) { + tabs.addPanel({ + id: tab, + component: 'ProjectReviewPanel', + title: titles.get(tab), + params: { tab, slot: tab }, + renderer: 'always', + minimumHeight: 120, + inactive: true, + position: tab === 'description' ? undefined : { referencePanel: 'description' }, + }) + } else { + panel.api.setTitle(titles.get(tab) ?? tab) + } + } + } + + watchEffect(syncTabs) + + window.addEventListener('pagehide', saveLayout) + onBeforeUnmount(() => { + saveLayout() + tabsReady = false + window.removeEventListener('pagehide', saveLayout) + subscriptions.forEach((subscription) => subscription.dispose()) + }) + + return { + bottomVisible, + leftVisible, + openTab, + revealSlot, + resetActiveTabs, + rightVisible, + topLeftGroupId, + topRightGroupId, + onColumnsReady, + onRowsReady, + onTabsReady, + onDividerDoubleClick, + toggleSidebar, + toggleToolsPanel, + } +} diff --git a/apps/frontend/src/components/project-review/message-thread/index.vue b/apps/frontend/src/components/project-review/message-thread/index.vue new file mode 100644 index 00000000000..6d3fffe3cff --- /dev/null +++ b/apps/frontend/src/components/project-review/message-thread/index.vue @@ -0,0 +1,145 @@ + + + diff --git a/apps/frontend/src/components/project-review/message-thread/markdown-template-picker.vue b/apps/frontend/src/components/project-review/message-thread/markdown-template-picker.vue new file mode 100644 index 00000000000..32221827ce8 --- /dev/null +++ b/apps/frontend/src/components/project-review/message-thread/markdown-template-picker.vue @@ -0,0 +1,261 @@ + + + diff --git a/apps/frontend/src/components/project-review/message-thread/markdown-templates.ts b/apps/frontend/src/components/project-review/message-thread/markdown-templates.ts new file mode 100644 index 00000000000..dc22f1ea5be --- /dev/null +++ b/apps/frontend/src/components/project-review/message-thread/markdown-templates.ts @@ -0,0 +1,20 @@ +export type { MessageTemplate as MarkdownTemplate } from '@modrinth/moderation' + +export interface MarkdownTemplateQuery { + from: number + to: number + query: string + anchor: { + contextElement: HTMLElement + getBoundingClientRect: () => { + x: number + y: number + left: number + right: number + top: number + bottom: number + width: number + height: number + } + } +} diff --git a/apps/frontend/src/components/project-review/message-thread/message-box.vue b/apps/frontend/src/components/project-review/message-thread/message-box.vue new file mode 100644 index 00000000000..c7ba87e37b8 --- /dev/null +++ b/apps/frontend/src/components/project-review/message-thread/message-box.vue @@ -0,0 +1,200 @@ + + + + + diff --git a/apps/frontend/src/components/project-review/message-thread/template-markdown-editor.vue b/apps/frontend/src/components/project-review/message-thread/template-markdown-editor.vue new file mode 100644 index 00000000000..fb9c21632f5 --- /dev/null +++ b/apps/frontend/src/components/project-review/message-thread/template-markdown-editor.vue @@ -0,0 +1,180 @@ + + + diff --git a/apps/frontend/src/components/project-review/messages.ts b/apps/frontend/src/components/project-review/messages.ts new file mode 100644 index 00000000000..225a192b20c --- /dev/null +++ b/apps/frontend/src/components/project-review/messages.ts @@ -0,0 +1,721 @@ +import { defineMessages } from '@modrinth/ui' + +export const projectReviewMessages = defineMessages({ + generalSettings: { + id: 'moderation.project-review.generalSettings', + defaultMessage: 'General', + }, + keybindsSettings: { + id: 'moderation.project-review.keybindsSettings', + defaultMessage: 'Keybinds', + }, + reviewIssues: { + id: 'moderation.project-review.reviewIssues', + defaultMessage: 'Issues', + }, + conversationThread: { + id: 'moderation.project-review.conversationThread', + defaultMessage: 'Conversation thread', + }, + noSelectedIssues: { + id: 'moderation.project-review.noSelectedIssues', + defaultMessage: + 'No issues added. Select issues while reviewing the project to see the message here.', + }, + reviewSection: { + id: 'moderation.project-review.reviewSection', + defaultMessage: 'Review {section}', + }, + selectedFindings: { + id: 'moderation.project-review.selectedFindings', + defaultMessage: '{count, plural, one {# selected finding} other {# selected findings}}', + }, + reReviewFindings: { + id: 'moderation.project-review.reReviewFindings', + defaultMessage: '{count, plural, one {# re-review item} other {# re-review items}}', + }, + openReviewGuidance: { + id: 'moderation.project-review.openReviewGuidance', + defaultMessage: 'Open review guidance in new tab', + }, + closeReview: { + id: 'moderation.project-review.closeReview', + defaultMessage: 'Close review controls', + }, + pinReview: { + id: 'moderation.project-review.pinReview', + defaultMessage: 'Pin review controls', + }, + unpinReview: { + id: 'moderation.project-review.unpinReview', + defaultMessage: 'Unpin review controls', + }, + noReviewActions: { + id: 'moderation.project-review.noReviewActions', + defaultMessage: 'No review actions available for this section', + }, + noIssues: { + id: 'moderation.project-review.noIssues', + defaultMessage: 'No issues.', + }, + reviewScope: { + id: 'moderation.project-review.reviewScope', + defaultMessage: 'These selections apply to the {stage} review for the whole project', + }, + licenseUrl: { + id: 'moderation.project-review.licenseUrl', + defaultMessage: 'License URL', + }, + withheld: { + id: 'moderation.project-review.withheld', + defaultMessage: 'Withheld', + }, + resolvePermissions: { + id: 'moderation.project-review.resolvePermissions', + defaultMessage: 'Review redistribution permissions', + }, + title: { + id: 'moderation.project-review.title', + defaultMessage: 'Project Review', + }, + left: { + id: 'moderation.project-review.left', + defaultMessage: 'Project details', + }, + right: { + id: 'moderation.project-review.right', + defaultMessage: 'Right panel', + }, + bottom: { + id: 'moderation.project-review.bottom', + defaultMessage: 'Tools', + }, + description: { + id: 'moderation.project-review.description', + defaultMessage: 'Description', + }, + gallery: { + id: 'moderation.project-review.gallery', + defaultMessage: 'Gallery', + }, + disclosures: { + id: 'moderation.project-review.disclosures', + defaultMessage: 'Disclosures', + }, + permissions: { + id: 'moderation.project-review.permissions', + defaultMessage: 'Permissions', + }, + permissionsPlaceholder: { + id: 'moderation.project-review.permissionsPlaceholder', + defaultMessage: 'Permissions review is coming soon', + }, + reupload: { + id: 'moderation.project-review.reupload', + defaultMessage: 'Reupload', + }, + 're-review': { + id: 'moderation.project-review.re-review', + defaultMessage: 'Re-review', + }, + 'post-approval': { + id: 'moderation.project-review.post-approval', + defaultMessage: 'Post-approval', + }, + 'status-alerts': { + id: 'moderation.project-review.status-alerts', + defaultMessage: 'Status alerts', + }, + undefinedProject: { + id: 'moderation.project-review.undefinedProject', + defaultMessage: 'Undefined project', + }, + versions: { + id: 'moderation.project-review.versions', + defaultMessage: 'Versions', + }, + 'tech-review': { + id: 'moderation.project-review.tech-review', + defaultMessage: 'Tech Review', + }, + showLeft: { + id: 'moderation.project-review.showLeft', + defaultMessage: 'Show project details', + }, + hideLeft: { + id: 'moderation.project-review.hideLeft', + defaultMessage: 'Hide project details', + }, + showRight: { + id: 'moderation.project-review.showRight', + defaultMessage: 'Show panel', + }, + hideRight: { + id: 'moderation.project-review.hideRight', + defaultMessage: 'Hide panel', + }, + showTools: { + id: 'moderation.project-review.showTools', + defaultMessage: 'Show tools panel', + }, + hideTools: { + id: 'moderation.project-review.hideTools', + defaultMessage: 'Hide tools panel', + }, + empty: { + id: 'moderation.project-review.empty', + defaultMessage: 'No project selected', + }, + loading: { + id: 'moderation.project-review.loading', + defaultMessage: 'Loading...', + }, + projectTitle: { + id: 'moderation.project-review.projectTitle', + defaultMessage: 'Title', + }, + editSection: { + id: 'moderation.project-review.editSection', + defaultMessage: 'Edit {section}', + }, + saveProject: { + id: 'moderation.project-review.saveProject', + defaultMessage: 'Save project', + }, + slug: { + id: 'moderation.project-review.slug', + defaultMessage: 'Slug', + }, + icon: { + id: 'moderation.project-review.icon', + defaultMessage: 'Icon', + }, + openIcon: { + id: 'moderation.project-review.openIcon', + defaultMessage: 'View enlarged project icon', + }, + openMemberAvatar: { + id: 'moderation.project-review.openMemberAvatar', + defaultMessage: 'View enlarged avatar for {username}', + }, + summary: { + id: 'moderation.project-review.summary', + defaultMessage: 'Summary', + }, + matchesName: { + id: 'moderation.project-review.matchesName', + defaultMessage: 'Matches the project name', + }, + customSlug: { + id: 'moderation.project-review.customSlug', + defaultMessage: 'Differs from the project name', + }, + customLicense: { + id: 'moderation.project-review.customLicense', + defaultMessage: 'Custom', + }, + license: { + id: 'moderation.project-review.license', + defaultMessage: 'License', + }, + tags: { + id: 'moderation.project-review.tags', + defaultMessage: 'Tags', + }, + links: { + id: 'moderation.project-review.links', + defaultMessage: 'Links', + }, + emptyTags: { + id: 'moderation.project-review.emptyTags', + defaultMessage: 'No tags', + }, + emptyLinks: { + id: 'moderation.project-review.emptyLinks', + defaultMessage: 'No links', + }, + linkRemoved: { + id: 'moderation.project-review.linkRemoved', + defaultMessage: 'Link removed: {link}', + }, + linkUnavailable: { + id: 'moderation.project-review.linkUnavailable', + defaultMessage: 'Link unavailable', + }, + previousLink: { + id: 'moderation.project-review.previousLink', + defaultMessage: 'Previous link: {url}', + }, + source: { + id: 'moderation.project-review.source', + defaultMessage: 'Source code', + }, + issues: { + id: 'moderation.project-review.issues', + defaultMessage: 'Issue tracker', + }, + website: { + id: 'moderation.project-review.website', + defaultMessage: 'Website', + }, + store: { + id: 'moderation.project-review.store', + defaultMessage: 'Store', + }, + discord: { + id: 'moderation.project-review.discord', + defaultMessage: 'Discord invite', + }, + serverDiscord: { + id: 'moderation.project-review.serverDiscord', + defaultMessage: 'Discord', + }, + wiki: { + id: 'moderation.project-review.wiki', + defaultMessage: 'Wiki page', + }, + donationPatreon: { + id: 'moderation.project-review.donationPatreon', + defaultMessage: 'Patreon', + }, + donationBmac: { + id: 'moderation.project-review.donationBmac', + defaultMessage: 'Buy Me A Coffee', + }, + donationPaypal: { + id: 'moderation.project-review.donationPaypal', + defaultMessage: 'PayPal', + }, + donationGithub: { + id: 'moderation.project-review.donationGithub', + defaultMessage: 'GitHub Sponsors', + }, + donationKoFi: { + id: 'moderation.project-review.donationKoFi', + defaultMessage: 'Ko-fi', + }, + donationOther: { + id: 'moderation.project-review.donationOther', + defaultMessage: 'Other', + }, + compatibility: { + id: 'moderation.project-review.compatibility', + defaultMessage: 'Compatibility', + }, + serverDetails: { + id: 'moderation.project-review.serverDetails', + defaultMessage: 'Server details', + }, + serverAddress: { + id: 'moderation.project-review.serverAddress', + defaultMessage: 'Address', + }, + noServerAddress: { + id: 'moderation.project-review.noServerAddress', + defaultMessage: 'No address.', + }, + serverLanguages: { + id: 'moderation.project-review.serverLanguages', + defaultMessage: 'Languages', + }, + noServerLanguages: { + id: 'moderation.project-review.noServerLanguages', + defaultMessage: 'No languages.', + }, + serverRegion: { + id: 'moderation.project-review.serverRegion', + defaultMessage: 'Region', + }, + serverContent: { + id: 'moderation.project-review.serverContent', + defaultMessage: 'Content', + }, + vanilla: { + id: 'moderation.project-review.vanilla', + defaultMessage: 'Vanilla', + }, + modpack: { + id: 'moderation.project-review.modpack', + defaultMessage: 'Modpack', + }, + modpackVersion: { + id: 'moderation.project-review.modpackVersion', + defaultMessage: 'Version {version}', + }, + mrpack: { + id: 'moderation.project-review.mrpack', + defaultMessage: '.mrpack', + }, + recommended: { + id: 'moderation.project-review.recommended', + defaultMessage: '(Recommended)', + }, + gameVersions: { + id: 'moderation.project-review.gameVersions', + defaultMessage: 'Game versions', + }, + platforms: { + id: 'moderation.project-review.platforms', + defaultMessage: 'Platforms', + }, + emptyPlatforms: { + id: 'moderation.project-review.emptyPlatforms', + defaultMessage: 'No platforms', + }, + environments: { + id: 'moderation.project-review.environments', + defaultMessage: 'Environments', + }, + emptyEnvironments: { + id: 'moderation.project-review.emptyEnvironments', + defaultMessage: 'No environments', + }, + members: { + id: 'moderation.project-review.members', + defaultMessage: 'Members', + }, + memberCount: { + id: 'moderation.project-review.memberCount', + defaultMessage: '{count, plural, one {# member} other {# members}}', + }, + organization: { + id: 'moderation.project-review.organization', + defaultMessage: 'Organization', + }, + openOrganizationIcon: { + id: 'moderation.project-review.openOrganizationIcon', + defaultMessage: 'View enlarged icon for {organization}', + }, + owner: { + id: 'moderation.project-review.owner', + defaultMessage: 'Owner', + }, + details: { + id: 'moderation.project-review.details', + defaultMessage: 'Details', + }, + actions: { + id: 'moderation.project-review.actions', + defaultMessage: 'Actions', + }, + monetization: { + id: 'moderation.project-review.monetization', + defaultMessage: 'Monetization', + }, + monetizationStatus: { + id: 'moderation.project-review.monetizationStatus', + defaultMessage: + '{status, select, monetized {Monetized} demonetized {Demonetized} forceDemonetized {Force demonetized} other {Unknown}}', + }, + monetizationValue: { + id: 'moderation.project-review.monetizationValue', + defaultMessage: + '{status, select, monetized {Enabled} demonetized {Disabled} forceDemonetized {Force disabled} other {Unknown}}', + }, + visibility: { + id: 'moderation.project-review.visibility', + defaultMessage: 'Visibility', + }, + visibilityOption: { + id: 'moderation.project-review.visibilityOption', + defaultMessage: + '{status, select, approved {Public} archived {Public} unlisted {Unlisted} private {Private} other {Unknown}}', + }, + projectId: { + id: 'moderation.project-review.projectId', + defaultMessage: 'Project ID', + }, + permalink: { + id: 'moderation.project-review.permalink', + defaultMessage: 'Perma link', + }, + project: { + id: 'moderation.project-review.project', + defaultMessage: 'Project', + }, + projectType: { + id: 'moderation.project-review.projectType', + defaultMessage: 'Type', + }, + created: { + id: 'moderation.project-review.created', + defaultMessage: 'Created', + }, + updated: { + id: 'moderation.project-review.updated', + defaultMessage: 'Updated', + }, + submitted: { + id: 'moderation.project-review.submitted', + defaultMessage: 'Submitted', + }, + submissions: { + id: 'moderation.project-review.submissions', + defaultMessage: '{count, plural, one {# submission} other {# submissions}}', + }, + downloads: { + id: 'moderation.project-review.downloads', + defaultMessage: 'Downloads', + }, + requesting: { + id: 'moderation.project-review.requesting', + defaultMessage: 'Requesting', + }, + complete: { + id: 'moderation.project-review.complete', + defaultMessage: 'Complete', + }, + total: { + id: 'moderation.project-review.total', + defaultMessage: 'Total', + }, + back: { + id: 'moderation.project-review.back', + defaultMessage: 'Back', + }, + skip: { + id: 'moderation.project-review.skip', + defaultMessage: 'Skip', + }, + next: { + id: 'moderation.project-review.next', + defaultMessage: 'Next', + }, + finish: { + id: 'moderation.project-review.finish', + defaultMessage: 'Finish', + }, + reset: { + id: 'moderation.project-review.reset', + defaultMessage: 'Reset', + }, + exit: { + id: 'moderation.project-review.exit', + defaultMessage: 'Exit', + }, + retry: { + id: 'moderation.project-review.retry', + defaultMessage: 'Retry', + }, + loadError: { + id: 'moderation.project-review.loadError', + defaultMessage: 'Could not load project data', + }, + queueError: { + id: 'moderation.project-review.queueError', + defaultMessage: 'Could not change projects. Please try again', + }, + unavailable: { + id: 'moderation.project-review.unavailable', + defaultMessage: 'Unavailable', + }, + noLicenseUrl: { + id: 'moderation.project-review.noLicense', + defaultMessage: 'No license URL', + }, + queueNavigation: { + id: 'moderation.project-review.queueNavigation', + defaultMessage: 'Review queue navigation', + }, + emptyDescription: { + id: 'moderation.project-review.emptyDescription', + defaultMessage: 'No description', + }, + emptyGallery: { + id: 'moderation.project-review.emptyGallery', + defaultMessage: 'No gallery images', + }, + imageNumber: { + id: 'moderation.project-review.imageNumber', + defaultMessage: 'Image {number}', + }, + openImage: { + id: 'moderation.project-review.openImage', + defaultMessage: 'Open image {number}', + }, + openImageInNewTab: { + id: 'moderation.project-review.openImageInNewTab', + defaultMessage: 'Open image in new tab', + }, + featured: { + id: 'moderation.project-review.featured', + defaultMessage: 'Featured', + }, + uploaded: { + id: 'moderation.project-review.uploaded', + defaultMessage: 'Uploaded {date}', + }, + emptyVersions: { + id: 'moderation.project-review.emptyVersions', + defaultMessage: 'No versions', + }, + expandAll: { + id: 'moderation.project-review.expandAll', + defaultMessage: 'Expand all', + }, + collapseAll: { + id: 'moderation.project-review.collapseAll', + defaultMessage: 'Collapse all', + }, + expandVersion: { + id: 'moderation.project-review.expandVersion', + defaultMessage: 'Show details', + }, + collapseVersion: { + id: 'moderation.project-review.collapseVersion', + defaultMessage: 'Hide details', + }, + dependencyCount: { + id: 'moderation.project-review.dependencyCount', + defaultMessage: '{count, plural, one {# dep} other {# deps}}', + }, + versionNumber: { + id: 'moderation.project-review.versionNumber', + defaultMessage: 'Version Number', + }, + versionSubtitle: { + id: 'moderation.project-review.versionSubtitle', + defaultMessage: 'Version Subtitle', + }, + publishedBy: { + id: 'moderation.project-review.publishedBy', + defaultMessage: 'Published by', + }, + fileCount: { + id: 'moderation.project-review.fileCount', + defaultMessage: '{count, plural, one {# file} other {# files}}', + }, + files: { + id: 'moderation.project-review.files', + defaultMessage: 'Files', + }, + primary: { + id: 'moderation.project-review.primary', + defaultMessage: 'Primary file', + }, + hashes: { + id: 'moderation.project-review.hashes', + defaultMessage: 'Hashes', + }, + copyHash: { + id: 'moderation.project-review.copyHash', + defaultMessage: 'Copy {algorithm}', + }, + changelog: { + id: 'moderation.project-review.changelog', + defaultMessage: 'Changelog', + }, + emptyChangelog: { + id: 'moderation.project-review.emptyChangelog', + defaultMessage: 'No changelog', + }, + versionId: { + id: 'moderation.project-review.versionId', + defaultMessage: 'Version ID', + }, + published: { + id: 'moderation.project-review.published', + defaultMessage: 'Published', + }, + author: { + id: 'moderation.project-review.author', + defaultMessage: 'Author', + }, + viewVersion: { + id: 'moderation.project-review.viewVersion', + defaultMessage: 'View version', + }, + download: { + id: 'moderation.project-review.download', + defaultMessage: 'Download', + }, + openSlicer: { + id: 'moderation.project-review.openSlicer', + defaultMessage: 'Open in Slicer', + }, + dependencies: { + id: 'moderation.project-review.dependencies', + defaultMessage: 'Dependencies', + }, + emptyDependencies: { + id: 'moderation.project-review.emptyDependencies', + defaultMessage: 'No dependencies', + }, + listed: { + id: 'moderation.project-review.listed', + defaultMessage: 'Listed', + }, + archived: { + id: 'moderation.project-review.archived', + defaultMessage: 'Archived', + }, + draft: { + id: 'moderation.project-review.draft', + defaultMessage: 'Draft', + }, + unlisted: { + id: 'moderation.project-review.unlisted', + defaultMessage: 'Unlisted', + }, + scheduled: { + id: 'moderation.project-review.scheduled', + defaultMessage: 'Scheduled', + }, + unknown: { + id: 'moderation.project-review.unknown', + defaultMessage: 'Unknown', + }, + required: { + id: 'moderation.project-review.required', + defaultMessage: 'Required', + }, + sourceAvailabilityRequired: { + id: 'moderation.project-review.sourceAvailabilityRequired', + defaultMessage: 'License requires source code.', + }, + optional: { + id: 'moderation.project-review.optional', + defaultMessage: 'Optional', + }, + incompatible: { + id: 'moderation.project-review.incompatible', + defaultMessage: 'Incompatible', + }, + embedded: { + id: 'moderation.project-review.embedded', + defaultMessage: 'Embedded', + }, + pendingFiles: { + id: 'moderation.project-review.pendingFiles', + defaultMessage: 'Pending files ({count})', + }, + completedFiles: { + id: 'moderation.project-review.completedFiles', + defaultMessage: 'Reviewed files ({count})', + }, + noReports: { + id: 'moderation.project-review.noReports', + defaultMessage: 'No technical-review reports are available for this project', + }, + noPendingFiles: { + id: 'moderation.project-review.noPendingFiles', + defaultMessage: 'No files with unresolved flags', + }, + reviewProgress: { + id: 'moderation.project-review.reviewProgress', + defaultMessage: + 'Reviewed flags indicate progress, not a safe verdict. Files without flags have no flag verdict', + }, + backToFiles: { + id: 'moderation.project-review.backToFiles', + defaultMessage: 'Back to files', + }, + noFlagsFiles: { + id: 'moderation.project-review.noFlagsFiles', + defaultMessage: 'Files without flags ({count})', + }, + sourceError: { + id: 'moderation.project-review.sourceError', + defaultMessage: 'Could not load source code. Try opening the flag again', + }, +}) diff --git a/apps/frontend/src/components/project-review/permissions/index.vue b/apps/frontend/src/components/project-review/permissions/index.vue new file mode 100644 index 00000000000..4038debbd2d --- /dev/null +++ b/apps/frontend/src/components/project-review/permissions/index.vue @@ -0,0 +1,42 @@ + + + diff --git a/apps/frontend/src/components/project-review/project-info/edit/button.vue b/apps/frontend/src/components/project-review/project-info/edit/button.vue new file mode 100644 index 00000000000..cecd384a6d5 --- /dev/null +++ b/apps/frontend/src/components/project-review/project-info/edit/button.vue @@ -0,0 +1,46 @@ + + + + + diff --git a/apps/frontend/src/components/project-review/project-info/edit/details.vue b/apps/frontend/src/components/project-review/project-info/edit/details.vue new file mode 100644 index 00000000000..1ad413c3db8 --- /dev/null +++ b/apps/frontend/src/components/project-review/project-info/edit/details.vue @@ -0,0 +1,136 @@ + + + diff --git a/apps/frontend/src/components/project-review/project-info/edit/edit-modal.vue b/apps/frontend/src/components/project-review/project-info/edit/edit-modal.vue new file mode 100644 index 00000000000..6afa7b6e6e7 --- /dev/null +++ b/apps/frontend/src/components/project-review/project-info/edit/edit-modal.vue @@ -0,0 +1,62 @@ + + + diff --git a/apps/frontend/src/components/project-review/project-info/edit/license.vue b/apps/frontend/src/components/project-review/project-info/edit/license.vue new file mode 100644 index 00000000000..b02dc4c0a64 --- /dev/null +++ b/apps/frontend/src/components/project-review/project-info/edit/license.vue @@ -0,0 +1,335 @@ + + + diff --git a/apps/frontend/src/components/project-review/project-info/edit/links.vue b/apps/frontend/src/components/project-review/project-info/edit/links.vue new file mode 100644 index 00000000000..529e2b45cce --- /dev/null +++ b/apps/frontend/src/components/project-review/project-info/edit/links.vue @@ -0,0 +1,489 @@ + + + diff --git a/apps/frontend/src/components/project-review/project-info/edit/tags.vue b/apps/frontend/src/components/project-review/project-info/edit/tags.vue new file mode 100644 index 00000000000..cbf004cf35b --- /dev/null +++ b/apps/frontend/src/components/project-review/project-info/edit/tags.vue @@ -0,0 +1,354 @@ + + + + diff --git a/apps/frontend/src/components/project-review/project-info/edit/use-project-edit.ts b/apps/frontend/src/components/project-review/project-info/edit/use-project-edit.ts new file mode 100644 index 00000000000..bff4df1d185 --- /dev/null +++ b/apps/frontend/src/components/project-review/project-info/edit/use-project-edit.ts @@ -0,0 +1,68 @@ +import type { Labrinth } from '@modrinth/api-client' +import { + commonMessages, + injectModrinthClient, + injectNotificationManager, + useVIntl, +} from '@modrinth/ui' +import { useMutation, useQueryClient } from '@tanstack/vue-query' +import { watch } from 'vue' + +import { injectProjectReviewPageContext } from '~/providers/project-review' + +export function useProjectInfoEdit() { + const { project } = injectProjectReviewPageContext() + const client = injectModrinthClient() + const queryClient = useQueryClient() + const { addNotification } = injectNotificationManager() + const { formatMessage } = useVIntl() + let draftProjectId: string | undefined + let session = 0 + + function beginEditing() { + draftProjectId = project.value?.id + session += 1 + } + + watch( + () => project.value?.id, + () => { + draftProjectId = undefined + session += 1 + }, + { flush: 'sync' }, + ) + + const mutation = useMutation({ + mutationFn: ({ id, patch }: { id: string; patch: Labrinth.Projects.v3.EditProjectRequest }) => + client.labrinth.projects_v3.edit(id, patch), + onSuccess: async (_, { id }) => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['project', 'v3', id] }), + queryClient.invalidateQueries({ queryKey: ['project', 'v2', id] }), + queryClient.invalidateQueries({ queryKey: ['project', id] }), + ]) + }, + onError: (error) => { + addNotification({ + title: formatMessage(commonMessages.errorNotificationTitle), + text: error instanceof Error ? error.message : String(error), + type: 'error', + }) + }, + }) + + async function saveProject(patch: Labrinth.Projects.v3.EditProjectRequest) { + const id = draftProjectId + const saveSession = session + if (!id || id !== project.value?.id || mutation.isPending.value) return false + try { + await mutation.mutateAsync({ id, patch }) + return session === saveSession && project.value?.id === id + } catch { + return false + } + } + + return { project, saving: mutation.isPending, beginEditing, saveProject } +} diff --git a/apps/frontend/src/components/project-review/project-info/edit/use-project-text-edit.ts b/apps/frontend/src/components/project-review/project-info/edit/use-project-text-edit.ts new file mode 100644 index 00000000000..d548dbefafe --- /dev/null +++ b/apps/frontend/src/components/project-review/project-info/edit/use-project-text-edit.ts @@ -0,0 +1,86 @@ +import { defineMessages, useVIntl } from '@modrinth/ui' +import { computed, nextTick, ref, watch } from 'vue' + +import { useProjectInfoEdit } from './use-project-edit' + +type TextField = 'name' | 'slug' | 'summary' | 'description' + +const limits: Record = { + name: { min: 3, max: 64 }, + slug: { min: 3, max: 64 }, + summary: { min: 3, max: 256 }, + description: { min: 0, max: 65536 }, +} + +const messages = defineMessages({ + length: { + id: 'moderation.project-review.validation.length', + defaultMessage: 'Enter between {min} and {max} characters.', + }, + slug: { + id: 'moderation.project-review.validation.slug', + defaultMessage: 'Use only letters, numbers, periods, underscores, and hyphens.', + }, +}) + +export function useProjectTextEdit(field: TextField, focus: () => unknown) { + const { formatMessage } = useVIntl() + const { project, saving, beginEditing, saveProject } = useProjectInfoEdit() + const editing = ref(false) + const draft = ref('') + const value = computed(() => (field === 'description' ? draft.value : draft.value.trim())) + const validationMessage = computed(() => { + const length = Array.from(value.value).length + if (length < limits[field].min || length > limits[field].max) { + return formatMessage(messages.length, limits[field]) + } + if (field === 'slug' && !/^[a-zA-Z0-9._-]+$/.test(value.value)) { + return formatMessage(messages.slug) + } + return '' + }) + const valid = computed(() => !validationMessage.value) + + watch( + () => project.value?.id, + () => { + editing.value = false + draft.value = '' + }, + { flush: 'sync' }, + ) + + async function startEditing() { + if (!project.value || editing.value || saving.value) return + beginEditing() + draft.value = project.value[field] ?? '' + editing.value = true + await nextTick() + focus() + } + + function resetEditing() { + if (saving.value) return + draft.value = '' + editing.value = false + } + + async function save() { + if (!editing.value || !valid.value || saving.value) return + if (value.value === project.value?.[field] || (await saveProject({ [field]: value.value }))) { + editing.value = false + } + } + + return { + project, + saving, + editing, + draft, + valid, + validationMessage, + startEditing, + resetEditing, + save, + } +} diff --git a/apps/frontend/src/components/project-review/project-info/index.vue b/apps/frontend/src/components/project-review/project-info/index.vue new file mode 100644 index 00000000000..89f0b00bbde --- /dev/null +++ b/apps/frontend/src/components/project-review/project-info/index.vue @@ -0,0 +1,56 @@ +