move settings to page

This commit is contained in:
SrGooglo 2023-02-13 13:28:40 +00:00
parent 0800763acc
commit bbdda50f09
4 changed files with 658 additions and 655 deletions

View File

@ -1,492 +0,0 @@
import React from "react"
import * as antd from "antd"
import { SliderPicker } from "react-color"
import { Translation } from "react-i18next"
import classnames from "classnames"
import config from "config"
import { Icons, createIconRender } from "components/Icons"
import SettingsList from "schemas/settings"
import groupsDecorator from "schemas/settingsGroupsDecorator.json"
import "./index.less"
const ItemTypes = {
Button: antd.Button,
Switch: antd.Switch,
Slider: antd.Slider,
Checkbox: antd.Checkbox,
Input: antd.Input,
TextArea: antd.Input.TextArea,
InputNumber: antd.InputNumber,
Select: antd.Select,
SliderColorPicker: SliderPicker,
}
const SettingsFooter = (props) => {
const isDevMode = window.__evite?.env?.NODE_ENV !== "production"
return <div className="footer" >
<div>
<div>{config.app?.siteName}</div>
<div>
<antd.Tag>
<Icons.Tag />v{window.app.version}
</antd.Tag>
</div>
<div>
<antd.Tag color={isDevMode ? "magenta" : "green"}>
{isDevMode ? <Icons.Triangle /> : <Icons.Box />}
{isDevMode ? "development" : "stable"}
</antd.Tag>
</div>
</div>
<div>
<antd.Button type="link" onClick={props.onClickAppAbout}>
<Translation>
{t => t("about")}
</Translation>
</antd.Button>
</div>
</div>
}
const SettingItem = (props) => {
let { item } = props
const [loading, setLoading] = React.useState(true)
const [value, setValue] = React.useState(null)
const [delayedValue, setDelayedValue] = React.useState(null)
const [disabled, setDisabled] = React.useState(false)
let SettingComponent = item.component
if (!SettingComponent) {
console.error(`Item [${item.id}] has no an component!`)
return null
}
if (typeof item.props === "undefined") {
item.props = {}
}
const dispatchUpdate = async (updateValue) => {
if (typeof item.onUpdate === "function") {
const result = await item.onUpdate(updateValue).catch((error) => {
console.error(error)
antd.message.error(error.message)
return false
})
if (!result) {
return false
}
updateValue = result
} else {
const storagedValue = await window.app.settings.get(item.id)
if (typeof updateValue === "undefined") {
updateValue = !storagedValue
}
}
if (item.storaged) {
await window.app.settings.set(item.id, updateValue)
}
if (typeof item.emitEvent !== "undefined") {
let emissionPayload = updateValue
if (typeof item.emissionValueUpdate === "function") {
emissionPayload = item.emissionValueUpdate(emissionPayload)
}
if (Array.isArray(item.emitEvent)) {
window.app.eventBus.emit(...item.emitEvent, emissionPayload)
} else if (typeof item.emitEvent === "string") {
window.app.eventBus.emit(item.emitEvent, emissionPayload)
}
}
if (item.noUpdate) {
return false
}
if (item.debounced) {
setDelayedValue(null)
}
setValue(updateValue)
}
const onUpdateItem = async (updateValue) => {
setValue(updateValue)
if (!item.debounced) {
await dispatchUpdate(updateValue)
} else {
setDelayedValue(updateValue)
}
}
const checkDependsValidation = () => {
return !Boolean(Object.keys(item.dependsOn).every((key) => {
const storagedValue = window.app.settings.get(key)
console.debug(`Checking validation for [${key}] with now value [${storagedValue}]`)
if (typeof item.dependsOn[key] === "function") {
return item.dependsOn[key](storagedValue)
}
return storagedValue === item.dependsOn[key]
}))
}
const settingInitialization = async () => {
if (item.storaged) {
const storagedValue = window.app.settings.get(item.id)
setValue(storagedValue)
}
if (typeof item.defaultValue === "function") {
setLoading(true)
setValue(await item.defaultValue(props.ctx))
setLoading(false)
}
if (item.disabled === true) {
setDisabled(true)
}
if (typeof item.dependsOn === "object") {
// create a event handler to watch changes
Object.keys(item.dependsOn).forEach((key) => {
window.app.eventBus.on(`setting.update.${key}`, () => {
setDisabled(checkDependsValidation())
})
})
// by default check depends validation
setDisabled(checkDependsValidation())
}
if (typeof item.listenUpdateValue === "string") {
window.app.eventBus.on(`setting.update.${item.listenUpdateValue}`, (value) => setValue(value))
}
if (item.reloadValueOnUpdateEvent) {
window.app.eventBus.on(item.reloadValueOnUpdateEvent, () => {
console.log(`Reloading value for item [${item.id}]`)
settingInitialization()
})
}
setLoading(false)
}
React.useEffect(() => {
settingInitialization()
return () => {
if (typeof item.dependsOn === "object") {
for (let key in item.dependsOn) {
window.app.eventBus.off(`setting.update.${key}`, onUpdateItem)
}
}
}
}, [])
if (typeof SettingComponent === "string") {
if (typeof ItemTypes[SettingComponent] === "undefined") {
console.error(`Item [${item.id}] has an invalid component: ${item.component}`)
return null
}
switch (SettingComponent.toLowerCase()) {
case "slidercolorpicker": {
item.props.onChange = (color) => {
item.props.color = color.hex
}
item.props.onChangeComplete = (color) => {
onUpdateItem(color.hex)
}
item.props.color = value
break
}
case "textarea": {
item.props.defaultValue = value
item.props.onPressEnter = (event) => dispatchUpdate(event.target.value)
item.props.onChange = (event) => onUpdateItem(event.target.value)
break
}
case "input": {
item.props.defaultValue = value
item.props.onPressEnter = (event) => dispatchUpdate(event.target.value)
item.props.onChange = (event) => onUpdateItem(event.target.value)
break
}
case "switch": {
item.props.checked = value
item.props.onClick = (event) => onUpdateItem(event)
break
}
case "select": {
item.props.onChange = (value) => onUpdateItem(value)
item.props.defaultValue = value
break
}
case "slider": {
item.props.defaultValue = value
item.props.onAfterChange = (value) => onUpdateItem(value)
break
}
default: {
if (!item.props.children) {
item.props.children = item.title ?? item.id
}
item.props.value = item.defaultValue
item.props.onClick = (event) => onUpdateItem(event)
break
}
}
// override with default item component
SettingComponent = ItemTypes[SettingComponent]
}
item.props["disabled"] = disabled
return <div key={item.id} className="settingItem">
<div className="header">
<div className="title">
<div>
<h4>
{Icons[item.icon] ? React.createElement(Icons[item.icon]) : null}
<Translation>{
t => t(item.title ?? item.id)
}</Translation>
</h4>
<p> <Translation>{
t => t(item.description)
}</Translation></p>
</div>
<div>
{item.experimental && <antd.Tag> Experimental </antd.Tag>}
</div>
</div>
{item.extraActions &&
<div className="extraActions">
{item.extraActions.map((action, index) => {
return <div>
<antd.Button
key={action.id}
id={action.id}
onClick={action.onClick}
icon={action.icon && createIconRender(action.icon)}
type={action.type ?? "round"}
>
{action.title}
</antd.Button>
</div>
})}
</div>
}
</div>
<div className="component">
<div>
{
loading
? <div> Loading... </div>
: React.createElement(SettingComponent, {
...item.props,
ctx: {
currentValue: value,
dispatchUpdate,
onUpdateItem,
...props.ctx,
}
})}
</div>
{
delayedValue && <div>
<antd.Button
type="round"
icon={<Icons.Save />}
onClick={async () => await dispatchUpdate(value)}
>
Save
</antd.Button>
</div>
}
</div>
</div>
}
const SettingGroup = React.memo((props) => {
const {
ctx,
groupKey,
settings,
loading,
} = props
const fromDecoratorIcon = groupsDecorator[groupKey]?.icon
const fromDecoratorTitle = groupsDecorator[groupKey]?.title
if (loading) {
return <antd.Skeleton active />
}
return <div index={groupKey} key={groupKey} className="group">
<h1>
{
fromDecoratorIcon ? React.createElement(Icons[fromDecoratorIcon]) : null
}
<Translation>
{
t => t(fromDecoratorTitle ?? groupKey)
}
</Translation>
</h1>
<div className="content">
{
settings.map((item) => <SettingItem
item={item}
ctx={ctx}
/>)
}
</div>
</div>
})
const SettingTab = React.memo((props) => {
const { tab } = props
const [loading, setLoading] = React.useState(true)
const [ctxData, setCtxData] = React.useState(null)
let groupsSettings = {}
if (!Array.isArray(tab.settings)) {
console.error("Cannot generate settings from non-array")
return groupsSettings
}
tab.settings.forEach((item) => {
if (!groupsSettings[item.group]) {
groupsSettings[item.group] = []
}
groupsSettings[item.group].push(item)
})
const processCtx = async () => {
setLoading(true)
if (typeof tab.ctxData === "function") {
const resultCtx = await tab.ctxData()
setCtxData(resultCtx)
}
setLoading(false)
}
React.useEffect(() => {
processCtx()
}, [])
return Object.keys(groupsSettings).map((groupKey) => {
return <SettingGroup
groupKey={groupKey}
settings={groupsSettings[groupKey]}
loading={loading}
ctx={ctxData}
/>
})
})
const SettingsTabs = Object.keys(SettingsList).map((settingsKey) => {
const tab = SettingsList[settingsKey]
return {
key: settingsKey,
label: <>
{createIconRender(tab.icon ?? "Settings")}
{tab.label}
</>,
children: <SettingTab
tab={tab}
/>
}
})
export default class SettingsMenu extends React.PureComponent {
state = {
activeKey: "app"
}
componentDidMount = async () => {
if (typeof this.props.close === "function") {
// register escape key to close settings menu
window.addEventListener("keydown", this.handleKeyDown)
}
}
componentWillUnmount() {
if (typeof this.props.close === "function") {
window.removeEventListener("keydown", this.handleKeyDown)
}
}
handleKeyDown = (event) => {
if (event.key === "Escape") {
this.props.close()
}
}
onClickAppAbout = () => {
window.app.setLocation("/about")
if (typeof this.props.close === "function") {
this.props.close()
}
}
changeTab = (activeKey) => {
this.setState({ activeKey })
}
render() {
return <div
className={classnames(
"settings_wrapper",
{
["mobile"]: window.isMobile,
}
)}
>
<div className="settings">
<antd.Tabs
activeKey={this.state.activeKey}
onTabClick={this.changeTab}
tabPosition={window.isMobile ? "top" : "left"}
centered={window.isMobile}
destroyInactiveTabPane
items={SettingsTabs}
/>
<SettingsFooter onClickAppAbout={this.onClickAppAbout} />
</div>
</div>
}
}

View File

@ -1,163 +0,0 @@
.settings_wrapper {
.settings {
display: flex;
flex-direction: column;
>div {
margin-bottom: 25px;
}
&.mobile {
.ant-tabs-nav-list {
width: 100%;
justify-content: space-evenly;
}
}
.ant-tabs-nav {
height: fit-content;
position: sticky;
top: 0;
left: 0;
.ant-tabs-nav-wrap {
height: fit-content;
.ant-tabs-nav-list {
height: fit-content;
.ant-tabs-tab {
padding: 5px 0 !important;
margin-right: 10px !important;
}
}
}
}
.group {
display: flex;
flex-direction: column;
color: var(--background-color-contrast);
h1,
h2,
h3,
h4,
h5,
h6 {
color: var(--background-color-contrast);
}
.content {
>div {
margin-bottom: 25px;
}
}
}
.settingItem {
padding: 0 20px;
>div {
margin-bottom: 10px;
}
.header {
display: inline-flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
.title {
display: flex;
align-items: center;
color: var(--background-color-contrast);
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 0;
color: var(--background-color-contrast);
}
p {
font-size: 11px;
color: var(--background-color-contrast);
margin: 0;
}
>div {
margin-right: 10px;
}
}
.extraActions {
display: inline-flex;
align-items: center;
>div {
margin-right: 10px;
}
}
}
.component {
display: flex;
flex-direction: column;
--ignore-dragger: true;
padding: 0 20px;
span {
color: var(--background-color-contrast);
}
>div {
margin-bottom: 10px;
}
}
}
.footer {
position: relative;
width: 100%;
padding-top: 20px;
padding-bottom: 20px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
>div {
margin-bottom: 10px;
font-family: "DM Mono", monospace;
font-size: 10px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
.ant-tag {
height: 18px;
line-height: 18px;
font-size: 10px;
}
>div {
padding: 0 7px;
}
}
}
}
}

View File

@ -0,0 +1,506 @@
import React from "react"
import * as antd from "antd"
import { SliderPicker } from "react-color"
import { Translation } from "react-i18next"
import classnames from "classnames"
import { Icons, createIconRender } from "components/Icons"
import SettingsList from "schemas/settings"
import menuGroupsDecorators from "schemas/settingsMenuGroupsDecorators"
import groupsDecorators from "schemas/settingsGroupsDecorators"
import "./index.less"
const ItemTypes = {
Button: antd.Button,
Switch: antd.Switch,
Slider: antd.Slider,
Checkbox: antd.Checkbox,
Input: antd.Input,
TextArea: antd.Input.TextArea,
InputNumber: antd.InputNumber,
Select: antd.Select,
SliderColorPicker: SliderPicker,
}
const SettingItem = (props) => {
let { item } = props
const [loading, setLoading] = React.useState(true)
const [value, setValue] = React.useState(null)
const [delayedValue, setDelayedValue] = React.useState(null)
const [disabled, setDisabled] = React.useState(false)
let SettingComponent = item.component
if (!SettingComponent) {
console.error(`Item [${item.id}] has no an component!`)
return null
}
if (typeof item.props === "undefined") {
item.props = {}
}
const dispatchUpdate = async (updateValue) => {
if (typeof item.onUpdate === "function") {
try {
const result = await item.onUpdate(updateValue)
if (result) {
updateValue = result
}
} catch (error) {
console.error(error)
if (error.response.data.error) {
app.message.error(error.response.data.error)
} else {
app.message.error(error.message)
}
return false
}
} else {
const storagedValue = await window.app.settings.get(item.id)
if (typeof updateValue === "undefined") {
updateValue = !storagedValue
}
}
if (item.storaged) {
await window.app.settings.set(item.id, updateValue)
}
if (typeof item.emitEvent !== "undefined") {
let emissionPayload = updateValue
if (typeof item.emissionValueUpdate === "function") {
emissionPayload = item.emissionValueUpdate(emissionPayload)
}
if (Array.isArray(item.emitEvent)) {
window.app.eventBus.emit(...item.emitEvent, emissionPayload)
} else if (typeof item.emitEvent === "string") {
window.app.eventBus.emit(item.emitEvent, emissionPayload)
}
}
if (item.noUpdate) {
return false
}
if (item.debounced) {
setDelayedValue(null)
}
setValue(updateValue)
}
const onUpdateItem = async (updateValue) => {
setValue(updateValue)
if (!item.debounced) {
await dispatchUpdate(updateValue)
} else {
setDelayedValue(updateValue)
}
}
const checkDependsValidation = () => {
return !Boolean(Object.keys(item.dependsOn).every((key) => {
const storagedValue = window.app.settings.get(key)
console.debug(`Checking validation for [${key}] with now value [${storagedValue}]`)
if (typeof item.dependsOn[key] === "function") {
return item.dependsOn[key](storagedValue)
}
return storagedValue === item.dependsOn[key]
}))
}
const settingInitialization = async () => {
if (item.storaged) {
const storagedValue = window.app.settings.get(item.id)
setValue(storagedValue)
}
if (typeof item.defaultValue === "function") {
setLoading(true)
setValue(await item.defaultValue(props.ctx))
setLoading(false)
}
if (item.disabled === true) {
setDisabled(true)
}
if (typeof item.dependsOn === "object") {
// create a event handler to watch changes
Object.keys(item.dependsOn).forEach((key) => {
window.app.eventBus.on(`setting.update.${key}`, () => {
setDisabled(checkDependsValidation())
})
})
// by default check depends validation
setDisabled(checkDependsValidation())
}
if (typeof item.listenUpdateValue === "string") {
window.app.eventBus.on(`setting.update.${item.listenUpdateValue}`, (value) => setValue(value))
}
if (item.reloadValueOnUpdateEvent) {
window.app.eventBus.on(item.reloadValueOnUpdateEvent, () => {
console.log(`Reloading value for item [${item.id}]`)
settingInitialization()
})
}
setLoading(false)
}
React.useEffect(() => {
settingInitialization()
return () => {
if (typeof item.dependsOn === "object") {
for (let key in item.dependsOn) {
window.app.eventBus.off(`setting.update.${key}`, onUpdateItem)
}
}
}
}, [])
if (typeof SettingComponent === "string") {
if (typeof ItemTypes[SettingComponent] === "undefined") {
console.error(`Item [${item.id}] has an invalid component: ${item.component}`)
return null
}
switch (SettingComponent.toLowerCase()) {
case "slidercolorpicker": {
item.props.onChange = (color) => {
item.props.color = color.hex
}
item.props.onChangeComplete = (color) => {
onUpdateItem(color.hex)
}
item.props.color = value
break
}
case "textarea": {
item.props.defaultValue = value
item.props.onPressEnter = (event) => dispatchUpdate(event.target.value)
item.props.onChange = (event) => onUpdateItem(event.target.value)
break
}
case "input": {
item.props.defaultValue = value
item.props.onPressEnter = (event) => dispatchUpdate(event.target.value)
item.props.onChange = (event) => onUpdateItem(event.target.value)
break
}
case "switch": {
item.props.checked = value
item.props.onClick = (event) => onUpdateItem(event)
break
}
case "select": {
item.props.onChange = (value) => onUpdateItem(value)
item.props.defaultValue = value
break
}
case "slider": {
item.props.defaultValue = value
item.props.onAfterChange = (value) => onUpdateItem(value)
break
}
default: {
if (!item.props.children) {
item.props.children = item.title ?? item.id
}
item.props.value = item.defaultValue
item.props.onClick = (event) => onUpdateItem(event)
break
}
}
// override with default item component
SettingComponent = ItemTypes[SettingComponent]
}
item.props["disabled"] = disabled
return <div key={item.id} className="settingItem">
<div className="header">
<div className="title">
<div>
<h4>
{Icons[item.icon] ? React.createElement(Icons[item.icon]) : null}
<Translation>{
t => t(item.title ?? item.id)
}</Translation>
</h4>
<p> <Translation>{
t => t(item.description)
}</Translation></p>
</div>
<div>
{item.experimental && <antd.Tag> Experimental </antd.Tag>}
</div>
</div>
{item.extraActions &&
<div className="extraActions">
{item.extraActions.map((action, index) => {
return <div>
<antd.Button
key={action.id}
id={action.id}
onClick={action.onClick}
icon={action.icon && createIconRender(action.icon)}
type={action.type ?? "round"}
>
{action.title}
</antd.Button>
</div>
})}
</div>
}
</div>
<div className="component">
<div>
{
loading
? <div> Loading... </div>
: React.createElement(SettingComponent, {
...item.props,
ctx: {
currentValue: value,
dispatchUpdate,
onUpdateItem,
...props.ctx,
}
})}
</div>
{
delayedValue && <div>
<antd.Button
type="round"
icon={<Icons.Save />}
onClick={async () => await dispatchUpdate(value)}
>
Save
</antd.Button>
</div>
}
</div>
</div>
}
const SettingGroup = React.memo((props) => {
const {
ctx,
groupKey,
settings,
loading,
} = props
const fromDecoratorIcon = groupsDecorators[groupKey]?.icon
const fromDecoratorTitle = groupsDecorators[groupKey]?.title
if (loading) {
return <antd.Skeleton active />
}
return <div index={groupKey} key={groupKey} className="group">
<h1>
{
fromDecoratorIcon ? React.createElement(Icons[fromDecoratorIcon]) : null
}
<Translation>
{
t => t(fromDecoratorTitle ?? groupKey)
}
</Translation>
</h1>
<div className="content">
{
settings.map((item) => <SettingItem
item={item}
ctx={ctx}
/>)
}
</div>
</div>
})
const SettingTab = (props) => {
const [groups, setGroups] = React.useState({})
const [loading, setLoading] = React.useState(true)
const [ctxData, setCtxData] = React.useState({})
const processCtx = async () => {
setLoading(true)
if (typeof props.tab.ctxData === "function") {
const resultCtx = await props.tab.ctxData()
setCtxData(resultCtx)
}
setLoading(false)
}
React.useEffect(() => {
if (!Array.isArray(props.tab.settings)) {
console.error("Cannot generate settings from non-array")
return []
}
let groupsSettings = {}
props.tab.settings.forEach((item) => {
if (!groupsSettings[item.group]) {
groupsSettings[item.group] = []
}
groupsSettings[item.group].push(item)
})
setGroups(groupsSettings)
processCtx()
}, [props.tab])
if (loading) {
return <antd.Skeleton active />
}
return Object.keys(groups).map((groupKey) => {
return <SettingGroup
groupKey={groupKey}
settings={groups[groupKey]}
loading={loading}
ctx={ctxData}
/>
})
}
const generateMenuItems = () => {
const groups = {}
Object.keys(SettingsList).forEach((tabKey) => {
const tab = SettingsList[tabKey]
if (!tab.group) {
tab.group = "Others"
}
groups[tab.group] = groups[tab.group] ?? []
groups[tab.group].push(tab)
})
let groupsKeys = Object.keys(groups)
// make "bottom" group last
groupsKeys = groupsKeys.sort((a, b) => {
if (a === "bottom") {
return 1
}
if (b === "bottom") {
return -1
}
return 0
})
return groupsKeys.map((groupKey, index) => {
const children = groups[groupKey].map((item) => {
return {
key: item.id,
label: <>
{createIconRender(item.icon ?? "Settings")}
{item.label}
</>,
}
})
if (index !== groupsKeys.length - 1) {
children.push({
type: "divider",
})
}
return {
key: groupKey,
label: groupKey === "bottom" ? null : <>
{
menuGroupsDecorators[groupKey]?.icon && createIconRender(menuGroupsDecorators[groupKey]?.icon ?? "Settings")
}
<Translation>
{
t => t(menuGroupsDecorators[groupKey]?.label ?? groupKey)
}
</Translation>
</>,
type: "group",
children: children
}
})
}
export default React.memo(() => {
const [activeKey, setActiveKey] = React.useState("general")
const [menuItems, setMenuItems] = React.useState([])
const onChangeTab = (event) => {
setActiveKey(event.key)
}
React.useEffect(() => {
setMenuItems(generateMenuItems())
}, [])
return <div
className={classnames(
"settings_wrapper",
{
["mobile"]: window.isMobile,
}
)}
>
<div className="settings_menu">
<antd.Menu
mode="vertical"
items={menuItems}
onClick={onChangeTab}
selectedKeys={[activeKey]}
/>
</div>
<div className="settings_content">
{
SettingsList[activeKey] &&
React.createElement(SettingsList[activeKey].render ?? SettingTab, {
tab: SettingsList[activeKey],
})
}
</div>
</div>
})

View File

@ -0,0 +1,152 @@
.settings_wrapper {
display: flex;
flex-direction: row;
.settings_menu {
position: sticky;
top: 0;
left: 0;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
width: 30%;
}
.settings_content {
display: flex;
flex-direction: column;
width: 70%;
.group {
display: flex;
flex-direction: column;
color: var(--background-color-contrast);
h1,
h2,
h3,
h4,
h5,
h6 {
color: var(--background-color-contrast);
}
.content {
>div {
margin-bottom: 25px;
}
}
}
.settingItem {
padding: 0 20px;
>div {
margin-bottom: 10px;
}
.header {
display: inline-flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
.title {
display: flex;
align-items: center;
color: var(--background-color-contrast);
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 0;
color: var(--background-color-contrast);
}
p {
font-size: 11px;
color: var(--background-color-contrast);
margin: 0;
}
>div {
margin-right: 10px;
}
}
.extraActions {
display: inline-flex;
align-items: center;
>div {
margin-right: 10px;
}
}
}
.component {
display: flex;
flex-direction: column;
--ignore-dragger: true;
padding: 0 20px;
span {
color: var(--background-color-contrast);
}
>div {
margin-bottom: 10px;
}
}
}
.footer {
position: relative;
width: 100%;
padding-top: 20px;
padding-bottom: 20px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
>div {
margin-bottom: 10px;
font-family: "DM Mono", monospace;
font-size: 10px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
.ant-tag {
height: 18px;
line-height: 18px;
font-size: 10px;
}
>div {
padding: 0 7px;
}
}
}
}
}