mirror of
https://github.com/DeNNiiInc/dbgate.git
synced 2026-04-17 23:45:59 +00:00
markdown manifest
This commit is contained in:
@@ -3,16 +3,16 @@ const currentVersion = require('../currentVersion');
|
||||
module.exports = {
|
||||
get_meta: 'get',
|
||||
async get() {
|
||||
const toolbarButtons = process.env.TOOLBAR;
|
||||
const toolbar = toolbarButtons
|
||||
? toolbarButtons.split(',').map((name) => ({
|
||||
name,
|
||||
icon: process.env[`ICON_${name}`],
|
||||
title: process.env[`TITLE_${name}`],
|
||||
page: process.env[`PAGE_${name}`],
|
||||
}))
|
||||
: null;
|
||||
const startupPages = process.env.STARTUP_PAGES ? process.env.STARTUP_PAGES.split(',') : [];
|
||||
// const toolbarButtons = process.env.TOOLBAR;
|
||||
// const toolbar = toolbarButtons
|
||||
// ? toolbarButtons.split(',').map((name) => ({
|
||||
// name,
|
||||
// icon: process.env[`ICON_${name}`],
|
||||
// title: process.env[`TITLE_${name}`],
|
||||
// page: process.env[`PAGE_${name}`],
|
||||
// }))
|
||||
// : null;
|
||||
// const startupPages = process.env.STARTUP_PAGES ? process.env.STARTUP_PAGES.split(',') : [];
|
||||
const permissions = process.env.PERMISSIONS ? process.env.PERMISSIONS.split(',') : null;
|
||||
const singleDatabase =
|
||||
process.env.SINGLE_CONNECTION && process.env.SINGLE_DATABASE
|
||||
@@ -24,8 +24,8 @@ module.exports = {
|
||||
|
||||
return {
|
||||
runAsPortal: !!process.env.CONNECTIONS,
|
||||
toolbar,
|
||||
startupPages,
|
||||
// toolbar,
|
||||
// startupPages,
|
||||
singleDatabase,
|
||||
permissions,
|
||||
...currentVersion,
|
||||
|
||||
@@ -5,6 +5,10 @@ const hasPermission = require('../utility/hasPermission');
|
||||
const socket = require('../utility/socket');
|
||||
const scheduler = require('./scheduler');
|
||||
|
||||
const markdownAutorunRegex = /<\!--.*@autorun\s*(\n.*-->|-->)/s;
|
||||
const markdownButtonRegex = /<\!--.*@button\s+([^\n]+)(\n.*-->|-->)/s;
|
||||
const markdownIconRegex = /<\!--.*@icon\s+([^\n]+)(\n.*-->|-->)/s;
|
||||
|
||||
function serialize(format, data) {
|
||||
if (format == 'text') return data;
|
||||
if (format == 'json') return JSON.stringify(data);
|
||||
@@ -61,4 +65,29 @@ module.exports = {
|
||||
scheduler.reload();
|
||||
}
|
||||
},
|
||||
|
||||
markdownManifest_meta: 'get',
|
||||
async markdownManifest() {
|
||||
if (!hasPermission(`files/markdown/write`)) return {};
|
||||
const dir = path.join(filesdir(), 'markdown');
|
||||
if (!(await fs.exists(dir))) return {};
|
||||
const files = await fs.readdir(dir);
|
||||
const res = [];
|
||||
for (const file of files) {
|
||||
const filePath = path.join(dir, file);
|
||||
const text = await fs.readFile(filePath, { encoding: 'utf-8' });
|
||||
const autorun = text.match(markdownAutorunRegex);
|
||||
const button = text.match(markdownButtonRegex);
|
||||
const icon = text.match(markdownIconRegex);
|
||||
if (autorun || button) {
|
||||
res.push({
|
||||
file,
|
||||
autorun: !!autorun,
|
||||
button: button ? button[1].trim() : undefined,
|
||||
icon: icon ? icon[1].trim() : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
return res;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -72,9 +72,9 @@ function start(argument = null) {
|
||||
useController(app, '/files', files);
|
||||
useController(app, '/scheduler', scheduler);
|
||||
|
||||
if (process.env.PAGES_DIRECTORY) {
|
||||
app.use('/pages', express.static(process.env.PAGES_DIRECTORY));
|
||||
}
|
||||
// if (process.env.PAGES_DIRECTORY) {
|
||||
// app.use('/pages', express.static(process.env.PAGES_DIRECTORY));
|
||||
// }
|
||||
|
||||
app.use('/runners/data', express.static(rundir()));
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ const iconNames = {
|
||||
'icon account': 'mdi mdi-account',
|
||||
'icon sql-file': 'mdi mdi-file',
|
||||
'icon web': 'mdi mdi-web',
|
||||
'icon home': 'mdi mdi-home',
|
||||
|
||||
'icon edit': 'mdi mdi-pencil',
|
||||
'icon delete': 'mdi mdi-delete',
|
||||
@@ -40,6 +41,7 @@ const iconNames = {
|
||||
'icon error': 'mdi mdi-close-circle',
|
||||
'icon ok': 'mdi mdi-check-circle',
|
||||
'icon markdown': 'mdi mdi-application',
|
||||
'icon preview': 'mdi mdi-file-find',
|
||||
|
||||
'icon run': 'mdi mdi-play',
|
||||
'icon chevron-down': 'mdi mdi-chevron-down',
|
||||
@@ -62,6 +64,7 @@ const iconNames = {
|
||||
'img shell': 'mdi mdi-flash color-blue-7',
|
||||
'img chart': 'mdi mdi-chart-bar color-magenta-7',
|
||||
'img markdown': 'mdi mdi-application color-red-7',
|
||||
'img preview': 'mdi mdi-file-find color-red-7',
|
||||
|
||||
'img free-table': 'mdi mdi-table color-green-7',
|
||||
'img macro': 'mdi mdi-hammer-wrench',
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import Markdown from 'markdown-to-jsx';
|
||||
import styled from 'styled-components';
|
||||
import OpenChartLink from './OpenChartLink';
|
||||
import MarkdownLink from './MarkdownLink';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
padding: 10px;
|
||||
@@ -18,6 +19,7 @@ export default function MarkdownExtendedView({ children }) {
|
||||
OpenChartLink: {
|
||||
component: OpenChartLink,
|
||||
},
|
||||
a: MarkdownLink,
|
||||
},
|
||||
}}
|
||||
>
|
||||
|
||||
13
packages/web/src/markdown/MarkdownLink.js
Normal file
13
packages/web/src/markdown/MarkdownLink.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import useTheme from '../theme/useTheme';
|
||||
import { StyledThemedLink } from '../widgets/FormStyledButton';
|
||||
|
||||
export default function MarkdownLink({ href, title, children }) {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<StyledThemedLink theme={theme} href={href} title={title} target="_blank">
|
||||
{children}
|
||||
</StyledThemedLink>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import useHasPermission from '../utility/useHasPermission';
|
||||
import ToolbarButton from '../widgets/ToolbarButton';
|
||||
|
||||
export default function MarkdownToolbar({ save }) {
|
||||
export default function MarkdownToolbar({ save, showPreview }) {
|
||||
const hasPermission = useHasPermission();
|
||||
|
||||
return (
|
||||
@@ -12,6 +12,9 @@ export default function MarkdownToolbar({ save }) {
|
||||
Save
|
||||
</ToolbarButton>
|
||||
)}
|
||||
<ToolbarButton onClick={showPreview} icon="icon preview">
|
||||
Preview
|
||||
</ToolbarButton>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
import React from 'react';
|
||||
import { useCurrentDatabase, useSetOpenedTabs } from '../utility/globalState';
|
||||
import styled from 'styled-components';
|
||||
import { openNewTab } from '../utility/common';
|
||||
import axios from '../utility/axios';
|
||||
import useTheme from '../theme/useTheme';
|
||||
|
||||
const StyledLink = styled.a`
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
color: ${(props) => props.theme.main_background_blue[7]};
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
`;
|
||||
import { StyledThemedLink } from '../widgets/FormStyledButton';
|
||||
|
||||
export default function OpenChartLink({ file, children }) {
|
||||
const setOpenedTabs = useSetOpenedTabs();
|
||||
@@ -37,8 +28,8 @@ export default function OpenChartLink({ file, children }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledLink theme={theme} onClick={handleClick}>
|
||||
<StyledThemedLink theme={theme} onClick={handleClick}>
|
||||
{children}
|
||||
</StyledLink>
|
||||
</StyledThemedLink>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import moment from 'moment';
|
||||
import styled from 'styled-components';
|
||||
import getElectron from '../utility/getElectron';
|
||||
import useTheme from '../theme/useTheme';
|
||||
import { StyledThemedLink } from '../widgets/FormStyledButton';
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
@@ -24,15 +25,6 @@ const StyledValue = styled.span`
|
||||
font-weight: bold;
|
||||
`;
|
||||
|
||||
const StyledLink = styled.a`
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
color: ${(props) => props.theme.main_background_blue[7]};
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
`;
|
||||
|
||||
function Line({ label, children }) {
|
||||
return (
|
||||
<StyledLine>
|
||||
@@ -48,13 +40,13 @@ function Link({ label, children, href }) {
|
||||
<StyledLine>
|
||||
{label}:{' '}
|
||||
{electron ? (
|
||||
<StyledLink theme={theme} onClick={() => electron.shell.openExternal(href)}>
|
||||
<StyledThemedLink theme={theme} onClick={() => electron.shell.openExternal(href)}>
|
||||
{children}
|
||||
</StyledLink>
|
||||
</StyledThemedLink>
|
||||
) : (
|
||||
<StyledLink theme={theme} href={href} target="_blank" rel="noopener noreferrer">
|
||||
<StyledThemedLink theme={theme} href={href} target="_blank" rel="noopener noreferrer">
|
||||
{children}
|
||||
</StyledLink>
|
||||
</StyledThemedLink>
|
||||
)}
|
||||
</StyledLine>
|
||||
);
|
||||
|
||||
@@ -8,15 +8,42 @@ import useEditorData from '../utility/useEditorData';
|
||||
import SaveTabModal from '../modals/SaveTabModal';
|
||||
import useModalState from '../modals/useModalState';
|
||||
import LoadingInfo from '../widgets/LoadingInfo';
|
||||
import { useOpenedTabs, useSetOpenedTabs } from '../utility/globalState';
|
||||
import { openNewTab } from '../utility/common';
|
||||
|
||||
export default function MarkdownEditorTab({ tabid, tabVisible, toolbarPortalRef, ...other }) {
|
||||
const { editorData, setEditorData, isLoading } = useEditorData({ tabid });
|
||||
const { editorData, setEditorData, isLoading, saveToStorage } = useEditorData({ tabid });
|
||||
const saveFileModalState = useModalState();
|
||||
const openedTabs = useOpenedTabs();
|
||||
const setOpenedTabs = useSetOpenedTabs();
|
||||
|
||||
const handleKeyDown = (data, hash, keyString, keyCode, event) => {
|
||||
if (keyCode == keycodes.f5) {
|
||||
event.preventDefault();
|
||||
// handlePreview();
|
||||
showPreview();
|
||||
}
|
||||
};
|
||||
|
||||
const showPreview = async () => {
|
||||
await saveToStorage();
|
||||
const existing = (openedTabs || []).find((x) => x.props && x.props.sourceTabId == tabid && x.closedTime == null);
|
||||
if (existing) {
|
||||
setOpenedTabs((tabs) =>
|
||||
tabs.map((x) => ({
|
||||
...x,
|
||||
selected: x.tabid == existing.tabid,
|
||||
}))
|
||||
);
|
||||
} else {
|
||||
const thisTab = (openedTabs || []).find((x) => x.tabid == tabid);
|
||||
openNewTab(setOpenedTabs, {
|
||||
title: thisTab.title,
|
||||
icon: 'img preview',
|
||||
tabComponent: 'MarkdownPreviewTab',
|
||||
props: {
|
||||
sourceTabId: tabid,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -40,7 +67,10 @@ export default function MarkdownEditorTab({ tabid, tabVisible, toolbarPortalRef,
|
||||
{toolbarPortalRef &&
|
||||
toolbarPortalRef.current &&
|
||||
tabVisible &&
|
||||
ReactDOM.createPortal(<MarkdownToolbar save={saveFileModalState.open} />, toolbarPortalRef.current)}
|
||||
ReactDOM.createPortal(
|
||||
<MarkdownToolbar save={saveFileModalState.open} showPreview={showPreview} />,
|
||||
toolbarPortalRef.current
|
||||
)}
|
||||
<SaveTabModal
|
||||
modalState={saveFileModalState}
|
||||
tabVisible={tabVisible}
|
||||
|
||||
23
packages/web/src/tabs/MarkdownPreviewTab.js
Normal file
23
packages/web/src/tabs/MarkdownPreviewTab.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import LoadingInfo from '../widgets/LoadingInfo';
|
||||
import MarkdownExtendedView from '../markdown/MarkdownExtendedView';
|
||||
import useEditorData from '../utility/useEditorData';
|
||||
|
||||
export default function MarkdownPreviewTab({ sourceTabId, tabVisible }) {
|
||||
const [reloadToken, setReloadToken] = React.useState(0);
|
||||
const { editorData, isLoading } = useEditorData({ tabid: sourceTabId, reloadToken });
|
||||
|
||||
React.useEffect(() => {
|
||||
if (tabVisible) setReloadToken((x) => x + 1);
|
||||
}, [tabVisible]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div>
|
||||
<LoadingInfo message="Loading markdown page" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <MarkdownExtendedView>{editorData || ''}</MarkdownExtendedView>;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import PluginTab from './PluginTab';
|
||||
import ChartTab from './ChartTab';
|
||||
import MarkdownEditorTab from './MarkdownEditorTab';
|
||||
import MarkdownViewTab from './MarkdownViewTab';
|
||||
import MarkdownPreviewTab from './MarkdownPreviewTab';
|
||||
|
||||
export default {
|
||||
TableDataTab,
|
||||
@@ -24,4 +25,5 @@ export default {
|
||||
ChartTab,
|
||||
MarkdownEditorTab,
|
||||
MarkdownViewTab,
|
||||
MarkdownPreviewTab,
|
||||
};
|
||||
|
||||
@@ -46,6 +46,12 @@ const configLoader = () => ({
|
||||
reloadTrigger: 'config-changed',
|
||||
});
|
||||
|
||||
const markdownManifestLoader = () => ({
|
||||
url: 'files/markdown-manifest',
|
||||
params: {},
|
||||
reloadTrigger: 'files-changed-markdown',
|
||||
});
|
||||
|
||||
// const sqlObjectListLoader = ({ conid, database }) => ({
|
||||
// url: 'metadata/list-objects',
|
||||
// params: { conid, database },
|
||||
@@ -269,3 +275,10 @@ export function getFiles(args) {
|
||||
export function useFiles(args) {
|
||||
return useCore(filesLoader, args);
|
||||
}
|
||||
|
||||
export function getMarkdownManifest(args) {
|
||||
return getCore(markdownManifestLoader, args);
|
||||
}
|
||||
export function useMarkdownManifest(args) {
|
||||
return useCore(markdownManifestLoader, args);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import localforage from 'localforage';
|
||||
import { changeTab } from './common';
|
||||
import { useSetOpenedTabs } from './globalState';
|
||||
|
||||
export default function useEditorData({ tabid, loadFromArgs = null }) {
|
||||
export default function useEditorData({ tabid, reloadToken = 0, loadFromArgs = null }) {
|
||||
const localStorageKey = `tabdata_${tabid}`;
|
||||
const setOpenedTabs = useSetOpenedTabs();
|
||||
const changeCounterRef = React.useRef(0);
|
||||
@@ -57,19 +57,20 @@ export default function useEditorData({ tabid, loadFromArgs = null }) {
|
||||
|
||||
React.useEffect(() => {
|
||||
initialLoad();
|
||||
}, []);
|
||||
}, [reloadToken]);
|
||||
|
||||
const saveToStorage = React.useCallback(async () => {
|
||||
if (valueRef.current == null) return;
|
||||
try {
|
||||
await localforage.setItem(localStorageKey, valueRef.current);
|
||||
localStorage.removeItem(localStorageKey);
|
||||
savedCounterRef.current = changeCounterRef.current;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}, [localStorageKey, valueRef]);
|
||||
|
||||
const saveToStorageFallback = React.useCallback(() => {
|
||||
const saveToStorageSync = React.useCallback(() => {
|
||||
if (valueRef.current == null) return;
|
||||
if (savedCounterRef.current == changeCounterRef.current) return; // all saved
|
||||
// on window unload must be synchronous actions, save to local storage instead
|
||||
@@ -86,10 +87,10 @@ export default function useEditorData({ tabid, loadFromArgs = null }) {
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
window.addEventListener('beforeunload', saveToStorageFallback);
|
||||
window.addEventListener('beforeunload', saveToStorageSync);
|
||||
return () => {
|
||||
saveToStorage();
|
||||
window.removeEventListener('beforeunload', saveToStorageFallback);
|
||||
window.removeEventListener('beforeunload', saveToStorageSync);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -99,5 +100,7 @@ export default function useEditorData({ tabid, loadFromArgs = null }) {
|
||||
isLoading,
|
||||
initialData: initialDataRef.current,
|
||||
errorMessage,
|
||||
saveToStorage,
|
||||
saveToStorageSync,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -39,8 +39,7 @@ const makeStyle = (base) => styled(base)`
|
||||
|
||||
const ButtonInput = makeStyle(styled.input``);
|
||||
const FormStyledLabelBase = makeStyle(styled.label``);
|
||||
export const FormStyledLabel = styled(FormStyledLabelBase)`
|
||||
`;
|
||||
export const FormStyledLabel = styled(FormStyledLabelBase)``;
|
||||
|
||||
export default function FormStyledButton({
|
||||
onClick = undefined,
|
||||
@@ -67,3 +66,12 @@ export default function FormStyledButton({
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const StyledThemedLink = styled.a`
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
color: ${(props) => props.theme.main_background_blue[7]};
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -4,7 +4,7 @@ import ConnectionModal from '../modals/ConnectionModal';
|
||||
import styled from 'styled-components';
|
||||
import ToolbarButton, { ToolbarButtonExternalImage } from './ToolbarButton';
|
||||
import useNewQuery from '../query/useNewQuery';
|
||||
import { useConfig } from '../utility/metadataLoaders';
|
||||
import { useConfig, useMarkdownManifest } from '../utility/metadataLoaders';
|
||||
import { useSetOpenedTabs, useOpenedTabs, useCurrentTheme, useSetCurrentTheme } from '../utility/globalState';
|
||||
import { openNewTab } from '../utility/common';
|
||||
import useNewFreeTable from '../freetable/useNewFreeTable';
|
||||
@@ -25,7 +25,7 @@ export default function ToolBar({ toolbarPortalRef }) {
|
||||
const newQuery = useNewQuery();
|
||||
const newFreeTable = useNewFreeTable();
|
||||
const config = useConfig();
|
||||
const toolbar = config.toolbar || [];
|
||||
// const toolbar = config.toolbar || [];
|
||||
const setOpenedTabs = useSetOpenedTabs();
|
||||
const openedTabs = useOpenedTabs();
|
||||
const showModal = useShowModal();
|
||||
@@ -33,6 +33,7 @@ export default function ToolBar({ toolbarPortalRef }) {
|
||||
const setCurrentTheme = useSetCurrentTheme();
|
||||
const extensions = useExtensions();
|
||||
const electron = getElectron();
|
||||
const markdownManifest = useMarkdownManifest();
|
||||
|
||||
React.useEffect(() => {
|
||||
window['dbgate_createNewConnection'] = modalState.open;
|
||||
@@ -74,47 +75,47 @@ export default function ToolBar({ toolbarPortalRef }) {
|
||||
});
|
||||
};
|
||||
|
||||
function openTabFromButton(button) {
|
||||
if (openedTabs.find((x) => x.tabComponent == 'InfoPageTab' && x.props && x.props.page == button.page)) {
|
||||
function openTabFromButton(page) {
|
||||
if (
|
||||
openedTabs.find(
|
||||
(x) => x.tabComponent == 'MarkdownViewTab' && x.props && x.props.file == page.file && x.closedTime == null
|
||||
)
|
||||
) {
|
||||
setOpenedTabs((tabs) =>
|
||||
tabs.map((tab) => ({
|
||||
...tab,
|
||||
selected: tab.tabComponent == 'InfoPageTab' && tab.props && tab.props.page == button.page,
|
||||
closedTime: undefined,
|
||||
selected: tab.tabComponent == 'MarkdownViewTab' && tab.props && tab.props.file == page.file,
|
||||
}))
|
||||
);
|
||||
} else {
|
||||
openNewTab(setOpenedTabs, {
|
||||
title: button.title,
|
||||
tabComponent: 'InfoPageTab',
|
||||
icon: button.icon,
|
||||
title: page.button || page.file,
|
||||
tabComponent: 'MarkdownViewTab',
|
||||
icon: page.icon || 'img markdown',
|
||||
props: {
|
||||
page: button.page,
|
||||
file: page.file,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (config.startupPages) {
|
||||
for (const page of config.startupPages) {
|
||||
const button = toolbar.find((x) => x.name == page);
|
||||
if (button) {
|
||||
openTabFromButton(button);
|
||||
}
|
||||
}
|
||||
for (const page of (markdownManifest || []).filter((x) => x.autorun)) {
|
||||
openTabFromButton(page);
|
||||
}
|
||||
}, config && config.startupPages);
|
||||
}, [markdownManifest]);
|
||||
|
||||
return (
|
||||
<ToolbarContainer>
|
||||
<ConnectionModal modalState={modalState} />
|
||||
{!electron && <ToolbarButtonExternalImage image="/logo192.png" onClick={showAbout} />}
|
||||
{toolbar.map((button) => (
|
||||
<ToolbarButton key={button.name} onClick={() => openTabFromButton(button)} icon={button.icon}>
|
||||
{button.title}
|
||||
</ToolbarButton>
|
||||
))}
|
||||
{(markdownManifest || [])
|
||||
.filter((x) => x.button)
|
||||
.map((x) => (
|
||||
<ToolbarButton key={x.button} onClick={() => openTabFromButton(x)} icon={x.icon || 'icon markdown'}>
|
||||
{x.button}
|
||||
</ToolbarButton>
|
||||
))}
|
||||
{config.runAsPortal == false && (
|
||||
<ToolbarButton onClick={modalState.open} icon="icon new-connection">
|
||||
Add connection
|
||||
|
||||
Reference in New Issue
Block a user