mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
committed by
GitHub
parent
2c5eceb0c1
commit
d7c9c4bf76
@@ -3,13 +3,10 @@ import React, { useEffect } from 'react';
|
||||
import InsufficientPermission from './components/permission/InsufficientPermission';
|
||||
import PermissionAwareRoute from './components/permission/PermissionAwareRoute';
|
||||
import GeneralSettings from './views/generalSettings/GeneralSettings';
|
||||
import ToastsContainer from './components/toasts/ToastContainer';
|
||||
import JobMutation from './views/jobs/mutation/JobMutation';
|
||||
import UserMutator from './views/user/mutation/UserMutator';
|
||||
import ToastContext from './components/toasts/ToastContext';
|
||||
import JobInsight from './views/jobs/insights/JobInsight.jsx';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import useToast from './components/toasts/useToast';
|
||||
import { Switch, Redirect } from 'react-router-dom';
|
||||
import Logout from './components/logout/Logout';
|
||||
import Logo from './components/logo/Logo';
|
||||
@@ -23,20 +20,21 @@ import './App.less';
|
||||
|
||||
export default function FredyApp() {
|
||||
const dispatch = useDispatch();
|
||||
const [showToast, onToastFinished, toasts] = useToast();
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const currentUser = useSelector((state) => state.user.currentUser);
|
||||
|
||||
useEffect(() => {
|
||||
async function init() {
|
||||
await dispatch.provider.getProvider();
|
||||
await dispatch.jobs.getJobs();
|
||||
await dispatch.jobs.getProcessingTimes();
|
||||
await dispatch.notificationAdapter.getAdapter();
|
||||
await dispatch.user.getCurrentUser();
|
||||
|
||||
if (!needsLogin()) {
|
||||
await dispatch.provider.getProvider();
|
||||
await dispatch.jobs.getJobs();
|
||||
await dispatch.jobs.getProcessingTimes();
|
||||
await dispatch.notificationAdapter.getAdapter();
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
init();
|
||||
}, [currentUser?.userId]);
|
||||
|
||||
@@ -56,44 +54,41 @@ export default function FredyApp() {
|
||||
return loading ? null : needsLogin() ? (
|
||||
login()
|
||||
) : (
|
||||
<ToastContext.Provider value={{ showToast }}>
|
||||
<div className="app">
|
||||
<div className="app__container">
|
||||
<Logout />
|
||||
<Logo width={190} white />
|
||||
<Menu isAdmin={isAdmin()} />
|
||||
<ToastsContainer toasts={toasts} onToastFinished={onToastFinished} />
|
||||
<Switch>
|
||||
<Route name="Insufficient Permission" path={'/403'} component={InsufficientPermission} />
|
||||
<Route name="Create new Job" path={'/jobs/new'} component={JobMutation} />
|
||||
<Route name="Edit a Job" path={'/jobs/edit/:jobId'} component={JobMutation} />
|
||||
<Route name="Insights into a Job" path={'/jobs/insights/:jobId'} component={JobInsight} />
|
||||
<Route name="Job overview" path={'/jobs'} component={Jobs} />
|
||||
<PermissionAwareRoute
|
||||
name="Create new User"
|
||||
path="/users/new"
|
||||
component={<UserMutator />}
|
||||
currentUser={currentUser}
|
||||
/>
|
||||
<PermissionAwareRoute
|
||||
name="Edit a user"
|
||||
path="/users/edit/:userId"
|
||||
component={<UserMutator />}
|
||||
currentUser={currentUser}
|
||||
/>
|
||||
<PermissionAwareRoute name="Users" path="/users" component={<Users />} currentUser={currentUser} />
|
||||
<PermissionAwareRoute
|
||||
name="General Settings"
|
||||
path="/generalSettings"
|
||||
component={<GeneralSettings />}
|
||||
currentUser={currentUser}
|
||||
/>
|
||||
<div className="app">
|
||||
<div className="app__container">
|
||||
<Logout />
|
||||
<Logo width={190} white />
|
||||
<Menu isAdmin={isAdmin()} />
|
||||
<Switch>
|
||||
<Route name="Insufficient Permission" path={'/403'} component={InsufficientPermission} />
|
||||
<Route name="Create new Job" path={'/jobs/new'} component={JobMutation} />
|
||||
<Route name="Edit a Job" path={'/jobs/edit/:jobId'} component={JobMutation} />
|
||||
<Route name="Insights into a Job" path={'/jobs/insights/:jobId'} component={JobInsight} />
|
||||
<Route name="Job overview" path={'/jobs'} component={Jobs} />
|
||||
<PermissionAwareRoute
|
||||
name="Create new User"
|
||||
path="/users/new"
|
||||
component={<UserMutator />}
|
||||
currentUser={currentUser}
|
||||
/>
|
||||
<PermissionAwareRoute
|
||||
name="Edit a user"
|
||||
path="/users/edit/:userId"
|
||||
component={<UserMutator />}
|
||||
currentUser={currentUser}
|
||||
/>
|
||||
<PermissionAwareRoute name="Users" path="/users" component={<Users />} currentUser={currentUser} />
|
||||
<PermissionAwareRoute
|
||||
name="General Settings"
|
||||
path="/generalSettings"
|
||||
component={<GeneralSettings />}
|
||||
currentUser={currentUser}
|
||||
/>
|
||||
|
||||
<Redirect from="/" to={'/jobs'} />
|
||||
</Switch>
|
||||
</div>
|
||||
<Redirect from="/" to={'/jobs'} />
|
||||
</Switch>
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,11 +4,9 @@
|
||||
width:100%;
|
||||
|
||||
&__container {
|
||||
width: 100%;
|
||||
|
||||
padding: 1rem 1rem;
|
||||
background-color: #595959f5;
|
||||
color: #f1f1f1;
|
||||
color: var(--semi-color-text-0);
|
||||
background-color: #232429;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,4 +16,28 @@
|
||||
|
||||
.ui.black.label, .ui.black.labels .label {
|
||||
background-color: #31303078!important;
|
||||
}
|
||||
|
||||
a:link {
|
||||
color: #54a9ff;
|
||||
background-color: transparent;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:visited {
|
||||
color: #54a9ff;
|
||||
background-color: transparent;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #54a9ff;
|
||||
background-color: transparent;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
a:active {
|
||||
color: #54a9ff;
|
||||
background-color: transparent;
|
||||
text-decoration: underline;
|
||||
}
|
||||
@@ -5,9 +5,11 @@ import { HashRouter } from 'react-router-dom';
|
||||
import { createHashHistory } from 'history';
|
||||
import { Provider } from 'react-redux';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import en_US from '@douyinfe/semi-ui/lib/es/locale/source/en_US';
|
||||
import { LocaleProvider } from '@douyinfe/semi-ui';
|
||||
|
||||
const container = document.getElementById('fredy');
|
||||
const root = createRoot(container);
|
||||
|
||||
const history = createHashHistory();
|
||||
|
||||
import App from './App';
|
||||
@@ -17,7 +19,9 @@ import './Index.less';
|
||||
root.render(
|
||||
<Provider store={reduxStore}>
|
||||
<HashRouter history={history}>
|
||||
<App />
|
||||
<LocaleProvider locale={en_US}>
|
||||
<App />
|
||||
</LocaleProvider>
|
||||
</HashRouter>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
@@ -2,5 +2,14 @@ body, html {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background-color: #595959f5;
|
||||
background-color: #232429;
|
||||
}
|
||||
|
||||
.semi-table-row-head{
|
||||
background-color: #2b2b2b !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.semi-table-row-cell {
|
||||
background-color: #333333 !important;
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
import React from 'react';
|
||||
import { Header } from 'semantic-ui-react';
|
||||
import { Typography } from '@douyinfe/semi-ui';
|
||||
|
||||
import './Headline.less';
|
||||
|
||||
export default function Headline({ text, size = 'medium', className = '' } = {}) {
|
||||
export default function Headline({ text, size = 3 } = {}) {
|
||||
const { Title } = Typography;
|
||||
return (
|
||||
<Header className={`headline ${className}`} size={size}>
|
||||
<Title heading={size} style={{ marginBottom: '1rem' }}>
|
||||
{text}
|
||||
</Header>
|
||||
</Title>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
.headline{
|
||||
color: #f1f1f1 !important;
|
||||
}
|
||||
@@ -1,20 +1,20 @@
|
||||
import React from 'react';
|
||||
import { Button } from 'semantic-ui-react';
|
||||
import { Button } from '@douyinfe/semi-ui';
|
||||
import { xhrPost } from '../../services/xhr';
|
||||
|
||||
import { IconUser } from '@douyinfe/semi-icons';
|
||||
const Logout = function Logout() {
|
||||
return (
|
||||
<Button
|
||||
content="Logout"
|
||||
labelPosition="left"
|
||||
icon="user"
|
||||
size="mini"
|
||||
icon={<IconUser />}
|
||||
type="danger"
|
||||
theme="solid"
|
||||
onClick={async () => {
|
||||
await xhrPost('/api/login/logout');
|
||||
location.reload();
|
||||
}}
|
||||
negative
|
||||
/>
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,49 +1,54 @@
|
||||
import React from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { Icon, Menu } from 'semantic-ui-react';
|
||||
import { Tabs, TabPane } from '@douyinfe/semi-ui';
|
||||
|
||||
import './Menu.less';
|
||||
import { useLocation } from 'react-router';
|
||||
import { IconUser, IconTerminal, IconSetting } from '@douyinfe/semi-icons';
|
||||
|
||||
function parsePathName(name) {
|
||||
const split = name.split('/').filter((s) => s.length !== 0);
|
||||
return '/' + split[0];
|
||||
}
|
||||
|
||||
const TopMenu = function TopMenu({ isAdmin }) {
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
|
||||
const isActiveRoute = (name) => location.pathname.indexOf(name) !== -1;
|
||||
|
||||
return (
|
||||
<Menu pointing secondary className="topMenu">
|
||||
<Menu.Item
|
||||
name="jobs"
|
||||
active={isActiveRoute('jobs')}
|
||||
className={isActiveRoute('jobs') ? 'topMenu__active' : 'topMenu__item'}
|
||||
onClick={() => history.push('/jobs')}
|
||||
>
|
||||
<Icon name="search" /> Job Configuration
|
||||
</Menu.Item>
|
||||
<Tabs type="line" activeKey={parsePathName(location.pathname)} onTabClick={(key) => history.push(key)}>
|
||||
<TabPane
|
||||
itemKey="/jobs"
|
||||
tab={
|
||||
<span>
|
||||
<IconTerminal />
|
||||
Jobs
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
{isAdmin && (
|
||||
<Menu.Item
|
||||
name="user"
|
||||
active={isActiveRoute('users')}
|
||||
className={isActiveRoute('users') ? 'topMenu__active' : 'topMenu__item'}
|
||||
onClick={() => history.push('/users')}
|
||||
>
|
||||
<Icon name="user" /> User configuration
|
||||
</Menu.Item>
|
||||
<TabPane
|
||||
itemKey="/users"
|
||||
tab={
|
||||
<span>
|
||||
<IconUser />
|
||||
User
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<Menu.Item
|
||||
name="general"
|
||||
active={isActiveRoute('general')}
|
||||
className={isActiveRoute('general') ? 'topMenu__active' : 'topMenu__item'}
|
||||
onClick={() => history.push('/generalSettings')}
|
||||
>
|
||||
<Icon name="cog" /> General Settings
|
||||
</Menu.Item>
|
||||
<TabPane
|
||||
itemKey="/generalSettings"
|
||||
tab={
|
||||
<span>
|
||||
<IconSetting />
|
||||
General
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Menu>
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
.topMenu {
|
||||
border-bottom: 1px solid #b7b7b7f2 !important;
|
||||
|
||||
&__active {
|
||||
border-bottom: 1px solid #06dcfff2 !important;
|
||||
font-weight: 550 !important;
|
||||
color: #3ed7ff !important;
|
||||
margin: 0 0 -1px !important;
|
||||
}
|
||||
|
||||
&__item {
|
||||
color: #fffffff2 !important;
|
||||
border-color: transparent !important;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from 'react';
|
||||
import { Header } from 'semantic-ui-react';
|
||||
import insufficientPermission from '../../assets/insufficient_permission.png';
|
||||
|
||||
export default function InsufficientPermission() {
|
||||
@@ -7,9 +6,7 @@ export default function InsufficientPermission() {
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', flexDirection: 'column' }}>
|
||||
<img src={insufficientPermission} height={250} />
|
||||
<br />
|
||||
<Header as="h4" inverted>
|
||||
Insufficient permission :(
|
||||
</Header>
|
||||
<h4>Insufficient permission :(</h4>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,18 @@
|
||||
import React from 'react';
|
||||
import { Header, Icon, Popup, Segment } from 'semantic-ui-react';
|
||||
import { Card } from '@douyinfe/semi-ui';
|
||||
|
||||
import './SegmentParts.less';
|
||||
|
||||
export const SegmentPart = ({ name, icon = null, children, helpText }) => (
|
||||
<Segment inverted>
|
||||
<Header as="h5" inverted sub>
|
||||
{icon && <Icon name={icon} inverted size="mini" />}
|
||||
<Header.Content>{name}</Header.Content>
|
||||
</Header>
|
||||
export const SegmentPart = ({ name, Icon = null, children, helpText }) => {
|
||||
const { Meta } = Card;
|
||||
|
||||
<Popup
|
||||
content={helpText}
|
||||
trigger={
|
||||
<span className="generalSettings__help">
|
||||
{' '}
|
||||
<Icon name="help circle" inverted />
|
||||
What is this?
|
||||
</span>
|
||||
return (
|
||||
<Card
|
||||
title={
|
||||
<Meta title={name} description={helpText} avatar={Icon == null ? null : <Icon size="extra-extra-small" />} />
|
||||
}
|
||||
/>
|
||||
<Segment inverted className="segmentParts">
|
||||
>
|
||||
{children}
|
||||
</Segment>
|
||||
</Segment>
|
||||
);
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,66 +1,79 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import { Table, Button } from 'semantic-ui-react';
|
||||
import Switch from 'react-switch';
|
||||
|
||||
const emptyTable = () => {
|
||||
return (
|
||||
<Table.Row>
|
||||
<Table.Cell collapsing colSpan={6} style={{ textAlign: 'center' }}>
|
||||
No Data
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
};
|
||||
|
||||
const content = (jobs, onJobRemoval, onJobStatusChanged, onJobEdit, onJobInsight) => {
|
||||
return (
|
||||
<Fragment>
|
||||
{Object.keys(jobs).map((jobKey) => {
|
||||
const job = jobs[jobKey];
|
||||
|
||||
return (
|
||||
<Table.Row key={jobKey}>
|
||||
<Table.Cell collapsing>
|
||||
<Switch onChange={(checked) => onJobStatusChanged(job.id, checked)} checked={job.enabled} />
|
||||
</Table.Cell>
|
||||
<Table.Cell>{job.name}</Table.Cell>
|
||||
<Table.Cell>{job.numberOfFoundListings || 0}</Table.Cell>
|
||||
<Table.Cell>{job.provider.length || 0}</Table.Cell>
|
||||
<Table.Cell>{job.notificationAdapter.length || 0}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<div style={{ float: 'right' }}>
|
||||
<Button circular color="teal" icon="chart line" onClick={() => onJobInsight(job.id)} />
|
||||
<Button circular color="blue" icon="edit" onClick={() => onJobEdit(job.id)} />
|
||||
<Button circular color="red" icon="trash" onClick={() => onJobRemoval(job.id)} />
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
})}
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
import { Button, Empty, Table, Switch } from '@douyinfe/semi-ui';
|
||||
import { IconDelete, IconEdit, IconHistogram } from '@douyinfe/semi-icons';
|
||||
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
||||
const empty = (
|
||||
<Empty
|
||||
image={<IllustrationNoResult />}
|
||||
darkModeImage={<IllustrationNoResultDark />}
|
||||
description={'No jobs available'}
|
||||
/>
|
||||
);
|
||||
|
||||
export default function JobTable({ jobs = {}, onJobRemoval, onJobStatusChanged, onJobEdit, onJobInsight } = {}) {
|
||||
return (
|
||||
<Table singleLine inverted>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell />
|
||||
<Table.HeaderCell>Job Name</Table.HeaderCell>
|
||||
<Table.HeaderCell>Number of findings</Table.HeaderCell>
|
||||
<Table.HeaderCell>Active provider</Table.HeaderCell>
|
||||
<Table.HeaderCell>Active notification adapter</Table.HeaderCell>
|
||||
<Table.HeaderCell></Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
|
||||
<Table.Body>
|
||||
{Object.keys(jobs).length === 0
|
||||
? emptyTable()
|
||||
: content(jobs, onJobRemoval, onJobStatusChanged, onJobEdit, onJobInsight)}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
<Table
|
||||
pagination={false}
|
||||
empty={empty}
|
||||
columns={[
|
||||
{
|
||||
title: '',
|
||||
dataIndex: '',
|
||||
render: (job) => {
|
||||
return <Switch onChange={(checked) => onJobStatusChanged(job.id, checked)} checked={job.enabled} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Job Name',
|
||||
dataIndex: 'name',
|
||||
},
|
||||
{
|
||||
title: 'Number of findings',
|
||||
dataIndex: 'numberOfFoundListings',
|
||||
render: (value) => {
|
||||
return value || 0;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Active provider',
|
||||
dataIndex: 'provider',
|
||||
render: (value) => {
|
||||
return value.length || 0;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Active notification adapter',
|
||||
dataIndex: 'notificationAdapter',
|
||||
render: (value) => {
|
||||
return value.length || 0;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
dataIndex: 'tools',
|
||||
render: (_, job) => {
|
||||
return (
|
||||
<div style={{ float: 'right' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<IconHistogram />}
|
||||
onClick={() => onJobInsight(job.id)}
|
||||
style={{ marginRight: '1rem' }}
|
||||
/>
|
||||
<Button
|
||||
type="secondary"
|
||||
icon={<IconEdit />}
|
||||
onClick={() => onJobEdit(job.id)}
|
||||
style={{ marginRight: '1rem' }}
|
||||
/>
|
||||
<Button type="danger" icon={<IconDelete />} onClick={() => onJobRemoval(job.id)} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
dataSource={jobs}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,50 +1,38 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import { Table, Button } from 'semantic-ui-react';
|
||||
|
||||
const emptyTable = () => {
|
||||
return (
|
||||
<Table.Row>
|
||||
<Table.Cell collapsing colSpan="3" style={{ textAlign: 'center' }}>
|
||||
No Data
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
};
|
||||
|
||||
const content = (adapterData, onRemove, onEdit) => {
|
||||
return (
|
||||
<Fragment>
|
||||
{adapterData.map((data) => {
|
||||
return (
|
||||
<Table.Row key={data.id}>
|
||||
<Table.Cell>{data.name}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<div style={{ float: 'right' }}>
|
||||
<Button circular color="blue" icon="edit" onClick={() => onEdit(data.id)} />
|
||||
<Button circular color="red" icon="trash" onClick={() => onRemove(data.id)} />
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
})}
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
import { Empty, Table, Button } from '@douyinfe/semi-ui';
|
||||
import { IconDelete, IconEdit } from '@douyinfe/semi-icons';
|
||||
|
||||
export default function NotificationAdapterTable({ notificationAdapter = [], onRemove, onEdit } = {}) {
|
||||
return (
|
||||
<Table singleLine inverted>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell>Notification Adapter Name</Table.HeaderCell>
|
||||
<Table.HeaderCell></Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table
|
||||
pagination={false}
|
||||
empty={<Empty description="No Data" />}
|
||||
columns={[
|
||||
{
|
||||
title: 'Notification Adapter Name',
|
||||
dataIndex: 'name',
|
||||
},
|
||||
|
||||
<Table.Body>
|
||||
{notificationAdapter.length === 0 ? emptyTable() : content(notificationAdapter, onRemove, onEdit)}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
{
|
||||
title: '',
|
||||
dataIndex: 'tools',
|
||||
render: (_, record) => {
|
||||
return (
|
||||
<div style={{ float: 'right' }}>
|
||||
<Button
|
||||
type="secondary"
|
||||
icon={<IconEdit />}
|
||||
onClick={() => onEdit(record.id)}
|
||||
style={{ marginRight: '1rem' }}
|
||||
/>
|
||||
<Button type="danger" icon={<IconDelete />} onClick={() => onRemove(record.id)} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
dataSource={notificationAdapter}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,53 +1,42 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import { Table, Button } from 'semantic-ui-react';
|
||||
|
||||
const emptyTable = () => {
|
||||
return (
|
||||
<Table.Row>
|
||||
<Table.Cell collapsing colSpan="3" style={{ textAlign: 'center' }}>
|
||||
No Data
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
};
|
||||
|
||||
const content = (providerData, onRemove) => {
|
||||
return (
|
||||
<Fragment>
|
||||
{providerData.map((data) => {
|
||||
return (
|
||||
<Table.Row key={data.id}>
|
||||
<Table.Cell>{data.name}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<a href={data.url} target="_blank" rel="noopener noreferrer">
|
||||
Visit site
|
||||
</a>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<div style={{ float: 'right' }}>
|
||||
<Button circular color="red" icon="trash" onClick={() => onRemove(data.id)} />
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
})}
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
import { Empty, Table, Button } from '@douyinfe/semi-ui';
|
||||
import { IconDelete } from '@douyinfe/semi-icons';
|
||||
|
||||
export default function ProviderTable({ providerData = [], onRemove } = {}) {
|
||||
return (
|
||||
<Table singleLine inverted>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell>Provider Name</Table.HeaderCell>
|
||||
<Table.HeaderCell>Url</Table.HeaderCell>
|
||||
<Table.HeaderCell></Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
|
||||
<Table.Body>{providerData.length === 0 ? emptyTable() : content(providerData, onRemove)}</Table.Body>
|
||||
</Table>
|
||||
<Table
|
||||
pagination={false}
|
||||
empty={<Empty description="No Provider available" />}
|
||||
columns={[
|
||||
{
|
||||
title: 'Provider Name',
|
||||
dataIndex: 'name',
|
||||
},
|
||||
{
|
||||
title: 'Provider Url',
|
||||
dataIndex: 'url',
|
||||
render: (_, data) => {
|
||||
return (
|
||||
<a href={data.url} target="_blank" rel="noopener noreferrer">
|
||||
Visit site
|
||||
</a>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
dataIndex: 'tools',
|
||||
render: (_, record) => {
|
||||
return (
|
||||
<div style={{ float: 'right' }}>
|
||||
<Button type="danger" icon={<IconDelete />} onClick={() => onRemove(record.id)} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
dataSource={providerData}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,49 +1,58 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Table, Button } from 'semantic-ui-react';
|
||||
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
||||
import { format } from '../../services/time/timeService';
|
||||
import { Table, Button, Empty } from '@douyinfe/semi-ui';
|
||||
import { IconDelete, IconEdit } from '@douyinfe/semi-icons';
|
||||
|
||||
const emptyTable = () => {
|
||||
return (
|
||||
<Table.Row>
|
||||
<Table.Cell collapsing colSpan={4} style={{ textAlign: 'center' }}>
|
||||
No Data
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
};
|
||||
|
||||
const content = (user, onUserRemoval, onUserEdit) => {
|
||||
return user.map((user) => {
|
||||
return (
|
||||
<Table.Row key={user.id}>
|
||||
<Table.Cell>{user.username}</Table.Cell>
|
||||
<Table.Cell>{user.lastLogin == null ? '---' : format(user.lastLogin)}</Table.Cell>
|
||||
<Table.Cell>{user.numberOfJobs}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<div style={{ float: 'right' }}>
|
||||
<Button circular color="red" icon="trash" onClick={() => onUserRemoval(user.id)} />
|
||||
<Button circular color="blue" icon="edit" onClick={() => onUserEdit(user.id)} />
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
});
|
||||
};
|
||||
const empty = (
|
||||
<Empty
|
||||
image={<IllustrationNoResult />}
|
||||
darkModeImage={<IllustrationNoResultDark />}
|
||||
description={'No user available'}
|
||||
/>
|
||||
);
|
||||
|
||||
export default function UserTable({ user = [], onUserRemoval, onUserEdit } = {}) {
|
||||
return (
|
||||
<Table inverted>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell>Username</Table.HeaderCell>
|
||||
<Table.HeaderCell>Last login</Table.HeaderCell>
|
||||
<Table.HeaderCell>Number of jobs</Table.HeaderCell>
|
||||
<Table.HeaderCell></Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
|
||||
<Table.Body>{user.length === 0 ? emptyTable() : content(user, onUserRemoval, onUserEdit)}</Table.Body>
|
||||
</Table>
|
||||
<Table
|
||||
pagination={false}
|
||||
empty={empty}
|
||||
columns={[
|
||||
{
|
||||
title: 'Username',
|
||||
dataIndex: 'username',
|
||||
},
|
||||
{
|
||||
title: 'Last login',
|
||||
dataIndex: 'lastLogin',
|
||||
render: (value) => {
|
||||
return format(value);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Number of jobs',
|
||||
dataIndex: 'numberOfJobs',
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
dataIndex: 'tools',
|
||||
render: (value, user) => {
|
||||
return (
|
||||
<div style={{ float: 'right' }}>
|
||||
<Button
|
||||
type="danger"
|
||||
icon={<IconDelete />}
|
||||
onClick={() => onUserRemoval(user.id)}
|
||||
style={{ marginRight: '1rem' }}
|
||||
/>
|
||||
<Button type="primary" icon={<IconEdit />} onClick={() => onUserEdit(user.id)} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
dataSource={user}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import './Toasts.css';
|
||||
|
||||
export default function Toast({ id, delay = 5500, message, onHide, backgroundColor, color, title }) {
|
||||
const [className, setClassname] = React.useState('toast-container show-toast');
|
||||
|
||||
React.useEffect(() => {
|
||||
let hideTimeout = null;
|
||||
const timeout = setTimeout(() => {
|
||||
setClassname('toast-container hide-toast');
|
||||
hideTimeout = setTimeout(() => {
|
||||
onHide && onHide(id);
|
||||
}, 500);
|
||||
}, delay);
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
clearTimeout(hideTimeout);
|
||||
};
|
||||
}, [id, delay, onHide]);
|
||||
return (
|
||||
<div className={className} style={{ backgroundColor, color }}>
|
||||
<h5>{title}</h5>
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import Toast from './Toast';
|
||||
import React from 'react';
|
||||
|
||||
export default function ToastsContainer({ toasts, onToastFinished }) {
|
||||
return (
|
||||
<div className="toasts-container">
|
||||
{toasts.map((toast, index) => (
|
||||
<Toast key={index} {...toast} onHide={onToastFinished} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
const CheckoutDrawerContext = createContext({
|
||||
showToast: () => {},
|
||||
});
|
||||
|
||||
export default CheckoutDrawerContext;
|
||||
@@ -1,63 +0,0 @@
|
||||
.toasts-container {
|
||||
position: fixed;
|
||||
z-index: 65535;
|
||||
right: 0;
|
||||
max-width: 250px;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.toasts-container > .toast-container {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.toasts-container:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.toast-container {
|
||||
visibility: hidden;
|
||||
position: relative;
|
||||
z-index: 65535;
|
||||
right: -1000px;
|
||||
|
||||
background-color: skyblue;
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
|
||||
min-width: 10rem;
|
||||
min-height: 3rem;
|
||||
}
|
||||
|
||||
.toast-container.show-toast {
|
||||
visibility: visible;
|
||||
right: 24px;
|
||||
animation: slidein 0.5s;
|
||||
}
|
||||
|
||||
.toast-container.hide-toast {
|
||||
visibility: visible;
|
||||
animation: slideout 0.5s;
|
||||
}
|
||||
|
||||
@keyframes slidein {
|
||||
from {
|
||||
right: -1000px;
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
right: 24px;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideout {
|
||||
from {
|
||||
right: 24px;
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
right: -1000px;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function useToast() {
|
||||
const [toasts, setToasts] = React.useState([]);
|
||||
|
||||
const showToast = ({ message, delay, color, backgroundColor, title }) => {
|
||||
const toast = {
|
||||
id: toasts.length,
|
||||
message,
|
||||
delay,
|
||||
backgroundColor,
|
||||
color,
|
||||
title,
|
||||
};
|
||||
setToasts([...toasts, toast].reverse());
|
||||
};
|
||||
|
||||
const onToastFinished = (id) => {
|
||||
setToasts(toasts.filter((toast) => toast.id !== id));
|
||||
};
|
||||
|
||||
return [showToast, onToastFinished, toasts];
|
||||
}
|
||||
@@ -2,14 +2,32 @@ import React from 'react';
|
||||
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { Button, Form, Icon, Message, Segment, Radio } from 'semantic-ui-react';
|
||||
import ToastContext from '../../components/toasts/ToastContext';
|
||||
import { Divider, Input, Radio, TimePicker, Button, RadioGroup } from '@douyinfe/semi-ui';
|
||||
import { InputNumber } from '@douyinfe/semi-ui';
|
||||
import Headline from '../../components/headline/Headline';
|
||||
import { xhrPost } from '../../services/xhr';
|
||||
import { SegmentPart } from '../../components/segment/SegmentPart';
|
||||
import { Banner, Toast } from '@douyinfe/semi-ui';
|
||||
import { IconSave, IconCalendar, IconKey, IconRefresh, IconSignal } from '@douyinfe/semi-icons';
|
||||
import './GeneralSettings.less';
|
||||
|
||||
const GeneralSettings = function Users() {
|
||||
function formatFromTimestamp(ts) {
|
||||
const date = new Date(ts);
|
||||
return `${date.getHours()}:${date.getMinutes() > 9 ? date.getMinutes() : '0' + date.getMinutes()}`;
|
||||
}
|
||||
|
||||
function formatFromTBackend(time) {
|
||||
if (time == null || time.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date();
|
||||
const split = time.split(':');
|
||||
date.setHours(split[0]);
|
||||
date.setMinutes(split[1]);
|
||||
return date.getTime();
|
||||
}
|
||||
|
||||
const GeneralSettings = function GeneralSettings() {
|
||||
const dispatch = useDispatch();
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
|
||||
@@ -21,13 +39,12 @@ const GeneralSettings = function Users() {
|
||||
const [scrapingAntProxy, setScrapingAntProxy] = React.useState('');
|
||||
const [workingHourFrom, setWorkingHourFrom] = React.useState(null);
|
||||
const [workingHourTo, setWorkingHourTo] = React.useState(null);
|
||||
const ctx = React.useContext(ToastContext);
|
||||
|
||||
React.useEffect(() => {
|
||||
async function init() {
|
||||
await dispatch.generalSettings.getGeneralSettings();
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
init();
|
||||
}, []);
|
||||
|
||||
@@ -40,19 +57,18 @@ const GeneralSettings = function Users() {
|
||||
setWorkingHourTo(settings?.workingHours?.to);
|
||||
setScrapingAntProxy(settings?.scrapingAnt?.proxy || 'datacenter');
|
||||
}
|
||||
|
||||
init();
|
||||
}, [settings]);
|
||||
|
||||
const nullOrEmpty = (val) => val == null || val.length === 0;
|
||||
|
||||
const throwMessage = (message, type) => {
|
||||
ctx.showToast({
|
||||
title: type === 'error' ? 'Error' : 'Success',
|
||||
message: message,
|
||||
delay: 5000,
|
||||
backgroundColor: type === 'error' ? '#db2828' : '#87eb8f',
|
||||
color: type === 'error' ? '#fff' : '#000',
|
||||
});
|
||||
if (type === 'error') {
|
||||
Toast.error(message);
|
||||
} else {
|
||||
Toast.success(message);
|
||||
}
|
||||
};
|
||||
|
||||
const onStore = async () => {
|
||||
@@ -97,139 +113,130 @@ const GeneralSettings = function Users() {
|
||||
{!loading && (
|
||||
<React.Fragment>
|
||||
<Headline text="General Settings" />
|
||||
<Message className="generalSettings__message">
|
||||
<h5>
|
||||
<Icon name="info circle" />
|
||||
Info
|
||||
</h5>
|
||||
<p>If you change any settings, you must restart Fredy afterwards.</p>
|
||||
</Message>
|
||||
<Form>
|
||||
<Banner
|
||||
fullMode={false}
|
||||
type="info"
|
||||
closeIcon={null}
|
||||
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>Info</div>}
|
||||
style={{ marginBottom: '1rem' }}
|
||||
description="If you change any settings, you must restart Fredy afterwards."
|
||||
/>
|
||||
<div>
|
||||
<SegmentPart
|
||||
name="Interval"
|
||||
helpText="Interval in minutes for running queries against the configured services."
|
||||
icon="refresh"
|
||||
Icon={IconRefresh}
|
||||
>
|
||||
<Form.Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="1440"
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={1440}
|
||||
placeholder="Interval in minutes"
|
||||
inverted
|
||||
size="mini"
|
||||
width={6}
|
||||
defaultValue={interval}
|
||||
onChange={(e) => setInterval(e.target.value)}
|
||||
value={interval}
|
||||
formatter={(value) => `${value}`.replace(/\D/g, '')}
|
||||
onChange={(value) => setInterval(value)}
|
||||
suffix={'minutes'}
|
||||
/>
|
||||
</SegmentPart>
|
||||
|
||||
<SegmentPart name="Port" helpText="Port on which Fredy is running." icon="connectdevelop">
|
||||
<Form.Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="99999"
|
||||
<Divider margin="1rem" />
|
||||
<SegmentPart name="Port" helpText="Port on which Fredy is running." Icon={IconSignal}>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={99999}
|
||||
placeholder="Port"
|
||||
inverted
|
||||
size="mini"
|
||||
width={6}
|
||||
defaultValue={port}
|
||||
onChange={(e) => setPort(e.target.value)}
|
||||
value={port}
|
||||
formatter={(value) => `${value}`.replace(/\D/g, '')}
|
||||
onChange={(value) => setPort(value)}
|
||||
/>
|
||||
</SegmentPart>
|
||||
|
||||
<Divider margin="1rem" />
|
||||
<SegmentPart
|
||||
name="ScrapingAnt Api Key"
|
||||
helpText="The api key for ScrapingAnt is used to be able to scrape Immoscout."
|
||||
icon="key"
|
||||
Icon={IconKey}
|
||||
>
|
||||
<Form.Input
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="ScrapingAnt Api Key"
|
||||
inverted
|
||||
size="mini"
|
||||
width={6}
|
||||
defaultValue={scrapingAntApiKey}
|
||||
onChange={(e) => setScrapingAntApiKey(e.target.value)}
|
||||
value={scrapingAntApiKey}
|
||||
onChange={(val) => setScrapingAntApiKey(val)}
|
||||
/>
|
||||
</SegmentPart>
|
||||
|
||||
<Divider margin="1rem" />
|
||||
<SegmentPart
|
||||
name="ScrapingAnt proxy settings"
|
||||
helpText="Scraping ant provides different proxies."
|
||||
icon="key"
|
||||
Icon={IconKey}
|
||||
>
|
||||
<Message info>
|
||||
ScrapingAnt is needed to scrape Immoscout. ScrapingAnt itself is using 2 different types of proxies.{' '}
|
||||
<br />
|
||||
<h4>Datacenter-Proxy</h4>
|
||||
Proxy server located in one of the datacenters across the world. Datacenter proxies are slower and more
|
||||
likely to fail, but they are cheaper. A call with a datacenter proxy cost 10 credits.
|
||||
<h4>Residential-Proxy</h4>
|
||||
High-quality proxy server located in one of the real people houses across the world. Datacenter proxies
|
||||
are faster and more likely to success, but they are more expensive. A call with a datacenter proxy cost
|
||||
250 credits.
|
||||
<br />
|
||||
<br />
|
||||
<b>
|
||||
On the free tier, you have 10.000 credits, so chose your option wisely. Keep in mind, only successful
|
||||
calls will be charged.
|
||||
</b>
|
||||
</Message>
|
||||
<Form.Field>
|
||||
<Radio
|
||||
label="Datacenter proxy"
|
||||
name="scrapingAntProxy"
|
||||
value="datacenter"
|
||||
checked={scrapingAntProxy === 'datacenter'}
|
||||
onChange={(e, { value }) => setScrapingAntProxy(value)}
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<Radio
|
||||
label="Residential proxy"
|
||||
name="scrapingAntProxy"
|
||||
value="residential"
|
||||
checked={scrapingAntProxy === 'residential'}
|
||||
onChange={(e, { value }) => setScrapingAntProxy(value)}
|
||||
/>
|
||||
</Form.Field>
|
||||
</SegmentPart>
|
||||
<Banner
|
||||
fullMode={false}
|
||||
type="info"
|
||||
closeIcon={null}
|
||||
title={
|
||||
<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>
|
||||
ScrapingAnt is needed to scrape Immoscout. ScrapingAnt itself is using 2 different types of proxies
|
||||
</div>
|
||||
}
|
||||
style={{ marginBottom: '1rem' }}
|
||||
description={
|
||||
<div>
|
||||
<h4>Datacenter-Proxy</h4>
|
||||
Proxy server located in one of the datacenters across the world. Datacenter proxies are slower and
|
||||
more likely to fail, but they are cheaper. A call with a datacenter proxy cost 10 credits.
|
||||
<h4>Residential-Proxy</h4>
|
||||
High-quality proxy server located in one of the real people houses across the world. Datacenter
|
||||
proxies are faster and more likely to success, but they are more expensive. A call with a datacenter
|
||||
proxy cost 250 credits.
|
||||
<br />
|
||||
<br />
|
||||
<b>
|
||||
On the free tier, you have 10.000 credits, so chose your option wisely. Keep in mind, only
|
||||
successful calls will be charged.
|
||||
</b>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<RadioGroup value={scrapingAntProxy} onChange={(e) => setScrapingAntProxy(e.target.value)}>
|
||||
<Radio name="datacenter" value="datacenter" checked={scrapingAntProxy === 'datacenter'}>
|
||||
Datacenter proxy
|
||||
</Radio>
|
||||
<Radio name="residential" value="residential" checked={scrapingAntProxy === 'residential'}>
|
||||
Residential proxy
|
||||
</Radio>
|
||||
</RadioGroup>
|
||||
</SegmentPart>
|
||||
<Divider margin="1rem" />
|
||||
<SegmentPart
|
||||
name="Working hours"
|
||||
helpText="During this hours, Fredy will search for new apartments. If nothing is configured, Fredy will search around the clock."
|
||||
icon="calendar outline"
|
||||
Icon={IconCalendar}
|
||||
>
|
||||
<div className="generalSettings__timePickerContainer">
|
||||
<Form.Input
|
||||
className="generalSettings__time"
|
||||
type="time"
|
||||
placeholder="Working hours from"
|
||||
inverted
|
||||
size="mini"
|
||||
width={2}
|
||||
defaultValue={workingHourFrom}
|
||||
onChange={(e) => setWorkingHourFrom(e.target.value)}
|
||||
<TimePicker
|
||||
format={'HH:mm'}
|
||||
insetLabel="From"
|
||||
value={formatFromTBackend(workingHourFrom)}
|
||||
placeholder=""
|
||||
onChange={(val) => {
|
||||
setWorkingHourFrom(val == null ? null : formatFromTimestamp(val));
|
||||
}}
|
||||
/>
|
||||
<div className="generalSettings__until">until</div>
|
||||
<Form.Input
|
||||
type="time"
|
||||
placeholder="Working hours to"
|
||||
inverted
|
||||
size="mini"
|
||||
width={2}
|
||||
defaultValue={workingHourTo}
|
||||
onChange={(e) => setWorkingHourTo(e.target.value)}
|
||||
<TimePicker
|
||||
format={'HH:mm'}
|
||||
insetLabel="Until"
|
||||
value={formatFromTBackend(workingHourTo)}
|
||||
placeholder=""
|
||||
onChange={(val) => {
|
||||
setWorkingHourTo(val == null ? null : formatFromTimestamp(val));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</SegmentPart>
|
||||
|
||||
<Segment inverted floated="right">
|
||||
<Button color="teal" onClick={onStore}>
|
||||
Save
|
||||
</Button>
|
||||
</Segment>
|
||||
</Form>
|
||||
<Divider margin="1rem" />
|
||||
<Button type="primary" theme="solid" onClick={onStore} icon={<IconSave />}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,11 +2,7 @@
|
||||
&__timePickerContainer {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
&__until {
|
||||
margin-left: 1rem;
|
||||
margin-right: 1rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
&__help{
|
||||
@@ -14,8 +10,4 @@
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
&__message{
|
||||
background: #8fe8ff!important;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import React from 'react';
|
||||
|
||||
import ToastContext from '../../components/toasts/ToastContext';
|
||||
import JobTable from '../../components/table/JobTable';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { xhrDelete, xhrPut } from '../../services/xhr';
|
||||
import { Button, Icon } from 'semantic-ui-react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import ProcessingTimes from './ProcessingTimes';
|
||||
|
||||
import { Button, Toast } from '@douyinfe/semi-ui';
|
||||
import { IconPlusCircle } from '@douyinfe/semi-icons';
|
||||
import './Jobs.less';
|
||||
|
||||
export default function Jobs() {
|
||||
@@ -15,49 +14,24 @@ export default function Jobs() {
|
||||
const processingTimes = useSelector((state) => state.jobs.processingTimes);
|
||||
const history = useHistory();
|
||||
const dispatch = useDispatch();
|
||||
const ctx = React.useContext(ToastContext);
|
||||
|
||||
const onJobRemoval = async (jobId) => {
|
||||
try {
|
||||
await xhrDelete('/api/jobs', { jobId });
|
||||
ctx.showToast({
|
||||
title: 'Success',
|
||||
message: 'Job successfully remove',
|
||||
delay: 5000,
|
||||
backgroundColor: '#87eb8f',
|
||||
color: '#000',
|
||||
});
|
||||
Toast.success('Job successfully remove');
|
||||
await dispatch.jobs.getJobs();
|
||||
} catch (error) {
|
||||
ctx.showToast({
|
||||
title: 'Error',
|
||||
message: error,
|
||||
delay: 35000,
|
||||
backgroundColor: '#db2828',
|
||||
color: '#fff',
|
||||
});
|
||||
Toast.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const onJobStatusChanged = async (jobId, status) => {
|
||||
try {
|
||||
await xhrPut(`/api/jobs/${jobId}/status`, { status });
|
||||
ctx.showToast({
|
||||
title: 'Success',
|
||||
message: 'Job status successfully changed',
|
||||
delay: 5000,
|
||||
backgroundColor: '#87eb8f',
|
||||
color: '#000',
|
||||
});
|
||||
Toast.success('Job status successfully changed');
|
||||
await dispatch.jobs.getJobs();
|
||||
} catch (error) {
|
||||
ctx.showToast({
|
||||
title: 'Error',
|
||||
message: error,
|
||||
delay: 35000,
|
||||
backgroundColor: '#db2828',
|
||||
color: '#fff',
|
||||
});
|
||||
Toast.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -65,8 +39,12 @@ export default function Jobs() {
|
||||
<div>
|
||||
<div>
|
||||
{processingTimes != null && <ProcessingTimes processingTimes={processingTimes} />}
|
||||
<Button primary className="jobs__newButton" onClick={() => history.push('/jobs/new')}>
|
||||
<Icon name="plus" />
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<IconPlusCircle />}
|
||||
className="jobs__newButton"
|
||||
onClick={() => history.push('/jobs/new')}
|
||||
>
|
||||
New Job
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,51 +1,67 @@
|
||||
import React from 'react';
|
||||
import { format } from '../../services/time/timeService';
|
||||
import { Header, Label, Message, Segment } from 'semantic-ui-react';
|
||||
|
||||
import { Card, Descriptions, Divider } from '@douyinfe/semi-ui';
|
||||
import { IconBolt } from '@douyinfe/semi-icons';
|
||||
export default function ProcessingTimes({ processingTimes }) {
|
||||
const { Meta } = Card;
|
||||
return (
|
||||
<React.Fragment>
|
||||
<div>
|
||||
<Label as="span" color="black">
|
||||
Processing Interval:
|
||||
<Label.Detail>{processingTimes.interval} min</Label.Detail>
|
||||
</Label>
|
||||
<>
|
||||
<Descriptions
|
||||
row
|
||||
size="small"
|
||||
style={{
|
||||
backgroundColor: '#35363c',
|
||||
borderRadius: '4px',
|
||||
padding: '10px',
|
||||
}}
|
||||
>
|
||||
<Descriptions.Item itemKey="Processing Interval">{processingTimes.interval} min</Descriptions.Item>
|
||||
{processingTimes.lastRun && (
|
||||
<React.Fragment>
|
||||
<Label as="span" color="black">
|
||||
Last run:
|
||||
<Label.Detail>{format(processingTimes.lastRun)}</Label.Detail>
|
||||
</Label>
|
||||
<Label as="span" color="black">
|
||||
Next run:
|
||||
<Label.Detail>{format(processingTimes.lastRun + processingTimes.interval * 60000)}</Label.Detail>
|
||||
</Label>
|
||||
</React.Fragment>
|
||||
<>
|
||||
<Descriptions.Item itemKey="Last run">{format(processingTimes.lastRun)}</Descriptions.Item>
|
||||
<Descriptions.Item itemKey="Next run">
|
||||
{format(processingTimes.lastRun + processingTimes.interval * 60000)}
|
||||
</Descriptions.Item>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Descriptions>
|
||||
|
||||
{processingTimes.scrapingAntData != null && (
|
||||
<Segment inverted>
|
||||
<Header as="h5">Remaining ScrapingAnt calls</Header>
|
||||
<Message.List>
|
||||
<Message.Item>Plan: {processingTimes.scrapingAntData.plan_name}</Message.Item>
|
||||
<Message.Item>
|
||||
<>
|
||||
<Divider margin="1rem" />
|
||||
<Card
|
||||
style={{ backgroundColor: '#35363c' }}
|
||||
title={
|
||||
<Meta
|
||||
title="Remaining ScrapingAnt calls"
|
||||
description="Information about your Scraping Ant Plan"
|
||||
avatar={<IconBolt />}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<p>Plan: {processingTimes.scrapingAntData.plan_name}</p>
|
||||
<p>
|
||||
Duration: {format(new Date(processingTimes.scrapingAntData.start_date))} -{' '}
|
||||
{format(new Date(processingTimes.scrapingAntData.end_date))}
|
||||
</Message.Item>
|
||||
<Message.Item>
|
||||
<br />
|
||||
Credits: {processingTimes.scrapingAntData.remained_credits}/
|
||||
{processingTimes.scrapingAntData.plan_total_credits} (250 credits per call)
|
||||
</Message.Item>
|
||||
</Message.List>
|
||||
If you want to scrape Immoscout more often, you have to purchase a premium account of{' '}
|
||||
<a href="https://scrapingant.com/" target="_blank" rel="noreferrer">
|
||||
{' '}
|
||||
ScrapingAnt
|
||||
</a>
|
||||
. You can use the code <b>FREDY10</b> to get 10% off. (No affiliation, we are <b>not</b> getting paid to
|
||||
recommend ScrapingAnt.)
|
||||
</Segment>
|
||||
</p>
|
||||
If you want to scrape Immoscout more often, you have to purchase a premium account of{' '}
|
||||
<a href="https://scrapingant.com/" target="_blank" rel="noreferrer">
|
||||
ScrapingAnt
|
||||
</a>
|
||||
. You can use the code <b>FREDY10</b> to get 10% off. (No affiliation, we are <b>not</b> getting paid to
|
||||
recommend ScrapingAnt.)
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</React.Fragment>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
|
||||
|
||||
*/
|
||||
|
||||
@@ -2,19 +2,17 @@ import React, { Fragment, useState } from 'react';
|
||||
|
||||
import NotificationAdapterMutator from './components/notificationAdapter/NotificationAdapterMutator';
|
||||
import NotificationAdapterTable from '../../../components/table/NotificationAdapterTable';
|
||||
import { Icon, Form, Button, Label } from 'semantic-ui-react';
|
||||
import ProviderTable from '../../../components/table/ProviderTable';
|
||||
import ProviderMutator from './components/provider/ProviderMutator';
|
||||
import ToastContext from '../../../components/toasts/ToastContext';
|
||||
import Headline from '../../../components/headline/Headline';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { xhrPost } from '../../../services/xhr';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
import { Divider, Input, Switch, Button, TagInput, Toast } from '@douyinfe/semi-ui';
|
||||
import './JobMutation.less';
|
||||
import Switch from 'react-switch';
|
||||
import { SegmentPart } from '../../../components/segment/SegmentPart';
|
||||
import { IconPlusCircle } from '@douyinfe/semi-icons';
|
||||
|
||||
export default function JobMutator() {
|
||||
const jobs = useSelector((state) => state.jobs.jobs);
|
||||
@@ -38,7 +36,6 @@ export default function JobMutator() {
|
||||
const [enabled, setEnabled] = useState(defaultEnabled);
|
||||
const history = useHistory();
|
||||
const dispatch = useDispatch();
|
||||
const ctx = React.useContext(ToastContext);
|
||||
|
||||
const isSavingEnabled = () => {
|
||||
return notificationAdapterData.length > 0 && providerData.length > 0 && name != null && name.length > 0;
|
||||
@@ -55,24 +52,11 @@ export default function JobMutator() {
|
||||
jobId: jobToBeEdit?.id || null,
|
||||
});
|
||||
await dispatch.jobs.getJobs();
|
||||
ctx.showToast({
|
||||
title: 'Success',
|
||||
message: 'Job successfully saved...',
|
||||
delay: 5000,
|
||||
backgroundColor: '#87eb8f',
|
||||
color: '#000',
|
||||
});
|
||||
Toast.success('Job successfully saved...');
|
||||
history.push('/jobs');
|
||||
} catch (Exception) {
|
||||
console.error(Exception.json.message);
|
||||
|
||||
ctx.showToast({
|
||||
title: 'Error',
|
||||
message: Exception.json != null ? Exception.json.message : Exception,
|
||||
delay: 8000,
|
||||
backgroundColor: '#db2828',
|
||||
color: '#fff',
|
||||
});
|
||||
Toast.error(Exception.json != null ? Exception.json.message : Exception);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -107,20 +91,19 @@ export default function JobMutator() {
|
||||
)}
|
||||
|
||||
<Headline text={jobToBeEdit ? 'Edit a Job' : 'Create a new Job'} />
|
||||
<Form>
|
||||
<form>
|
||||
<SegmentPart name="Name">
|
||||
<Form.Input
|
||||
<Input
|
||||
autofocus
|
||||
type="text"
|
||||
maxLength={40}
|
||||
placeholder="Name"
|
||||
autoFocus
|
||||
inverted
|
||||
width={6}
|
||||
defaultValue={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
value={name}
|
||||
onChange={(value) => setName(value)}
|
||||
/>
|
||||
</SegmentPart>
|
||||
|
||||
<Divider margin="1rem" />
|
||||
<SegmentPart
|
||||
name="Provider"
|
||||
icon="briefcase"
|
||||
@@ -130,10 +113,14 @@ export default function JobMutator() {
|
||||
'to search for new listings.'
|
||||
}
|
||||
>
|
||||
<Form.Button primary className="jobMutation__newButton" onClick={() => setProviderCreationVisibility(true)}>
|
||||
<Icon name="plus" />
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<IconPlusCircle />}
|
||||
className="jobMutation__newButton"
|
||||
onClick={() => setProviderCreationVisibility(true)}
|
||||
>
|
||||
Add new Provider
|
||||
</Form.Button>
|
||||
</Button>
|
||||
|
||||
<ProviderTable
|
||||
providerData={providerData}
|
||||
@@ -142,20 +129,20 @@ export default function JobMutator() {
|
||||
}}
|
||||
/>
|
||||
</SegmentPart>
|
||||
|
||||
<Divider margin="1rem" />
|
||||
<SegmentPart
|
||||
icon="bell"
|
||||
name="Notification Adapter"
|
||||
helpText="Fredy supports multiple ways to notify you about new findings. These are called notification adapter. You can chose between email, Telegram etc."
|
||||
>
|
||||
<Form.Button
|
||||
primary
|
||||
<Button
|
||||
type="primary"
|
||||
className="jobMutation__newButton"
|
||||
icon={<IconPlusCircle />}
|
||||
onClick={() => setNotificationCreationVisibility(true)}
|
||||
>
|
||||
<Icon name="plus" />
|
||||
Add new Notification Adapter
|
||||
</Form.Button>
|
||||
</Button>
|
||||
|
||||
<NotificationAdapterTable
|
||||
notificationAdapter={notificationAdapterData}
|
||||
@@ -169,40 +156,19 @@ export default function JobMutator() {
|
||||
}}
|
||||
/>
|
||||
</SegmentPart>
|
||||
|
||||
<Divider margin="1rem" />
|
||||
<SegmentPart
|
||||
icon="bell"
|
||||
name="Blacklist"
|
||||
helpText="If a listing contains one of these words, it will be filtered out. Words must be comma separated. To remove a word from the black list, just click the red label(s)."
|
||||
helpText="If a listing contains one of these words, it will be filtered out. Type in a word, then hit enter."
|
||||
>
|
||||
<Form.Input
|
||||
type="text"
|
||||
maxLength={40}
|
||||
placeholder="Comma separated list of blacklisted words"
|
||||
autoFocus
|
||||
inverted
|
||||
width={6}
|
||||
onChange={(e) => {
|
||||
if (e.target.value.indexOf(',') !== -1) {
|
||||
setBlacklist([...blacklist, e.target.value.replace(',', '')]);
|
||||
e.target.value = '';
|
||||
}
|
||||
}}
|
||||
<TagInput
|
||||
value={blacklist || []}
|
||||
placeholder="Add a word for filtering..."
|
||||
onChange={(v) => setBlacklist([...v])}
|
||||
/>
|
||||
{blacklist.map((blacklistWord) => (
|
||||
<Label
|
||||
as="a"
|
||||
key={`blacklist_${blacklistWord}`}
|
||||
onClick={(e, obj) => {
|
||||
setBlacklist(blacklist.filter((word) => word !== obj.content));
|
||||
}}
|
||||
content={blacklistWord}
|
||||
icon="thumbs down"
|
||||
color="red"
|
||||
/>
|
||||
))}
|
||||
</SegmentPart>
|
||||
|
||||
<Divider margin="1rem" />
|
||||
<SegmentPart
|
||||
icon="play circle outline"
|
||||
name="Job activation"
|
||||
@@ -210,14 +176,14 @@ export default function JobMutator() {
|
||||
>
|
||||
<Switch className="jobMutation__spaceTop" onChange={(checked) => setEnabled(checked)} checked={enabled} />
|
||||
</SegmentPart>
|
||||
|
||||
<Button color="red" onClick={() => history.push('/jobs')}>
|
||||
<Divider margin="1rem" />
|
||||
<Button type="danger" style={{ marginRight: '1rem' }} onClick={() => history.push('/jobs')}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="green" disabled={!isSavingEnabled()} onClick={mutateJob}>
|
||||
<Button type="primary" icon={<IconPlusCircle />} disabled={!isSavingEnabled()} onClick={mutateJob}>
|
||||
Save
|
||||
</Button>
|
||||
</Form>
|
||||
</form>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
.jobMutation {
|
||||
&__newButton{
|
||||
float: right;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.semi-select-option-list-wrapper {
|
||||
width: 25rem;
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { transform } from '../../../../../services/transformer/notificationAdapterTransformer';
|
||||
import { Modal, Form, Button, Dropdown, Input, Message } from 'semantic-ui-react';
|
||||
import { xhrPost } from '../../../../../services/xhr';
|
||||
import Help from './NotificationHelpDisplay';
|
||||
import { useSelector } from 'react-redux';
|
||||
import Switch from 'react-switch';
|
||||
import { Banner, Button, Form, Modal, Select, Switch } from '@douyinfe/semi-ui';
|
||||
|
||||
import './NotificationAdapterMutator.less';
|
||||
|
||||
@@ -138,8 +137,7 @@ export default function NotificationAdapterMutator({
|
||||
const uiElement = selectedAdapter.fields[key];
|
||||
|
||||
return (
|
||||
<Form.Field key={uiElement.description}>
|
||||
<label>{uiElement.label}:</label>
|
||||
<Form key={key}>
|
||||
{uiElement.type === 'boolean' ? (
|
||||
<Switch
|
||||
checked={uiElement.value || false}
|
||||
@@ -148,106 +146,108 @@ export default function NotificationAdapterMutator({
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
<Form.Input
|
||||
style={{ width: '100%' }}
|
||||
field={uiElement.label}
|
||||
type={uiElement.type}
|
||||
value={uiElement.value || ''}
|
||||
placeholder={uiElement.label}
|
||||
onChange={(e) => {
|
||||
setValue(selectedAdapter, uiElement, key, e.target.value);
|
||||
label={uiElement.label}
|
||||
onChange={(value) => {
|
||||
setValue(selectedAdapter, uiElement, key, value);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Form.Field>
|
||||
</Form>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
onClose={() => onVisibilityChanged(false)}
|
||||
onOpen={() => onVisibilityChanged(true)}
|
||||
open={visible}
|
||||
title="Adding a new Notification Adapter"
|
||||
visible={visible}
|
||||
style={{ width: '95%' }}
|
||||
footer={
|
||||
<div>
|
||||
<Button type="secondary" disabled={selectedAdapter == null} style={{ float: 'left' }} onClick={() => onTry()}>
|
||||
Try
|
||||
</Button>
|
||||
<Button type="danger" onClick={() => onSubmit(true)}>
|
||||
Save
|
||||
</Button>
|
||||
<Button type="primary" onClick={() => onSubmit(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Modal.Header>Adding a new Notification Adapter</Modal.Header>
|
||||
<Modal.Content image>
|
||||
<Modal.Description>
|
||||
{validationMessage != null && (
|
||||
<Message negative>
|
||||
<Message.Header>Houston we have a problem...</Message.Header>
|
||||
<p dangerouslySetInnerHTML={{ __html: validationMessage }} />
|
||||
</Message>
|
||||
)}
|
||||
{successMessage != null && (
|
||||
<Message positive>
|
||||
<Message.Header>Yay!</Message.Header>
|
||||
<p dangerouslySetInnerHTML={{ __html: successMessage }} />
|
||||
</Message>
|
||||
)}
|
||||
|
||||
<p>
|
||||
When Fredy found new listings, we like to report them to you. To do so, notification adapter can be
|
||||
configured. <br />
|
||||
There are multiple ways how Fredy can send new listings to you. Chose your weapon...
|
||||
</p>
|
||||
<Dropdown
|
||||
placeholder="Select a notification adapter"
|
||||
className="providerMutator__fields"
|
||||
selection
|
||||
value={selectedAdapter == null ? '' : selectedAdapter.id}
|
||||
options={adapter
|
||||
.map((a) => {
|
||||
return {
|
||||
key: a.id,
|
||||
value: a.id,
|
||||
text: a.name,
|
||||
};
|
||||
})
|
||||
//filter out those, that have already been selected
|
||||
.filter((option) =>
|
||||
editNotificationAdapter != null
|
||||
? true
|
||||
: selected.find((selectedOption) => selectedOption.id === option.key) == null
|
||||
)
|
||||
.sort(sortAdapter)}
|
||||
onChange={(e, { value }) => {
|
||||
setSuccessMessage(null);
|
||||
setValidationMessage(null);
|
||||
const selectedAdapter = adapter.find((a) => a.id === value);
|
||||
setSelectedAdapter(Object.assign({}, selectedAdapter));
|
||||
}}
|
||||
/>
|
||||
<br />
|
||||
<br />
|
||||
{selectedAdapter != null && (
|
||||
<Form>
|
||||
<i>{selectedAdapter.description}</i>
|
||||
<br />
|
||||
{selectedAdapter.readme != null && (
|
||||
<React.Fragment>
|
||||
<Help readme={selectedAdapter.readme} />
|
||||
</React.Fragment>
|
||||
)}
|
||||
<br />
|
||||
{getFieldsFor(selectedAdapter)}
|
||||
</Form>
|
||||
)}
|
||||
</Modal.Description>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<Button
|
||||
content="Try Notification Adapter"
|
||||
labelPosition="left"
|
||||
floated="left"
|
||||
icon="hand spock"
|
||||
onClick={() => onTry()}
|
||||
color="teal"
|
||||
{validationMessage != null && (
|
||||
<Banner
|
||||
fullMode={false}
|
||||
type="danger"
|
||||
closeIcon={null}
|
||||
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>Error</div>}
|
||||
style={{ marginBottom: '1rem' }}
|
||||
description={<p dangerouslySetInnerHTML={{ __html: validationMessage }} />}
|
||||
/>
|
||||
<Button color="black" onClick={() => onSubmit(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button content="Save" labelPosition="right" icon="checkmark" onClick={() => onSubmit(true)} positive />
|
||||
</Modal.Actions>
|
||||
)}
|
||||
{successMessage != null && (
|
||||
<Banner
|
||||
fullMode={false}
|
||||
type="success"
|
||||
closeIcon={null}
|
||||
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>Yay!</div>}
|
||||
style={{ marginBottom: '1rem' }}
|
||||
description={<p dangerouslySetInnerHTML={{ __html: successMessage }} />}
|
||||
/>
|
||||
)}
|
||||
|
||||
<p>
|
||||
When Fredy found new listings, we like to report them to you. To do so, notification adapter can be configured.{' '}
|
||||
<br />
|
||||
There are multiple ways how Fredy can send new listings to you. Chose your weapon...
|
||||
</p>
|
||||
|
||||
<Select
|
||||
filter
|
||||
placeholder="Select a notification adapter"
|
||||
className="providerMutator__fields"
|
||||
value={selectedAdapter == null ? '' : selectedAdapter.id}
|
||||
optionList={adapter
|
||||
.map((a) => {
|
||||
return {
|
||||
otherKey: a.id,
|
||||
value: a.id,
|
||||
label: a.name,
|
||||
};
|
||||
})
|
||||
//filter out those, that have already been selected
|
||||
.filter((option) =>
|
||||
editNotificationAdapter != null
|
||||
? true
|
||||
: selected.find((selectedOption) => selectedOption.id === option.key) == null
|
||||
)
|
||||
.sort(sortAdapter)}
|
||||
onChange={(value) => {
|
||||
setSuccessMessage(null);
|
||||
setValidationMessage(null);
|
||||
const selectedAdapter = adapter.find((a) => a.id === value);
|
||||
setSelectedAdapter(Object.assign({}, selectedAdapter));
|
||||
}}
|
||||
/>
|
||||
<br />
|
||||
<br />
|
||||
{selectedAdapter != null && (
|
||||
<>
|
||||
<i>{selectedAdapter.description}</i>
|
||||
<br />
|
||||
<br />
|
||||
{selectedAdapter.readme != null && <Help readme={selectedAdapter.readme} />}
|
||||
<br />
|
||||
{getFieldsFor(selectedAdapter)}
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
import React from 'react';
|
||||
import { Accordion, Icon } from 'semantic-ui-react';
|
||||
import { Banner } from '@douyinfe/semi-ui';
|
||||
|
||||
export default function Help({ readme }) {
|
||||
const [active, setActive] = React.useState(false);
|
||||
|
||||
return (
|
||||
<Accordion>
|
||||
<Accordion.Title active={active} index={0} onClick={() => setActive(!active)}>
|
||||
<React.Fragment>
|
||||
<Icon name="dropdown" /> <span className="providerMutator__helpLink"> More information</span>
|
||||
</React.Fragment>
|
||||
</Accordion.Title>
|
||||
<Accordion.Content active={active} className="providerMutator__helpBox">
|
||||
<p dangerouslySetInnerHTML={{ __html: readme }} />
|
||||
</Accordion.Content>
|
||||
</Accordion>
|
||||
<Banner
|
||||
fullMode={false}
|
||||
type="info"
|
||||
closeIcon={null}
|
||||
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>Information</div>}
|
||||
description={<p dangerouslySetInnerHTML={{ __html: readme }} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { Banner, Modal, Select, Input } from '@douyinfe/semi-ui';
|
||||
import { transform } from '../../../../../services/transformer/providerTransformer';
|
||||
import { Modal, Icon, Button, Dropdown, Input, Message } from 'semantic-ui-react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { IconLikeHeart } from '@douyinfe/semi-icons';
|
||||
import './ProviderMutator.less';
|
||||
|
||||
const sortProvider = (a, b) => {
|
||||
@@ -61,79 +61,90 @@ export default function ProviderMutator({ onVisibilityChanged, visible = false,
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal onClose={() => onVisibilityChanged(false)} onOpen={() => onVisibilityChanged(true)} open={visible}>
|
||||
<Modal.Header>Adding a new Provider</Modal.Header>
|
||||
<Modal.Content image>
|
||||
<Modal.Description>
|
||||
{validationMessage != null && (
|
||||
<Message negative>
|
||||
<Message.Header>Houston we have a problem...</Message.Header>
|
||||
<p>{validationMessage}</p>
|
||||
</Message>
|
||||
)}
|
||||
<Modal
|
||||
title="Adding a new Provider"
|
||||
visible={visible}
|
||||
onOk={() => onSubmit(true)}
|
||||
onCancel={() => onSubmit(false)}
|
||||
style={{ width: '50rem' }}
|
||||
okText="Save"
|
||||
>
|
||||
{validationMessage != null && (
|
||||
<Banner
|
||||
fullMode={false}
|
||||
type="danger"
|
||||
closeIcon={null}
|
||||
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>Error</div>}
|
||||
style={{ marginBottom: '1rem' }}
|
||||
description={validationMessage}
|
||||
/>
|
||||
)}
|
||||
|
||||
<p>
|
||||
Provider are the <Icon name="heart" color="red" /> of Fredy. We're supporting multiple Provider such as
|
||||
Immowelt, Kalaydo etc. Select a provider from the list below.
|
||||
<br />
|
||||
Fredy will then open the provider's url in a new tab.
|
||||
</p>
|
||||
<p>
|
||||
You will need to configure your search parameter like you would do when you do a regular search on the
|
||||
provider's website.
|
||||
<br />
|
||||
When the search results are shown on the website, copy the url and paste it into the textfield below.
|
||||
<br />
|
||||
<span style={{ color: '#ff0000' }}>
|
||||
<p>
|
||||
Provider are the <IconLikeHeart style={{ color: '#ff0000' }} /> of Fredy. We're supporting multiple Provider
|
||||
such as Immowelt, Kalaydo etc. Select a provider from the list below.
|
||||
<br />
|
||||
Fredy will then open the provider's url in a new tab.
|
||||
</p>
|
||||
<p>
|
||||
You will need to configure your search parameter like you would do when you do a regular search on the
|
||||
provider's website.
|
||||
<br />
|
||||
When the search results are shown on the website, copy the url and paste it into the textfield below.
|
||||
</p>
|
||||
<Banner
|
||||
fullMode={false}
|
||||
type="warning"
|
||||
closeIcon={null}
|
||||
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>ScrapingAnt</div>}
|
||||
style={{ marginBottom: '1rem' }}
|
||||
description={
|
||||
<div>
|
||||
<p>
|
||||
If you chose Immoscout as a provider, make sure to also add the scrapingAnt apiKey to the config.json.
|
||||
(See readme)
|
||||
</span>
|
||||
<br />
|
||||
<span style={{ color: '#ff0000' }}>
|
||||
</p>
|
||||
<p>
|
||||
Do not forget to sort the results by date before copying the url to Fredy, so that Fredy always captures
|
||||
the latest search results.
|
||||
</span>
|
||||
</p>
|
||||
<Dropdown
|
||||
placeholder="Select a provider"
|
||||
className="providerMutator__fields"
|
||||
selection
|
||||
value={selectedProvider == null ? '' : selectedProvider.id}
|
||||
options={provider
|
||||
.map((pro) => {
|
||||
return {
|
||||
key: pro.id,
|
||||
value: pro.id,
|
||||
text: pro.name,
|
||||
};
|
||||
})
|
||||
.sort(sortProvider)}
|
||||
onChange={(e, { value }) => {
|
||||
const selectedProvider = provider.find((pro) => pro.id === value);
|
||||
setSelectedProvider(selectedProvider);
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
window.open(selectedProvider.baseUrl);
|
||||
}}
|
||||
/>
|
||||
<br />
|
||||
<br />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Provider Url"
|
||||
width={10}
|
||||
className="providerMutator__fields"
|
||||
onBlur={(e) => {
|
||||
setProviderUrl(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</Modal.Description>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<Button color="black" onClick={() => onSubmit(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button content="Save" labelPosition="right" icon="checkmark" onClick={() => onSubmit(true)} positive />
|
||||
</Modal.Actions>
|
||||
<Select
|
||||
filter
|
||||
placeholder="Select a provider"
|
||||
className="providerMutator__fields"
|
||||
optionList={provider
|
||||
.map((pro) => {
|
||||
return {
|
||||
otherKey: pro.id,
|
||||
value: pro.id,
|
||||
label: pro.name,
|
||||
};
|
||||
})
|
||||
.sort(sortProvider)}
|
||||
style={{ width: 180 }}
|
||||
value={selectedProvider == null ? '' : selectedProvider.id}
|
||||
onChange={(value) => {
|
||||
const selectedProvider = provider.find((pro) => pro.id === value);
|
||||
setSelectedProvider(selectedProvider);
|
||||
|
||||
window.open(selectedProvider.baseUrl);
|
||||
}}
|
||||
/>
|
||||
<br />
|
||||
<br />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Provider Url"
|
||||
width={10}
|
||||
className="providerMutator__fields"
|
||||
onBlur={(e) => {
|
||||
setProviderUrl(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import React from 'react';
|
||||
import { Input } from 'semantic-ui-react';
|
||||
|
||||
import cityBackground from '../../assets/city_background.jpg';
|
||||
import Logo from '../../components/logo/Logo';
|
||||
import { xhrPost } from '../../services/xhr';
|
||||
import { Message } from 'semantic-ui-react';
|
||||
import { useHistory } from 'react-router';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { Input, Button, Banner } from '@douyinfe/semi-ui';
|
||||
|
||||
import './login.less';
|
||||
import { IconUser, IconLock } from '@douyinfe/semi-icons';
|
||||
|
||||
export default function Login() {
|
||||
const dispatch = useDispatch();
|
||||
@@ -44,30 +44,41 @@ export default function Login() {
|
||||
<Logo />
|
||||
<form>
|
||||
<div className="login__loginWrapper">
|
||||
{error && <Message negative icon="error" content={error} />}
|
||||
{error && <Banner type="danger" closeIcon={null} description={error} />}
|
||||
<Input
|
||||
icon="user"
|
||||
iconPosition="left"
|
||||
size="large"
|
||||
prefix={<IconUser />}
|
||||
placeholder="Username"
|
||||
defaultValue={username}
|
||||
value={username}
|
||||
showClear
|
||||
style={{ marginTop: error ? '1rem' : '4rem' }}
|
||||
autoFocus
|
||||
onChange={(e) => setUserName(e.target.value)}
|
||||
autofocus
|
||||
onChange={(value) => setUserName(value)}
|
||||
onKeyPress={async (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
await tryLogin();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="password"
|
||||
icon="lock"
|
||||
iconPosition="left"
|
||||
defaultValue={password}
|
||||
size="large"
|
||||
mode="password"
|
||||
prefix={<IconLock />}
|
||||
value={password}
|
||||
placeholder="Password"
|
||||
style={{ marginTop: '2rem' }}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onChange={(value) => setPassword(value)}
|
||||
onKeyPress={async (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
await tryLogin();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<button className="ui primary button" style={{ marginTop: '3rem' }} onClick={tryLogin}>
|
||||
<Button type="primary" onClick={tryLogin} theme="solid" style={{ marginTop: '3rem' }}>
|
||||
Login
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
&__loginWrapper {
|
||||
border: 1px solid #555050;
|
||||
border-radius: 30px;
|
||||
height: 25rem;
|
||||
height: 23rem;
|
||||
width: 30rem;
|
||||
z-index: 1;
|
||||
background-color: #151313ab;
|
||||
|
||||
@@ -1,21 +1,9 @@
|
||||
import React from 'react';
|
||||
import { Modal, Header, Icon, Button } from 'semantic-ui-react';
|
||||
|
||||
import { Modal } from '@douyinfe/semi-ui';
|
||||
const UserRemovalModal = function UserRemovalModal({ onOk, onCancel }) {
|
||||
return (
|
||||
<Modal open={true}>
|
||||
<Header icon="warning sign" content="Warning" />
|
||||
<Modal.Content>
|
||||
<p>Removing this user will also remove all associated jobs.</p>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<Button color="red" onClick={() => onCancel()}>
|
||||
<Icon name="remove" /> Cancel
|
||||
</Button>
|
||||
<Button color="green" onClick={() => onOk()}>
|
||||
<Icon name="checkmark" /> Remove
|
||||
</Button>
|
||||
</Modal.Actions>
|
||||
<Modal title="Removing user" visible={true} closable={false} onOk={onOk} onCancel={onCancel}>
|
||||
<p>Removing this user will also remove all associated jobs.</p>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React from 'react';
|
||||
|
||||
import ToastContext from '../../components/toasts/ToastContext';
|
||||
import { Toast } from '@douyinfe/semi-ui';
|
||||
import UserTable from '../../components/table/UserTable';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { Button, Icon } from 'semantic-ui-react';
|
||||
import { IconPlus } from '@douyinfe/semi-icons';
|
||||
import { Button } from '@douyinfe/semi-ui';
|
||||
import UserRemovalModal from './UserRemovalModal';
|
||||
import { xhrDelete } from '../../services/xhr';
|
||||
import { useHistory } from 'react-router';
|
||||
@@ -14,7 +15,6 @@ const Users = function Users() {
|
||||
const dispatch = useDispatch();
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const users = useSelector((state) => state.user.users);
|
||||
const ctx = React.useContext(ToastContext);
|
||||
const [userIdToBeRemoved, setUserIdToBeRemoved] = React.useState(null);
|
||||
const history = useHistory();
|
||||
|
||||
@@ -23,30 +23,19 @@ const Users = function Users() {
|
||||
await dispatch.user.getUsers();
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
init();
|
||||
}, []);
|
||||
|
||||
const onUserRemoval = async () => {
|
||||
try {
|
||||
await xhrDelete('/api/admin/users', { userId: userIdToBeRemoved });
|
||||
ctx.showToast({
|
||||
title: 'Success',
|
||||
message: 'User successfully remove',
|
||||
delay: 4000,
|
||||
backgroundColor: '#87eb8f',
|
||||
color: '#000',
|
||||
});
|
||||
Toast.success('User successfully remove');
|
||||
setUserIdToBeRemoved(null);
|
||||
await dispatch.jobs.getJobs();
|
||||
await dispatch.user.getUsers();
|
||||
} catch (error) {
|
||||
ctx.showToast({
|
||||
title: 'Error',
|
||||
message: error,
|
||||
delay: 8000,
|
||||
backgroundColor: '#db2828',
|
||||
color: '#fff',
|
||||
});
|
||||
Toast.error(error);
|
||||
setUserIdToBeRemoved(null);
|
||||
}
|
||||
};
|
||||
@@ -57,8 +46,12 @@ const Users = function Users() {
|
||||
<React.Fragment>
|
||||
{userIdToBeRemoved && <UserRemovalModal onCancel={() => setUserIdToBeRemoved(null)} onOk={onUserRemoval} />}
|
||||
|
||||
<Button primary className="users__newButton" onClick={() => history.push('/users/new')}>
|
||||
<Icon name="plus" />
|
||||
<Button
|
||||
type="primary"
|
||||
className="users__newButton"
|
||||
icon={<IconPlus />}
|
||||
onClick={() => history.push('/users/new')}
|
||||
>
|
||||
Create new User
|
||||
</Button>
|
||||
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import React from 'react';
|
||||
|
||||
import ToastContext from '../../../components/toasts/ToastContext';
|
||||
import { xhrGet, xhrPost } from '../../../services/xhr';
|
||||
import { useHistory, useParams } from 'react-router';
|
||||
import { Button, Form } from 'semantic-ui-react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import Switch from 'react-switch';
|
||||
|
||||
import { Divider, Input, Switch, Button, Toast } from '@douyinfe/semi-ui';
|
||||
import './UserMutator.less';
|
||||
import { SegmentPart } from '../../../components/segment/SegmentPart';
|
||||
import { IconPlusCircle } from '@douyinfe/semi-icons';
|
||||
|
||||
const UserMutator = function UserMutator() {
|
||||
const params = useParams();
|
||||
@@ -18,7 +16,6 @@ const UserMutator = function UserMutator() {
|
||||
const [isAdmin, setIsAdmin] = React.useState(false);
|
||||
|
||||
const history = useHistory();
|
||||
const ctx = React.useContext(ToastContext);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -38,6 +35,7 @@ const UserMutator = function UserMutator() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
}, [params.userId]);
|
||||
|
||||
@@ -51,76 +49,62 @@ const UserMutator = function UserMutator() {
|
||||
isAdmin,
|
||||
});
|
||||
await dispatch.user.getUsers();
|
||||
ctx.showToast({
|
||||
title: 'Success',
|
||||
message: 'User successfully saved...',
|
||||
delay: 5000,
|
||||
backgroundColor: '#87eb8f',
|
||||
color: '#000',
|
||||
});
|
||||
Toast.success('User successfully saved...');
|
||||
history.push('/users');
|
||||
} catch (Exception) {
|
||||
console.error(Exception);
|
||||
ctx.showToast({
|
||||
title: 'Error',
|
||||
message: Exception.json.message,
|
||||
delay: 6000,
|
||||
backgroundColor: '#db2828',
|
||||
color: '#fff',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
Toast.error(error.json.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form inverted className="userMutator">
|
||||
<form className="userMutator">
|
||||
<SegmentPart name="Username" helpText="The username used to login to Fredy">
|
||||
<Form.Input
|
||||
<Input
|
||||
type="text"
|
||||
label="Username"
|
||||
maxLength={30}
|
||||
placeholder="Username"
|
||||
autoFocus
|
||||
inverted
|
||||
width={6}
|
||||
defaultValue={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
value={username}
|
||||
onChange={(val) => setUsername(val)}
|
||||
/>
|
||||
</SegmentPart>
|
||||
<Divider margin="1rem" />
|
||||
<SegmentPart name="Password" helpText="The password used to login to Fredy">
|
||||
<Form.Input
|
||||
type="password"
|
||||
<Input
|
||||
mode="password"
|
||||
label="Password"
|
||||
placeholder="Password"
|
||||
inverted
|
||||
width={6}
|
||||
defaultValue={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
value={password}
|
||||
onChange={(val) => setPassword(val)}
|
||||
/>
|
||||
</SegmentPart>
|
||||
<Divider margin="1rem" />
|
||||
<SegmentPart name="Retype password" helpText="Retype the password to make sure they match">
|
||||
<Form.Input
|
||||
type="password"
|
||||
<Input
|
||||
mode="password"
|
||||
label="Retype password"
|
||||
placeholder="Retype password"
|
||||
inverted
|
||||
width={6}
|
||||
defaultValue={password2}
|
||||
onChange={(e) => setPassword2(e.target.value)}
|
||||
value={password2}
|
||||
onChange={(val) => setPassword2(val)}
|
||||
/>
|
||||
</SegmentPart>
|
||||
<SegmentPart name="Admin use" helpText="Check this if the user is an administrator">
|
||||
<Form.Field>
|
||||
<label>Is user an admin?</label>
|
||||
<Switch checked={isAdmin} onChange={(checked) => setIsAdmin(checked)} />
|
||||
</Form.Field>
|
||||
<Divider margin="1rem" />
|
||||
<SegmentPart name="Is user an admin?" helpText="Check this if the user is an administrator">
|
||||
<Switch checked={isAdmin} onChange={(checked) => setIsAdmin(checked)} />
|
||||
</SegmentPart>
|
||||
<Button color="red" onClick={() => history.push('/users')}>
|
||||
<Divider margin="1rem" />
|
||||
<Button type="danger" style={{ marginRight: '1rem' }} onClick={() => history.push('/users')}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="green" onClick={saveUser}>
|
||||
<Button type="primary" icon={<IconPlusCircle />} onClick={saveUser}>
|
||||
Save
|
||||
</Button>
|
||||
</Form>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user