From b4c5d625495285e91af9271da6f3a17b0137d8c2 Mon Sep 17 00:00:00 2001 From: tdgao Date: Tue, 15 Sep 2026 17:34:30 -0600 Subject: [PATCH 01/39] feat: start modview layout --- apps/frontend/package.json | 1 + .../src/components/project-review/context.ts | 11 + .../src/components/project-review/index.vue | 25 +++ .../project-review/layout-storage.ts | 62 ++++++ .../src/components/project-review/messages.ts | 68 ++++++ .../src/components/project-review/types.ts | 14 ++ .../components/project-review/use-layout.ts | 205 ++++++++++++++++++ .../project-review/workspace/center.vue | 23 ++ .../project-review/workspace/columns.vue | 19 ++ .../workspace/controls/left-control.vue | 12 + .../workspace/controls/right-control.vue | 12 + .../workspace/controls/sidebar-control.vue | 38 ++++ .../project-review/workspace/panel.vue | 35 +++ .../project-review/workspace/tabs/tab.vue | 40 ++++ .../project-review/workspace/tabs/tabs.vue | 115 ++++++++++ .../workspace/workspace.client.vue | 80 +++++++ apps/frontend/src/layouts/empty.vue | 5 + apps/frontend/src/locales/en-US/index.json | 48 ++++ apps/frontend/src/pages/moderation.vue | 2 + .../src/pages/moderation/project-review.vue | 23 ++ packages/assets/generated-icons.ts | 4 + packages/assets/icons/panel-left-open.svg | 17 ++ packages/assets/icons/panel-right-open.svg | 17 ++ pnpm-lock.yaml | 27 +++ 24 files changed, 903 insertions(+) create mode 100644 apps/frontend/src/components/project-review/context.ts create mode 100644 apps/frontend/src/components/project-review/index.vue create mode 100644 apps/frontend/src/components/project-review/layout-storage.ts create mode 100644 apps/frontend/src/components/project-review/messages.ts create mode 100644 apps/frontend/src/components/project-review/types.ts create mode 100644 apps/frontend/src/components/project-review/use-layout.ts create mode 100644 apps/frontend/src/components/project-review/workspace/center.vue create mode 100644 apps/frontend/src/components/project-review/workspace/columns.vue create mode 100644 apps/frontend/src/components/project-review/workspace/controls/left-control.vue create mode 100644 apps/frontend/src/components/project-review/workspace/controls/right-control.vue create mode 100644 apps/frontend/src/components/project-review/workspace/controls/sidebar-control.vue create mode 100644 apps/frontend/src/components/project-review/workspace/panel.vue create mode 100644 apps/frontend/src/components/project-review/workspace/tabs/tab.vue create mode 100644 apps/frontend/src/components/project-review/workspace/tabs/tabs.vue create mode 100644 apps/frontend/src/components/project-review/workspace/workspace.client.vue create mode 100644 apps/frontend/src/pages/moderation/project-review.vue create mode 100644 packages/assets/icons/panel-left-open.svg create mode 100644 packages/assets/icons/panel-right-open.svg diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 638a52f8e8a..8aefbecdbf0 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -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", diff --git a/apps/frontend/src/components/project-review/context.ts b/apps/frontend/src/components/project-review/context.ts new file mode 100644 index 00000000000..8f6f91f7833 --- /dev/null +++ b/apps/frontend/src/components/project-review/context.ts @@ -0,0 +1,11 @@ +import { createContext } from '@modrinth/ui' + +import type { ProjectReviewSlots } from './types' +import type { useProjectReviewLayout } from './use-layout' + +type ProjectReviewContext = ReturnType & { + slots: ProjectReviewSlots +} + +export const [injectProjectReviewContext, provideProjectReviewContext] = + createContext('ProjectReview') 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..7a2a2db9a33 --- /dev/null +++ b/apps/frontend/src/components/project-review/index.vue @@ -0,0 +1,25 @@ + + + diff --git a/apps/frontend/src/components/project-review/layout-storage.ts b/apps/frontend/src/components/project-review/layout-storage.ts new file mode 100644 index 00000000000..1d57ec3ae52 --- /dev/null +++ b/apps/frontend/src/components/project-review/layout-storage.ts @@ -0,0 +1,62 @@ +import type { SerializedDockview } from 'dockview-vue' + +import { projectReviewTabs } from './types' + +const STORAGE_KEY = 'moderation.project-review.layout.v1' + +export interface SavedWorkspaceLayout { + leftWidth: number + rightWidth: number + bottomHeight: number + leftVisible: boolean + rightVisible: boolean + tabs: SerializedDockview +} + +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, + ) || + typeof saved.leftVisible !== 'boolean' || + typeof saved.rightVisible !== 'boolean' || + !saved.tabs?.grid || + !saved.tabs.panels || + Object.keys(saved.tabs.panels).length !== projectReviewTabs.length || + saved.tabs.floatingGroups?.length || + saved.tabs.popoutGroups?.length || + saved.tabs.edgeGroups + ) { + return + } + + for (const tab of projectReviewTabs) { + const panel = saved.tabs.panels[tab] + if ( + panel?.id !== tab || + panel.contentComponent !== 'ProjectReviewPanel' || + panel.params?.tab !== tab || + panel.params?.slot !== tab + ) { + 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/messages.ts b/apps/frontend/src/components/project-review/messages.ts new file mode 100644 index 00000000000..897d8f3d699 --- /dev/null +++ b/apps/frontend/src/components/project-review/messages.ts @@ -0,0 +1,68 @@ +import { defineMessages } from '@modrinth/ui' + +export const projectReviewMessages = defineMessages({ + 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: 'Review issues', + }, + bottom: { + id: 'moderation.project-review.bottom', + defaultMessage: 'Review summary', + }, + description: { + id: 'moderation.project-review.description', + defaultMessage: 'Description', + }, + gallery: { + id: 'moderation.project-review.gallery', + defaultMessage: 'Gallery', + }, + disclosures: { + id: 'moderation.project-review.disclosures', + defaultMessage: 'Disclosures', + }, + versions: { + id: 'moderation.project-review.versions', + defaultMessage: 'Versions', + }, + history: { + id: 'moderation.project-review.history', + defaultMessage: 'History', + }, + '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 review issues', + }, + hideRight: { + id: 'moderation.project-review.hideRight', + defaultMessage: 'Hide review issues', + }, + empty: { + id: 'moderation.project-review.empty', + defaultMessage: 'No project selected.', + }, + loading: { + id: 'moderation.project-review.loading', + defaultMessage: 'Loading review workspace…', + }, +}) diff --git a/apps/frontend/src/components/project-review/types.ts b/apps/frontend/src/components/project-review/types.ts new file mode 100644 index 00000000000..8dd9a28ef76 --- /dev/null +++ b/apps/frontend/src/components/project-review/types.ts @@ -0,0 +1,14 @@ +import type { VNode } from 'vue' + +export const projectReviewTabs = [ + 'description', + 'gallery', + 'disclosures', + 'versions', + 'history', + 'tech-review', +] as const + +export type ProjectReviewTab = (typeof projectReviewTabs)[number] +export type ProjectReviewSlot = ProjectReviewTab | 'left' | 'right' | 'bottom' +export type ProjectReviewSlots = { [Key in ProjectReviewSlot]?: () => VNode[] } diff --git a/apps/frontend/src/components/project-review/use-layout.ts b/apps/frontend/src/components/project-review/use-layout.ts new file mode 100644 index 00000000000..18b4723e0fc --- /dev/null +++ b/apps/frontend/src/components/project-review/use-layout.ts @@ -0,0 +1,205 @@ +import type { + DockviewApi, + DockviewIDisposable, + DockviewReadyEvent, + DockviewWillDropEvent, + DockviewWillShowOverlayLocationEvent, + SplitviewApi, + SplitviewReadyEvent, +} from 'dockview-vue' +import { LayoutPriority } from 'dockview-vue' +import { onBeforeUnmount, ref, watchEffect } from 'vue' + +import { readWorkspaceLayout, saveWorkspaceLayout } from './layout-storage' +import { type ProjectReviewTab, projectReviewTabs } from './types' + +export function useProjectReviewLayout(getTitle: (tab: ProjectReviewTab) => string) { + const savedLayout = readWorkspaceLayout() + const leftVisible = ref(savedLayout?.leftVisible ?? true) + const rightVisible = ref(savedLayout?.rightVisible ?? true) + let leftWidth = savedLayout?.leftWidth ?? 260 + let rightWidth = savedLayout?.rightWidth ?? 300 + let bottomHeight = savedLayout?.bottomHeight ?? 180 + 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 + 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 (currentBottomHeight > 0) bottomHeight = currentBottomHeight + saveWorkspaceLayout({ + leftWidth, + rightWidth, + bottomHeight, + leftVisible: leftVisible.value, + rightVisible: rightVisible.value, + tabs: tabs.toJSON(), + }) + } + + function scheduleSave() { + clearTimeout(saveTimer) + saveTimer = setTimeout(saveLayout, 200) + } + + function onColumnsReady({ api }: SplitviewReadyEvent) { + columns = api + api.addPanel({ + id: 'left', + component: 'ProjectReviewPanel', + params: { slot: 'left' }, + minimumSize: 180, + size: leftWidth, + }) + api.addPanel({ + id: 'center', + component: 'ProjectReviewCenter', + minimumSize: 320, + priority: LayoutPriority.High, + }) + api.addPanel({ + id: 'right', + component: 'ProjectReviewPanel', + params: { slot: 'right' }, + minimumSize: 200, + 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: 180, + priority: LayoutPriority.High, + }) + api.addPanel({ + id: 'bottom', + component: 'ProjectReviewPanel', + params: { slot: 'bottom' }, + minimumSize: 100, + size: bottomHeight, + }) + 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), + ) + let restored = false + if (savedLayout) { + try { + api.fromJSON(savedLayout.tabs) + if ( + api.panels.length !== projectReviewTabs.length || + projectReviewTabs.some((tab) => !api.getPanel(tab)) + ) { + throw new Error('Incomplete saved review layout') + } + restored = true + } catch { + api.clear() + } + } + if (!restored) { + for (const tab of projectReviewTabs) { + api.addPanel({ + id: tab, + component: 'ProjectReviewPanel', + title: getTitle(tab), + params: { tab, slot: tab }, + renderer: 'always', + minimumHeight: 120, + position: tab === 'description' ? undefined : { referencePanel: 'description' }, + }) + } + api.getPanel('description')?.api.setActive() + } + for (const tab of projectReviewTabs) { + api.getPanel(tab)?.api.setTitle(getTitle(tab)) + } + tabsReady = true + updateCornerGroups() + } + + function toggleSidebar(side: 'left' | 'right') { + const visible = side === 'left' ? leftVisible : rightVisible + const panel = columns?.getPanel(side) + if (!panel) 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() + } + + watchEffect(() => { + for (const tab of projectReviewTabs) { + const title = getTitle(tab) + tabs?.getPanel(tab)?.api.setTitle(title) + } + }) + + window.addEventListener('pagehide', saveLayout) + onBeforeUnmount(() => { + saveLayout() + tabsReady = false + window.removeEventListener('pagehide', saveLayout) + subscriptions.forEach((subscription) => subscription.dispose()) + }) + + return { + leftVisible, + rightVisible, + topLeftGroupId, + topRightGroupId, + onColumnsReady, + onRowsReady, + onTabsReady, + toggleSidebar, + } +} diff --git a/apps/frontend/src/components/project-review/workspace/center.vue b/apps/frontend/src/components/project-review/workspace/center.vue new file mode 100644 index 00000000000..fae91695dee --- /dev/null +++ b/apps/frontend/src/components/project-review/workspace/center.vue @@ -0,0 +1,23 @@ + + + diff --git a/apps/frontend/src/components/project-review/workspace/columns.vue b/apps/frontend/src/components/project-review/workspace/columns.vue new file mode 100644 index 00000000000..7dc4b18e304 --- /dev/null +++ b/apps/frontend/src/components/project-review/workspace/columns.vue @@ -0,0 +1,19 @@ + + + diff --git a/apps/frontend/src/components/project-review/workspace/controls/left-control.vue b/apps/frontend/src/components/project-review/workspace/controls/left-control.vue new file mode 100644 index 00000000000..3957a9b2905 --- /dev/null +++ b/apps/frontend/src/components/project-review/workspace/controls/left-control.vue @@ -0,0 +1,12 @@ + + + diff --git a/apps/frontend/src/components/project-review/workspace/controls/right-control.vue b/apps/frontend/src/components/project-review/workspace/controls/right-control.vue new file mode 100644 index 00000000000..02aad2d1b2f --- /dev/null +++ b/apps/frontend/src/components/project-review/workspace/controls/right-control.vue @@ -0,0 +1,12 @@ + + + diff --git a/apps/frontend/src/components/project-review/workspace/controls/sidebar-control.vue b/apps/frontend/src/components/project-review/workspace/controls/sidebar-control.vue new file mode 100644 index 00000000000..8daafb5220e --- /dev/null +++ b/apps/frontend/src/components/project-review/workspace/controls/sidebar-control.vue @@ -0,0 +1,38 @@ + + + diff --git a/apps/frontend/src/components/project-review/workspace/panel.vue b/apps/frontend/src/components/project-review/workspace/panel.vue new file mode 100644 index 00000000000..61a12254cd1 --- /dev/null +++ b/apps/frontend/src/components/project-review/workspace/panel.vue @@ -0,0 +1,35 @@ + + + diff --git a/apps/frontend/src/components/project-review/workspace/tabs/tab.vue b/apps/frontend/src/components/project-review/workspace/tabs/tab.vue new file mode 100644 index 00000000000..1a86d9fce3e --- /dev/null +++ b/apps/frontend/src/components/project-review/workspace/tabs/tab.vue @@ -0,0 +1,40 @@ + + + + + diff --git a/apps/frontend/src/components/project-review/workspace/tabs/tabs.vue b/apps/frontend/src/components/project-review/workspace/tabs/tabs.vue new file mode 100644 index 00000000000..a56f2d2eebd --- /dev/null +++ b/apps/frontend/src/components/project-review/workspace/tabs/tabs.vue @@ -0,0 +1,115 @@ + + + + + diff --git a/apps/frontend/src/components/project-review/workspace/workspace.client.vue b/apps/frontend/src/components/project-review/workspace/workspace.client.vue new file mode 100644 index 00000000000..e4ec1381b87 --- /dev/null +++ b/apps/frontend/src/components/project-review/workspace/workspace.client.vue @@ -0,0 +1,80 @@ + + + + + diff --git a/apps/frontend/src/layouts/empty.vue b/apps/frontend/src/layouts/empty.vue index 6366d0350f6..4aa88178563 100644 --- a/apps/frontend/src/layouts/empty.vue +++ b/apps/frontend/src/layouts/empty.vue @@ -1,4 +1,9 @@ + + + diff --git a/apps/frontend/src/locales/en-US/index.json b/apps/frontend/src/locales/en-US/index.json index e87cc6c93cf..1c5f5e0e785 100644 --- a/apps/frontend/src/locales/en-US/index.json +++ b/apps/frontend/src/locales/en-US/index.json @@ -3035,6 +3035,54 @@ "moderation.page.technicalReview": { "message": "Tech review" }, + "moderation.project-review.bottom": { + "message": "Review summary" + }, + "moderation.project-review.description": { + "message": "Description" + }, + "moderation.project-review.disclosures": { + "message": "Disclosures" + }, + "moderation.project-review.empty": { + "message": "No project selected." + }, + "moderation.project-review.gallery": { + "message": "Gallery" + }, + "moderation.project-review.hideLeft": { + "message": "Hide project details" + }, + "moderation.project-review.hideRight": { + "message": "Hide review issues" + }, + "moderation.project-review.history": { + "message": "History" + }, + "moderation.project-review.left": { + "message": "Project details" + }, + "moderation.project-review.loading": { + "message": "Loading review workspace…" + }, + "moderation.project-review.right": { + "message": "Review issues" + }, + "moderation.project-review.showLeft": { + "message": "Show project details" + }, + "moderation.project-review.showRight": { + "message": "Show review issues" + }, + "moderation.project-review.tech-review": { + "message": "Tech Review" + }, + "moderation.project-review.title": { + "message": "Project review" + }, + "moderation.project-review.versions": { + "message": "Versions" + }, "modpack-scan-modal.batch.label": { "message": "{amount} per batch" }, diff --git a/apps/frontend/src/pages/moderation.vue b/apps/frontend/src/pages/moderation.vue index 84bae4107d4..f999e52f32e 100644 --- a/apps/frontend/src/pages/moderation.vue +++ b/apps/frontend/src/pages/moderation.vue @@ -1,5 +1,7 @@