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..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/KbdChip.vue b/apps/frontend/src/components/project-review/KbdChip.vue new file mode 100644 index 00000000000..ffbd749f495 --- /dev/null +++ b/apps/frontend/src/components/project-review/KbdChip.vue @@ -0,0 +1,42 @@ + + + diff --git a/apps/frontend/src/components/project-review/conversation.vue b/apps/frontend/src/components/project-review/conversation.vue new file mode 100644 index 00000000000..1c40203cc0f --- /dev/null +++ b/apps/frontend/src/components/project-review/conversation.vue @@ -0,0 +1,218 @@ + + + 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..7a3db91fd6c --- /dev/null +++ b/apps/frontend/src/components/project-review/description/index.vue @@ -0,0 +1,37 @@ + + + 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..4edf3540ffc --- /dev/null +++ b/apps/frontend/src/components/project-review/disclosures/index.vue @@ -0,0 +1,68 @@ + + + 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..ab96bdcc297 --- /dev/null +++ b/apps/frontend/src/components/project-review/gallery/index.vue @@ -0,0 +1,116 @@ + + + + + diff --git a/apps/frontend/src/components/project-review/history/index.vue b/apps/frontend/src/components/project-review/history/index.vue new file mode 100644 index 00000000000..89515b8c43d --- /dev/null +++ b/apps/frontend/src/components/project-review/history/index.vue @@ -0,0 +1,14 @@ + + + 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..635f6faff94 --- /dev/null +++ b/apps/frontend/src/components/project-review/index.vue @@ -0,0 +1,74 @@ + + + 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..8f6f91f7833 --- /dev/null +++ b/apps/frontend/src/components/project-review/layout/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/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..3c9d252a1cd --- /dev/null +++ b/apps/frontend/src/components/project-review/layout/index.client.vue @@ -0,0 +1,103 @@ + + + + + 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..f2f5ed424a8 --- /dev/null +++ b/apps/frontend/src/components/project-review/layout/layout-storage.ts @@ -0,0 +1,73 @@ +import type { SerializedDockview } from 'dockview-vue' + +import { 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 + 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).some( + (tab) => !projectReviewTabs.includes(tab as (typeof projectReviewTabs)[number]), + ) || + saved.tabs.floatingGroups?.length || + saved.tabs.popoutGroups?.length || + saved.tabs.edgeGroups + ) { + return + } + + for (const tab of projectReviewTabs) { + const panel = saved.tabs.panels[tab] + if (tab === 'permissions' && !panel) continue + 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/layout/panel.vue b/apps/frontend/src/components/project-review/layout/panel.vue new file mode 100644 index 00000000000..4bee6cbcdb9 --- /dev/null +++ b/apps/frontend/src/components/project-review/layout/panel.vue @@ -0,0 +1,91 @@ + + + + + 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..619d8c2b014 --- /dev/null +++ b/apps/frontend/src/components/project-review/layout/tabs/tab.vue @@ -0,0 +1,59 @@ + + + + + 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..1cc2ef240ee --- /dev/null +++ b/apps/frontend/src/components/project-review/layout/tabs/tabs.vue @@ -0,0 +1,109 @@ + + + + + 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..2823b77856c --- /dev/null +++ b/apps/frontend/src/components/project-review/layout/types.ts @@ -0,0 +1,17 @@ +import type { VNode } from 'vue' + +export const projectReviewTabs = [ + 'description', + 'gallery', + 'disclosures', + 'versions', + 'permissions', + 'history', + '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..0dd2b328d93 --- /dev/null +++ b/apps/frontend/src/components/project-review/layout/use-layout.ts @@ -0,0 +1,280 @@ +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 } from './layout-storage' +import { 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) + 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 + 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: 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, + }) + 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) + restored = true + } catch { + api.clear() + } + } + 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 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 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 titles = new Map(visibleTabs.map((tab) => [tab, getTitle(tab)])) + if (!tabs || !tabsReady) return + 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 { + leftVisible, + openTab, + rightVisible, + topLeftGroupId, + topRightGroupId, + onColumnsReady, + onRowsReady, + onTabsReady, + onDividerDoubleClick, + toggleSidebar, + } +} 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..3e785479d30 --- /dev/null +++ b/apps/frontend/src/components/project-review/messages.ts @@ -0,0 +1,582 @@ +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}', + }, + 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', + }, + history: { + id: 'moderation.project-review.history', + defaultMessage: 'History', + }, + historyStub: { + id: 'moderation.project-review.historyStub', + defaultMessage: 'Stub for history tab', + }, + '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', + }, + 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', + }, + titleAndSlug: { + id: 'moderation.project-review.titleAndSlug', + defaultMessage: 'Title and slug', + }, + 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', + }, + source: { + id: 'moderation.project-review.source', + defaultMessage: 'Source code', + }, + issues: { + id: 'moderation.project-review.issues', + defaultMessage: 'Issue tracker', + }, + discord: { + id: 'moderation.project-review.discord', + defaultMessage: 'Discord invite', + }, + wiki: { + id: 'moderation.project-review.wiki', + defaultMessage: 'Wiki', + }, + compatibility: { + id: 'moderation.project-review.compatibility', + defaultMessage: 'Compatibility', + }, + gameVersions: { + id: 'moderation.project-review.gameVersions', + defaultMessage: 'Game versions', + }, + platforms: { + id: 'moderation.project-review.platforms', + defaultMessage: 'Platforms', + }, + environments: { + id: 'moderation.project-review.environments', + defaultMessage: 'Environments', + }, + members: { + id: 'moderation.project-review.members', + defaultMessage: 'Members', + }, + owner: { + id: 'moderation.project-review.owner', + defaultMessage: 'Owner', + }, + details: { + id: 'moderation.project-review.details', + defaultMessage: 'Details', + }, + projectType: { + id: 'moderation.project-review.projectType', + defaultMessage: 'Project 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', + }, + approvedCount: { + id: 'moderation.project-review.approvedCount', + defaultMessage: '{count} approved', + }, + archivedCount: { + id: 'moderation.project-review.archivedCount', + defaultMessage: '{count} archived', + }, + unlistedCount: { + id: 'moderation.project-review.unlistedCount', + defaultMessage: '{count} unlisted', + }, + withheldCount: { + id: 'moderation.project-review.withheldCount', + defaultMessage: '{count} withheld', + }, + processingCount: { + id: 'moderation.project-review.processingCount', + defaultMessage: '{count} under review', + }, + draftCount: { + id: 'moderation.project-review.draftCount', + defaultMessage: '{count} draft', + }, + rejectedCount: { + id: 'moderation.project-review.rejectedCount', + defaultMessage: '{count} rejected', + }, + privateCount: { + id: 'moderation.project-review.privateCount', + defaultMessage: '{count} private', + }, + scheduledCount: { + id: 'moderation.project-review.scheduledCount', + defaultMessage: '{count} scheduled', + }, + unknownCount: { + id: 'moderation.project-review.unknownCount', + defaultMessage: '{count} unknown', + }, + 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', + }, + 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', + }, + 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..3edec438a10 --- /dev/null +++ b/apps/frontend/src/components/project-review/permissions/index.vue @@ -0,0 +1,30 @@ + + + diff --git a/apps/frontend/src/components/project-review/project-actions.vue b/apps/frontend/src/components/project-review/project-actions.vue new file mode 100644 index 00000000000..63f0d244f27 --- /dev/null +++ b/apps/frontend/src/components/project-review/project-actions.vue @@ -0,0 +1,150 @@ + + + + + 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..360acaa3896 --- /dev/null +++ b/apps/frontend/src/components/project-review/project-info/index.vue @@ -0,0 +1,148 @@ + + + + + diff --git a/apps/frontend/src/components/project-review/project-info/project/compatibility.vue b/apps/frontend/src/components/project-review/project-info/project/compatibility.vue new file mode 100644 index 00000000000..e360c246569 --- /dev/null +++ b/apps/frontend/src/components/project-review/project-info/project/compatibility.vue @@ -0,0 +1,43 @@ + + + diff --git a/apps/frontend/src/components/project-review/project-info/project/details.vue b/apps/frontend/src/components/project-review/project-info/project/details.vue new file mode 100644 index 00000000000..5570e1e3f6c --- /dev/null +++ b/apps/frontend/src/components/project-review/project-info/project/details.vue @@ -0,0 +1,69 @@ + + + diff --git a/apps/frontend/src/components/project-review/project-info/project/icon.vue b/apps/frontend/src/components/project-review/project-info/project/icon.vue new file mode 100644 index 00000000000..b06df1c3543 --- /dev/null +++ b/apps/frontend/src/components/project-review/project-info/project/icon.vue @@ -0,0 +1,53 @@ + + + diff --git a/apps/frontend/src/components/project-review/project-info/project/license.vue b/apps/frontend/src/components/project-review/project-info/project/license.vue new file mode 100644 index 00000000000..b125c62feaf --- /dev/null +++ b/apps/frontend/src/components/project-review/project-info/project/license.vue @@ -0,0 +1,39 @@ + + + diff --git a/apps/frontend/src/components/project-review/project-info/project/links.vue b/apps/frontend/src/components/project-review/project-info/project/links.vue new file mode 100644 index 00000000000..d5dcb5a6bbd --- /dev/null +++ b/apps/frontend/src/components/project-review/project-info/project/links.vue @@ -0,0 +1,70 @@ + + + diff --git a/apps/frontend/src/components/project-review/project-info/project/member-item.vue b/apps/frontend/src/components/project-review/project-info/project/member-item.vue new file mode 100644 index 00000000000..5b4d444d0df --- /dev/null +++ b/apps/frontend/src/components/project-review/project-info/project/member-item.vue @@ -0,0 +1,130 @@ + + + diff --git a/apps/frontend/src/components/project-review/project-info/project/members.vue b/apps/frontend/src/components/project-review/project-info/project/members.vue new file mode 100644 index 00000000000..3e4b63dbfd7 --- /dev/null +++ b/apps/frontend/src/components/project-review/project-info/project/members.vue @@ -0,0 +1,28 @@ + + + diff --git a/apps/frontend/src/components/project-review/project-info/project/slug.vue b/apps/frontend/src/components/project-review/project-info/project/slug.vue new file mode 100644 index 00000000000..3f6fdbcf035 --- /dev/null +++ b/apps/frontend/src/components/project-review/project-info/project/slug.vue @@ -0,0 +1,29 @@ + + + diff --git a/apps/frontend/src/components/project-review/project-info/project/summary.vue b/apps/frontend/src/components/project-review/project-info/project/summary.vue new file mode 100644 index 00000000000..b7379fe7146 --- /dev/null +++ b/apps/frontend/src/components/project-review/project-info/project/summary.vue @@ -0,0 +1,11 @@ + + + diff --git a/apps/frontend/src/components/project-review/project-info/project/tags.vue b/apps/frontend/src/components/project-review/project-info/project/tags.vue new file mode 100644 index 00000000000..fcf058b4c49 --- /dev/null +++ b/apps/frontend/src/components/project-review/project-info/project/tags.vue @@ -0,0 +1,26 @@ + + + diff --git a/apps/frontend/src/components/project-review/queue-bar.vue b/apps/frontend/src/components/project-review/queue-bar.vue new file mode 100644 index 00000000000..d20dda4744c --- /dev/null +++ b/apps/frontend/src/components/project-review/queue-bar.vue @@ -0,0 +1,140 @@ + + + + + diff --git a/apps/frontend/src/components/project-review/review-panel/anchor.vue b/apps/frontend/src/components/project-review/review-panel/anchor.vue new file mode 100644 index 00000000000..af478f043f3 --- /dev/null +++ b/apps/frontend/src/components/project-review/review-panel/anchor.vue @@ -0,0 +1,118 @@ + + + + + diff --git a/apps/frontend/src/components/project-review/review-panel/context.ts b/apps/frontend/src/components/project-review/review-panel/context.ts new file mode 100644 index 00000000000..af89c891aef --- /dev/null +++ b/apps/frontend/src/components/project-review/review-panel/context.ts @@ -0,0 +1,151 @@ +import { createContext } from '@modrinth/ui' +import { nextTick, onScopeDispose, type Ref, ref, shallowRef, useId, watch } from 'vue' + +import type { ReviewTarget } from '~/providers/project-review/review' + +export interface ReviewAnchor { + id: string + target: ReviewTarget + element: HTMLElement + trigger: HTMLElement | null + available: () => boolean +} + +const CLOSE_DELAY = 350 + +export const [injectReviewContext, provideReviewContext] = + createContext>('ProjectReviewActions') + +export function createReviewContext( + projectId: Ref, + isAvailable: (target: ReviewTarget) => boolean, +) { + const panelId = useId() + const active = shallowRef() + const panel = shallowRef(null) + const childPanels = new Set() + const pinned = ref(false) + let openTimer: ReturnType | undefined + let closeTimer: ReturnType | undefined + let pendingAnchor: ReviewAnchor | undefined + let openDropdowns = 0 + + function cancelClose() { + clearTimeout(closeTimer) + } + + function setDropdownOpen(id: string, open: boolean) { + if (active.value?.id !== id) return + openDropdowns = Math.max(0, openDropdowns + (open ? 1 : -1)) + if (open) cancelClose() + else leave(id) + } + + function contains(target: Node) { + return panel.value?.contains(target) || [...childPanels].some((child) => child.contains(target)) + } + + function registerChildPanel(element: HTMLElement) { + childPanels.add(element) + cancelClose() + return () => { + childPanels.delete(element) + if (active.value) leave(active.value.id) + } + } + + function close(restoreFocus = false) { + const trigger = active.value?.trigger + cancelClose() + clearTimeout(openTimer) + pendingAnchor = undefined + active.value = undefined + openDropdowns = 0 + pinned.value = false + if (restoreFocus) { + void nextTick(() => { + if (trigger?.isConnected) trigger.focus() + }) + } + } + + function open(anchor: ReviewAnchor, explicit = false) { + clearTimeout(openTimer) + pendingAnchor = undefined + const show = () => { + pendingAnchor = undefined + if ((pinned.value || openDropdowns > 0) && !explicit) return + if (!anchor.element.isConnected || !anchor.available() || !isAvailable(anchor.target)) return + cancelClose() + if (active.value?.id !== anchor.id) { + openDropdowns = 0 + pinned.value = false + } + active.value = anchor + } + if (explicit) show() + else { + pendingAnchor = anchor + openTimer = setTimeout(show, 100) + } + } + + function leave(id: string) { + if (pendingAnchor?.id === id) { + clearTimeout(openTimer) + pendingAnchor = undefined + } + if (active.value?.id !== id) return + cancelClose() + closeTimer = setTimeout(() => { + if (active.value?.id !== id) return + if (pinned.value || openDropdowns > 0) return + if ( + active.value.element.matches(':hover') || + panel.value?.matches(':hover') || + [...childPanels].some( + (child) => child.matches(':hover') || child.contains(document.activeElement), + ) + ) + return + if (pendingAnchor && pendingAnchor.id !== id) { + leave(id) + return + } + close() + }, CLOSE_DELAY) + } + + function release(id: string) { + if (pendingAnchor?.id === id) { + clearTimeout(openTimer) + pendingAnchor = undefined + } + if (active.value?.id === id) close(!!panel.value?.contains(document.activeElement)) + } + + watch(projectId, () => close(), { flush: 'sync' }) + watch( + () => active.value && isAvailable(active.value.target), + (available) => { + if (active.value && !available) release(active.value.id) + }, + ) + + onScopeDispose(close) + return { + active, + panel, + panelId, + pinned, + isAvailable, + open, + close, + release, + leave, + cancelClose, + setDropdownOpen, + contains, + registerChildPanel, + } +} diff --git a/apps/frontend/src/components/project-review/review-panel/controls.vue b/apps/frontend/src/components/project-review/review-panel/controls.vue new file mode 100644 index 00000000000..4664ef01ef7 --- /dev/null +++ b/apps/frontend/src/components/project-review/review-panel/controls.vue @@ -0,0 +1,267 @@ + + + diff --git a/apps/frontend/src/components/project-review/review-panel/index.vue b/apps/frontend/src/components/project-review/review-panel/index.vue new file mode 100644 index 00000000000..ce64f67391e --- /dev/null +++ b/apps/frontend/src/components/project-review/review-panel/index.vue @@ -0,0 +1,88 @@ + + + diff --git a/apps/frontend/src/components/project-review/review-panel/popover.vue b/apps/frontend/src/components/project-review/review-panel/popover.vue new file mode 100644 index 00000000000..a1cb0cb14f1 --- /dev/null +++ b/apps/frontend/src/components/project-review/review-panel/popover.vue @@ -0,0 +1,112 @@ + + + diff --git a/apps/frontend/src/components/project-review/review-panel/use-action-keybinds.ts b/apps/frontend/src/components/project-review/review-panel/use-action-keybinds.ts new file mode 100644 index 00000000000..f0a52548edc --- /dev/null +++ b/apps/frontend/src/components/project-review/review-panel/use-action-keybinds.ts @@ -0,0 +1,58 @@ +import { useEventListener } from '@vueuse/core' +import type { Ref } from 'vue' + +export function useActionKeybinds( + element: Ref, + isShowing: () => boolean = () => false, +) { + useEventListener('keydown', (event) => { + if ( + event.defaultPrevented || + event.repeat || + event.isComposing || + event.ctrlKey || + event.metaKey || + event.altKey + ) { + return + } + const digit = /^\d$/.test(event.key) + ? event.key + : event.shiftKey && /^Digit\d$/.test(event.code) + ? event.code.slice(-1) + : undefined + if (!digit) return + const keybind = `${event.shiftKey ? 'Shift+' : ''}${digit}` + + const target = event.target + if ( + target instanceof HTMLElement && + (target.isContentEditable || target.closest('input, textarea, select, [role="textbox"]')) + ) { + return + } + + const panel = element.value + if (!panel || panel.querySelector('[data-review-panel]:hover')) return + const dialog = target instanceof Element ? target.closest('[role="dialog"]') : null + if (dialog && dialog !== panel && !dialog.contains(panel)) return + if ( + [...document.querySelectorAll('[role="dialog"][aria-modal="true"]')].some( + (modal) => !modal.contains(panel), + ) + ) { + return + } + if (!panel.matches(':hover')) { + if (!isShowing() || document.querySelector('[data-review-panel]:hover')) return + } + + const button = panel.querySelector( + `button[data-review-keybind="${keybind}"]`, + ) + if (!button || button.disabled || button.closest('[data-review-panel]') !== panel) return + + event.preventDefault() + button.click() + }) +} diff --git a/apps/frontend/src/components/project-review/tech-review/index.vue b/apps/frontend/src/components/project-review/tech-review/index.vue new file mode 100644 index 00000000000..9b576c09e9b --- /dev/null +++ b/apps/frontend/src/components/project-review/tech-review/index.vue @@ -0,0 +1,170 @@ + + + diff --git a/apps/frontend/src/components/project-review/versions/index.vue b/apps/frontend/src/components/project-review/versions/index.vue new file mode 100644 index 00000000000..4d682f8e101 --- /dev/null +++ b/apps/frontend/src/components/project-review/versions/index.vue @@ -0,0 +1,116 @@ + + + diff --git a/apps/frontend/src/components/project-review/versions/version-card.vue b/apps/frontend/src/components/project-review/versions/version-card.vue new file mode 100644 index 00000000000..25caba8c0fa --- /dev/null +++ b/apps/frontend/src/components/project-review/versions/version-card.vue @@ -0,0 +1,348 @@ + + + diff --git a/apps/frontend/src/components/ui/moderation/settings/ModerationKeybinds.vue b/apps/frontend/src/components/ui/moderation/settings/ModerationKeybinds.vue index 2ef3e51d834..68ffd49f644 100644 --- a/apps/frontend/src/components/ui/moderation/settings/ModerationKeybinds.vue +++ b/apps/frontend/src/components/ui/moderation/settings/ModerationKeybinds.vue @@ -1,27 +1,65 @@ diff --git a/apps/frontend/src/components/ui/moderation/settings/ModerationSettings.vue b/apps/frontend/src/components/ui/moderation/settings/ModerationSettings.vue index eb22ba2d388..d1a9de2d37e 100644 --- a/apps/frontend/src/components/ui/moderation/settings/ModerationSettings.vue +++ b/apps/frontend/src/components/ui/moderation/settings/ModerationSettings.vue @@ -2,6 +2,10 @@ import { moderationSettings, type SettingDefinition } from '@modrinth/moderation' import { Button, Combobox, Input, Toggle } from '@modrinth/ui' +defineProps<{ + embedded?: boolean +}>() + const localhostHostname = 'localhost:3000' const flattenedSettings = Object.entries(moderationSettings).reduce( @@ -37,7 +41,11 @@ const displayedSettings = ref<{ [name: string]: SettingDefinition[] }>(flattened