diff --git a/packages/app/src/components/PagePanels/index.jsx b/packages/app/src/components/PagePanels/index.jsx
new file mode 100644
index 00000000..f4dd415b
--- /dev/null
+++ b/packages/app/src/components/PagePanels/index.jsx
@@ -0,0 +1,177 @@
+import React from "react"
+import classnames from "classnames"
+import * as antd from "antd"
+
+import { createIconRender } from "components/Icons"
+
+import "./index.less"
+
+export const Panel = (props) => {
+ return
+ {props.children}
+
+}
+
+export class PagePanelWithNavMenu extends React.Component {
+ state = {
+ // if defaultTab is not set, try to get it from query, if not, use the first tab
+ activeTab: this.props.defaultTab ?? new URLSearchParams(window.location.search).get("type") ?? Object.keys(this.props.tabs)[0],
+ }
+
+ primaryPanelRef = React.createRef()
+
+ renderActiveTab() {
+ const tab = this.props.tabs[this.state.activeTab]
+
+ if (!tab) {
+ if (this.props.onNotFound) {
+ return this.props.onNotFound()
+ }
+
+ return
+ }
+
+ return React.createElement(tab.component)
+ }
+
+ replaceQueryTypeToCurrentTab = () => {
+ app.history.replace(`${window.location.pathname}?type=${this.state.activeTab}`)
+ }
+
+ handleTabChange = async (key) => {
+ if (this.state.activeTab === key) return
+
+ if (this.props.transition) {
+ // set to primary panel fade-opacity-leave class
+ this.primaryPanelRef.current.classList.add("fade-opacity-leave")
+
+ // remove fade-opacity-leave class after animation
+ setTimeout(() => {
+ this.primaryPanelRef.current.classList.remove("fade-opacity-leave")
+ }, 300)
+
+ await new Promise(resolve => setTimeout(resolve, 200))
+ }
+
+ if (this.props.onTabChange) {
+ this.props.onTabChange(key)
+ }
+
+ this.setState({ activeTab: key })
+
+ if (this.props.useSetQueryType) {
+ this.replaceQueryTypeToCurrentTab()
+ }
+ }
+
+ render() {
+ const panels = [
+ {
+ children:
+ },
+ {
+ props: {
+ ref: this.primaryPanelRef,
+ className: this.props.transition ? "fade-opacity-enter" : undefined,
+ },
+ children: this.renderActiveTab()
+ },
+ ]
+
+ if (this.props.extraPanel) {
+ panels.push(this.props.extraPanel)
+ }
+
+ return
+ }
+}
+
+export default class PagePanels extends React.Component {
+ generateGridStyle = () => {
+ switch (this.props.panels.length) {
+ case 1: {
+ return {
+ gridTemplateColumns: "1fr",
+ }
+ }
+ case 2: {
+ return {
+ gridTemplateColumns: "1fr 3fr",
+ }
+ }
+ case 3: {
+ return {
+ gridTemplateColumns: "1fr 1fr 1fr",
+ }
+ }
+ }
+ }
+
+ render() {
+ if (!this.props.panels) {
+ return null
+ }
+
+ return
+ {
+ this.props.panels[0] &&
+ }
+ {
+ this.props.panels[1] &&
+ }
+ {
+ this.props.panels[2] &&
+ }
+
+ }
+}
\ No newline at end of file
diff --git a/packages/app/src/components/PagePanels/index.less b/packages/app/src/components/PagePanels/index.less
new file mode 100644
index 00000000..cbe5ca6b
--- /dev/null
+++ b/packages/app/src/components/PagePanels/index.less
@@ -0,0 +1,82 @@
+.pagePanels {
+ display: grid;
+
+ grid-template-columns: 1fr 1fr 1fr;
+ grid-template-rows: 1fr;
+ grid-column-gap: 20px;
+ grid-row-gap: 0px;
+
+ width: 100%;
+
+ .panel {
+ position: sticky;
+ top: 0;
+
+ height: fit-content;
+
+ display: flex;
+ flex-direction: column;
+
+ align-items: center;
+
+ &.full {
+ height: 100%;
+ }
+
+ &.left {
+ .card {
+ background-color: var(--background-color-accent);
+
+ .ant-menu {
+ .ant-menu-item-selected {
+ background-color: var(--background-color-primary) !important;
+ }
+
+ .ant-menu-item-disabled {
+ .ant-menu-title-content {
+ svg {
+ color: var(--disabled-color) !important;
+ }
+
+ color: var(--disabled-color) !important;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ .card {
+ display: flex;
+ flex-direction: column;
+
+ background-color: var(--background-color-accent);
+ border-radius: 12px;
+ padding: 20px;
+
+ margin-bottom: 20px;
+ width: 20vw;
+
+ h1,
+ h2 {
+ width: fit-content;
+ margin: 0;
+ }
+
+ .header {
+ display: flex;
+ flex-direction: row;
+
+ justify-content: space-between;
+ align-items: center;
+
+ width: 100%;
+
+ margin-bottom: 10px;
+ }
+ }
+
+ &.card:last-child {
+ margin-bottom: 0;
+ }
+}
\ No newline at end of file