mirror of
https://github.com/DeNNiiInc/dbgate.git
synced 2026-04-29 13:23:58 +00:00
import using drag & drop
This commit is contained in:
@@ -2,6 +2,21 @@ const path = require('path');
|
|||||||
const { uploadsdir } = require('../utility/directories');
|
const { uploadsdir } = require('../utility/directories');
|
||||||
const uuidv1 = require('uuid/v1');
|
const uuidv1 = require('uuid/v1');
|
||||||
|
|
||||||
|
const extensions = [
|
||||||
|
{
|
||||||
|
ext: '.xlsx',
|
||||||
|
type: 'excel',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ext: '.jsonl',
|
||||||
|
type: 'jsonl',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ext: '.csv',
|
||||||
|
type: 'csv',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
upload_meta: {
|
upload_meta: {
|
||||||
method: 'post',
|
method: 'post',
|
||||||
@@ -16,10 +31,21 @@ module.exports = {
|
|||||||
const uploadName = uuidv1();
|
const uploadName = uuidv1();
|
||||||
const filePath = path.join(uploadsdir(), uploadName);
|
const filePath = path.join(uploadsdir(), uploadName);
|
||||||
console.log(`Uploading file ${data.name}, size=${data.size}`);
|
console.log(`Uploading file ${data.name}, size=${data.size}`);
|
||||||
|
let storageType = null;
|
||||||
|
let shortName = data.name;
|
||||||
|
for (const { ext, type } of extensions) {
|
||||||
|
if (data.name.endsWith(ext)) {
|
||||||
|
storageType = type;
|
||||||
|
shortName = data.name.slice(0, -ext.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
data.mv(filePath, () => {
|
data.mv(filePath, () => {
|
||||||
res.json({
|
res.json({
|
||||||
originalName: data.name,
|
originalName: data.name,
|
||||||
|
shortName,
|
||||||
|
storageType,
|
||||||
uploadName,
|
uploadName,
|
||||||
|
filePath,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import { SocketProvider } from './utility/SocketProvider';
|
import { SocketProvider } from './utility/SocketProvider';
|
||||||
import ConnectionsPinger from './utility/ConnectionsPinger';
|
import ConnectionsPinger from './utility/ConnectionsPinger';
|
||||||
import { ModalLayerProvider } from './modals/showModal';
|
import { ModalLayerProvider } from './modals/showModal';
|
||||||
|
import UploadsProvider from './utility/UploadsProvider';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@@ -26,7 +27,9 @@ function App() {
|
|||||||
<ConnectionsPinger>
|
<ConnectionsPinger>
|
||||||
<ModalLayerProvider>
|
<ModalLayerProvider>
|
||||||
<CurrentArchiveProvider>
|
<CurrentArchiveProvider>
|
||||||
<Screen />
|
<UploadsProvider>
|
||||||
|
<Screen />
|
||||||
|
</UploadsProvider>
|
||||||
</CurrentArchiveProvider>
|
</CurrentArchiveProvider>
|
||||||
</ModalLayerProvider>
|
</ModalLayerProvider>
|
||||||
</ConnectionsPinger>
|
</ConnectionsPinger>
|
||||||
|
|||||||
53
packages/web/src/DragAndDropFileTarget.js
Normal file
53
packages/web/src/DragAndDropFileTarget.js
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
|
||||||
|
const TargetStyled = styled.div`
|
||||||
|
position: fixed;
|
||||||
|
display: flex;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: #aaaaff;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-around;
|
||||||
|
z-index: 1000;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const InfoBox = styled.div``;
|
||||||
|
|
||||||
|
const IconWrapper = styled.div`
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
font-size: 50px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const InfoWrapper = styled.div`
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
margin-top: 10px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const TitleWrapper = styled.div`
|
||||||
|
font-size: 30px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default function DragAndDropFileTarget({ isDragActive, inputProps }) {
|
||||||
|
return (
|
||||||
|
!!isDragActive && (
|
||||||
|
<TargetStyled>
|
||||||
|
<InfoBox>
|
||||||
|
<IconWrapper>
|
||||||
|
<i className="fas fa-cloud-upload-alt" />
|
||||||
|
</IconWrapper>
|
||||||
|
<TitleWrapper>Drop the files to upload to DbGate</TitleWrapper>
|
||||||
|
<InfoWrapper>Supported file types: csv, MS Excel, json-lines</InfoWrapper>
|
||||||
|
</InfoBox>
|
||||||
|
<input {...inputProps} />
|
||||||
|
</TargetStyled>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import theme from './theme';
|
import theme from './theme';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { useDropzone } from 'react-dropzone';
|
|
||||||
import TabsPanel from './TabsPanel';
|
import TabsPanel from './TabsPanel';
|
||||||
import TabContent from './TabContent';
|
import TabContent from './TabContent';
|
||||||
import WidgetIconPanel from './widgets/WidgetIconPanel';
|
import WidgetIconPanel from './widgets/WidgetIconPanel';
|
||||||
@@ -13,7 +12,8 @@ import ToolBar from './widgets/Toolbar';
|
|||||||
import StatusBar from './widgets/StatusBar';
|
import StatusBar from './widgets/StatusBar';
|
||||||
import { useSplitterDrag, HorizontalSplitHandle } from './widgets/Splitter';
|
import { useSplitterDrag, HorizontalSplitHandle } from './widgets/Splitter';
|
||||||
import { ModalLayer } from './modals/showModal';
|
import { ModalLayer } from './modals/showModal';
|
||||||
import resolveApi from './utility/resolveApi';
|
import DragAndDropFileTarget from './DragAndDropFileTarget';
|
||||||
|
import { useUploadsZone } from './utility/UploadsProvider';
|
||||||
|
|
||||||
const BodyDiv = styled.div`
|
const BodyDiv = styled.div`
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -98,15 +98,6 @@ const ScreenHorizontalSplitHandle = styled(HorizontalSplitHandle)`
|
|||||||
bottom: ${theme.statusBar.height}px;
|
bottom: ${theme.statusBar.height}px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const DragAndDropTarget = styled.div`
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: red;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export default function Screen() {
|
export default function Screen() {
|
||||||
const currentWidget = useCurrentWidget();
|
const currentWidget = useCurrentWidget();
|
||||||
const leftPanelWidth = useLeftPanelWidth();
|
const leftPanelWidth = useLeftPanelWidth();
|
||||||
@@ -117,42 +108,7 @@ export default function Screen() {
|
|||||||
const toolbarPortalRef = React.useRef();
|
const toolbarPortalRef = React.useRef();
|
||||||
const onSplitDown = useSplitterDrag('clientX', (diff) => setLeftPanelWidth((v) => v + diff));
|
const onSplitDown = useSplitterDrag('clientX', (diff) => setLeftPanelWidth((v) => v + diff));
|
||||||
|
|
||||||
const onDrop = React.useCallback((files) => {
|
const { getRootProps, getInputProps, isDragActive } = useUploadsZone();
|
||||||
// Do something with the files
|
|
||||||
console.log('FILES', files);
|
|
||||||
files.forEach(async (file) => {
|
|
||||||
if (parseInt(file.size, 10) >= 4 * 1024 * 1024) {
|
|
||||||
// to big file
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('data', file);
|
|
||||||
|
|
||||||
const fetchOptions = {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData,
|
|
||||||
};
|
|
||||||
|
|
||||||
const apiBase = resolveApi();
|
|
||||||
const resp = await fetch(`${apiBase}/uploads/upload`, fetchOptions);
|
|
||||||
const event = await resp.json();
|
|
||||||
|
|
||||||
return event;
|
|
||||||
|
|
||||||
// const reader = new FileReader();
|
|
||||||
|
|
||||||
// reader.onabort = () => console.log('file reading was aborted');
|
|
||||||
// reader.onerror = () => console.log('file reading has failed');
|
|
||||||
// reader.onload = () => {
|
|
||||||
// // Do whatever you want with the file contents
|
|
||||||
// const binaryStr = reader.result;
|
|
||||||
// console.log(binaryStr);
|
|
||||||
// };
|
|
||||||
// reader.readAsArrayBuffer(file);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop });
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div {...getRootProps()}>
|
<div {...getRootProps()}>
|
||||||
@@ -184,12 +140,7 @@ export default function Screen() {
|
|||||||
</StausBarContainer>
|
</StausBarContainer>
|
||||||
<ModalLayer />
|
<ModalLayer />
|
||||||
|
|
||||||
{!!isDragActive && (
|
<DragAndDropFileTarget inputProps={getInputProps()} isDragActive={isDragActive} />
|
||||||
<DragAndDropTarget>
|
|
||||||
Drop the files here ...
|
|
||||||
<input {...getInputProps()} />{' '}
|
|
||||||
</DragAndDropTarget>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import getAsArray from '../utility/getAsArray';
|
|||||||
import axios from '../utility/axios';
|
import axios from '../utility/axios';
|
||||||
import LoadingInfo from '../widgets/LoadingInfo';
|
import LoadingInfo from '../widgets/LoadingInfo';
|
||||||
import SqlEditor from '../sqleditor/SqlEditor';
|
import SqlEditor from '../sqleditor/SqlEditor';
|
||||||
|
import { useUploadsProvider } from '../utility/UploadsProvider';
|
||||||
|
|
||||||
const Container = styled.div`
|
const Container = styled.div`
|
||||||
max-height: 50vh;
|
max-height: 50vh;
|
||||||
@@ -62,6 +63,11 @@ const SqlWrapper = styled.div`
|
|||||||
width: 20vw;
|
width: 20vw;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const DragWrapper = styled.div`
|
||||||
|
padding: 10px;
|
||||||
|
background: #ddd;
|
||||||
|
`;
|
||||||
|
|
||||||
function getFileFilters(storageType) {
|
function getFileFilters(storageType) {
|
||||||
const res = [];
|
const res = [];
|
||||||
if (storageType == 'csv') res.push({ name: 'CSV files', extensions: ['csv'] });
|
if (storageType == 'csv') res.push({ name: 'CSV files', extensions: ['csv'] });
|
||||||
@@ -141,7 +147,7 @@ function FilesInput() {
|
|||||||
if (electron) {
|
if (electron) {
|
||||||
return <ElectronFilesInput />;
|
return <ElectronFilesInput />;
|
||||||
}
|
}
|
||||||
return <ErrorInfo message="Import files is currently implemented only for electron client" />;
|
return <DragWrapper>Drag & drop imported files here</DragWrapper>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SourceTargetConfig({
|
function SourceTargetConfig({
|
||||||
@@ -287,12 +293,43 @@ function SourceName({ name }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ImportExportConfigurator() {
|
export default function ImportExportConfigurator({ uploadedFile = undefined }) {
|
||||||
const { values, setFieldValue } = useFormikContext();
|
const { values, setFieldValue } = useFormikContext();
|
||||||
const targetDbinfo = useDatabaseInfo({ conid: values.targetConnectionId, database: values.targetDatabaseName });
|
const targetDbinfo = useDatabaseInfo({ conid: values.targetConnectionId, database: values.targetDatabaseName });
|
||||||
const sourceConnectionInfo = useConnectionInfo({ conid: values.sourceConnectionId });
|
const sourceConnectionInfo = useConnectionInfo({ conid: values.sourceConnectionId });
|
||||||
const { engine: sourceEngine } = sourceConnectionInfo || {};
|
const { engine: sourceEngine } = sourceConnectionInfo || {};
|
||||||
const { sourceList } = values;
|
const { sourceList } = values;
|
||||||
|
const { uploadListener, setUploadListener } = useUploadsProvider();
|
||||||
|
|
||||||
|
const handleUpload = React.useCallback(
|
||||||
|
(file) => {
|
||||||
|
addFilesToSourceList(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
full: file.filePath,
|
||||||
|
name: file.shortName,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
values,
|
||||||
|
setFieldValue
|
||||||
|
);
|
||||||
|
// setFieldValue('sourceList', [...(sourceList || []), file.originalName]);
|
||||||
|
},
|
||||||
|
[setFieldValue, sourceList]
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setUploadListener(() => handleUpload);
|
||||||
|
return () => {
|
||||||
|
setUploadListener(null);
|
||||||
|
};
|
||||||
|
}, [handleUpload]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (uploadedFile) {
|
||||||
|
handleUpload(uploadedFile);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ function GenerateSctriptButton({ modalState }) {
|
|||||||
return <FormStyledButton type="button" value="Generate script" onClick={handleGenerateScript} />;
|
return <FormStyledButton type="button" value="Generate script" onClick={handleGenerateScript} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ImportExportModal({ modalState, initialValues }) {
|
export default function ImportExportModal({ modalState, initialValues, uploadedFile = undefined }) {
|
||||||
const [executeNumber, setExecuteNumber] = React.useState(0);
|
const [executeNumber, setExecuteNumber] = React.useState(0);
|
||||||
const [runnerId, setRunnerId] = React.useState(null);
|
const [runnerId, setRunnerId] = React.useState(null);
|
||||||
const archive = useCurrentArchive();
|
const archive = useCurrentArchive();
|
||||||
@@ -69,7 +69,7 @@ export default function ImportExportModal({ modalState, initialValues }) {
|
|||||||
<Form>
|
<Form>
|
||||||
<ModalHeader modalState={modalState}>Import/Export</ModalHeader>
|
<ModalHeader modalState={modalState}>Import/Export</ModalHeader>
|
||||||
<ModalContent>
|
<ModalContent>
|
||||||
<ImportExportConfigurator />
|
<ImportExportConfigurator uploadedFile={uploadedFile} />
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<FormStyledButton type="submit" value="Run" />
|
<FormStyledButton type="submit" value="Run" />
|
||||||
|
|||||||
79
packages/web/src/utility/UploadsProvider.js
Normal file
79
packages/web/src/utility/UploadsProvider.js
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useDropzone } from 'react-dropzone';
|
||||||
|
import ImportExportModal from '../modals/ImportExportModal';
|
||||||
|
import useShowModal from '../modals/showModal';
|
||||||
|
import resolveApi from './resolveApi';
|
||||||
|
|
||||||
|
const UploadsContext = React.createContext(null);
|
||||||
|
|
||||||
|
export default function UploadsProvider({ children }) {
|
||||||
|
const [uploadListener, setUploadListener] = React.useState(null);
|
||||||
|
return <UploadsContext.Provider value={{ uploadListener, setUploadListener }}>{children}</UploadsContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUploadsProvider() {
|
||||||
|
return React.useContext(UploadsContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUploadsZone() {
|
||||||
|
const { uploadListener } = useUploadsProvider();
|
||||||
|
const showModal = useShowModal();
|
||||||
|
|
||||||
|
const onDrop = React.useCallback(
|
||||||
|
(files) => {
|
||||||
|
files.forEach(async (file) => {
|
||||||
|
if (parseInt(file.size, 10) >= 4 * 1024 * 1024) {
|
||||||
|
// to big file
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('data', file);
|
||||||
|
|
||||||
|
const fetchOptions = {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
};
|
||||||
|
|
||||||
|
const apiBase = resolveApi();
|
||||||
|
const resp = await fetch(`${apiBase}/uploads/upload`, fetchOptions);
|
||||||
|
const fileData = await resp.json();
|
||||||
|
|
||||||
|
if (uploadListener) {
|
||||||
|
uploadListener(fileData);
|
||||||
|
} else {
|
||||||
|
if (['csv', 'excel', 'jsonl'].includes(fileData.storageType)) {
|
||||||
|
showModal((modalState) => (
|
||||||
|
<ImportExportModal
|
||||||
|
uploadedFile={fileData}
|
||||||
|
modalState={modalState}
|
||||||
|
initialValues={{
|
||||||
|
sourceStorageType: fileData.storageType,
|
||||||
|
// sourceConnectionId: data.conid,
|
||||||
|
// sourceDatabaseName: data.database,
|
||||||
|
// sourceSchemaName: data.schemaName,
|
||||||
|
// sourceList: [data.pureName],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// const reader = new FileReader();
|
||||||
|
|
||||||
|
// reader.onabort = () => console.log('file reading was aborted');
|
||||||
|
// reader.onerror = () => console.log('file reading has failed');
|
||||||
|
// reader.onload = () => {
|
||||||
|
// // Do whatever you want with the file contents
|
||||||
|
// const binaryStr = reader.result;
|
||||||
|
// console.log(binaryStr);
|
||||||
|
// };
|
||||||
|
// reader.readAsArrayBuffer(file);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[uploadListener]
|
||||||
|
);
|
||||||
|
const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop });
|
||||||
|
|
||||||
|
return { getRootProps, getInputProps, isDragActive };
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ import { useConfig } from '../utility/metadataLoaders';
|
|||||||
import { useSetOpenedTabs, useOpenedTabs } from '../utility/globalState';
|
import { useSetOpenedTabs, useOpenedTabs } from '../utility/globalState';
|
||||||
import { openNewTab } from '../utility/common';
|
import { openNewTab } from '../utility/common';
|
||||||
import useNewFreeTable from '../freetable/useNewFreeTable';
|
import useNewFreeTable from '../freetable/useNewFreeTable';
|
||||||
|
import ImportExportModal from '../modals/ImportExportModal';
|
||||||
|
import useShowModal from '../modals/showModal';
|
||||||
|
|
||||||
const ToolbarContainer = styled.div`
|
const ToolbarContainer = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -22,6 +24,7 @@ export default function ToolBar({ toolbarPortalRef }) {
|
|||||||
const toolbar = config.toolbar || [];
|
const toolbar = config.toolbar || [];
|
||||||
const setOpenedTabs = useSetOpenedTabs();
|
const setOpenedTabs = useSetOpenedTabs();
|
||||||
const openedTabs = useOpenedTabs();
|
const openedTabs = useOpenedTabs();
|
||||||
|
const showModal = useShowModal();
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
window['dbgate_createNewConnection'] = modalState.open;
|
window['dbgate_createNewConnection'] = modalState.open;
|
||||||
@@ -29,6 +32,21 @@ export default function ToolBar({ toolbarPortalRef }) {
|
|||||||
window['dbgate_closeAll'] = () => setOpenedTabs([]);
|
window['dbgate_closeAll'] = () => setOpenedTabs([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const showImport = () => {
|
||||||
|
showModal((modalState) => (
|
||||||
|
<ImportExportModal
|
||||||
|
modalState={modalState}
|
||||||
|
initialValues={{
|
||||||
|
sourceStorageType: 'csv',
|
||||||
|
// sourceConnectionId: data.conid,
|
||||||
|
// sourceDatabaseName: data.database,
|
||||||
|
// sourceSchemaName: data.schemaName,
|
||||||
|
// sourceList: [data.pureName],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
function openTabFromButton(button) {
|
function openTabFromButton(button) {
|
||||||
if (openedTabs.find((x) => x.tabComponent == 'InfoPageTab' && x.props && x.props.page == button.page)) {
|
if (openedTabs.find((x) => x.tabComponent == 'InfoPageTab' && x.props && x.props.page == button.page)) {
|
||||||
setOpenedTabs((tabs) =>
|
setOpenedTabs((tabs) =>
|
||||||
@@ -79,6 +97,9 @@ export default function ToolBar({ toolbarPortalRef }) {
|
|||||||
<ToolbarButton onClick={newFreeTable} icon="fas fa-table">
|
<ToolbarButton onClick={newFreeTable} icon="fas fa-table">
|
||||||
Free table editor
|
Free table editor
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
|
<ToolbarButton onClick={showImport} icon="fas fa-file-upload">
|
||||||
|
Import data
|
||||||
|
</ToolbarButton>
|
||||||
<ToolbarContainer ref={toolbarPortalRef}></ToolbarContainer>
|
<ToolbarContainer ref={toolbarPortalRef}></ToolbarContainer>
|
||||||
</ToolbarContainer>
|
</ToolbarContainer>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user