mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbad4456d7 | ||
|
|
deec626feb | ||
|
|
88c6641485 | ||
|
|
f4eedda658 | ||
|
|
d2b80561f8 | ||
|
|
3bda88a075 | ||
|
|
86465e0076 | ||
|
|
d947dad488 | ||
|
|
23ef434fe1 | ||
|
|
5e6d92c5be | ||
|
|
4ba098e0b6 | ||
|
|
2d1a9a0452 | ||
|
|
6fbee3e7c6 | ||
|
|
46775c3662 | ||
|
|
1feb5bfda1 | ||
|
|
3ec9ed3b2a | ||
|
|
75a536d5ab | ||
|
|
f3cded7e5d | ||
|
|
d7c9c4bf76 |
@@ -203,10 +203,6 @@ module.exports = {
|
||||
// https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/self-closing-comp.md
|
||||
'react/self-closing-comp': 'warn',
|
||||
|
||||
// Enforce spaces before the closing bracket of self-closing JSX elements
|
||||
// https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/jsx-space-before-closing.md
|
||||
'react/jsx-space-before-closing': ['warn', 'always'],
|
||||
|
||||
// Enforce component methods order
|
||||
// https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/sort-comp.md
|
||||
'react/sort-comp': 'off',
|
||||
@@ -237,7 +233,7 @@ module.exports = {
|
||||
|
||||
// only .jsx files may have JSX
|
||||
// https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/jsx-filename-extension.md
|
||||
'react/jsx-filename-extension': ['error', { extensions: ['.js'] }],
|
||||
'react/jsx-filename-extension': ['error', { extensions: ['.jsx'] }],
|
||||
|
||||
// prevent accidental JS comments from being injected into JSX as text
|
||||
// https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/jsx-no-comment-textnodes.md
|
||||
@@ -282,15 +278,5 @@ module.exports = {
|
||||
// https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/no-children-prop.md
|
||||
'react/no-children-prop': 'warn',
|
||||
|
||||
// Validate whitespace in and around the JSX opening and closing brackets
|
||||
// https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/jsx-tag-spacing.md
|
||||
'react/jsx-tag-spacing': [
|
||||
'warn',
|
||||
{
|
||||
closingSlash: 'never',
|
||||
beforeSelfClosing: 'always',
|
||||
afterOpening: 'never',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
12
README.md
12
README.md
@@ -27,11 +27,11 @@ yarn run start
|
||||
_Fredy_ will start with the default port, set to `9998`. You can access _Fredy_ by opening your browser at `http://localhost:9998`. The default login is `admin`, both for username and password. You should change the password as soon as possible when you plan to run Fredy on a server.
|
||||
|
||||
<p align="center">
|
||||
<img alt="Job Configuration" src="https://github.com/orangecoding/fredy/blob/master/doc/screenshot__1.png" width="30%">
|
||||
<img alt="Job Configuration" src="https://github.com/orangecoding/fredy/blob/master/doc/screenshot1.png" width="30%">
|
||||
|
||||
<img alt="Job Analytics" src="https://github.com/orangecoding/fredy/blob/master/doc/screenshot2.png" width="30%">
|
||||
<img alt="Job Analytics" src="https://github.com/orangecoding/fredy/blob/master/doc/screenshot_2.png" width="30%">
|
||||
|
||||
<img alt="Job Overview" width="30%" src="https://github.com/orangecoding/fredy/blob/master/doc/screenshot3.png">
|
||||
<img alt="Job Overview" width="30%" src="https://github.com/orangecoding/fredy/blob/master/doc/screenshot_3.png">
|
||||
</p>
|
||||
<p align="center">
|
||||
|
||||
@@ -81,10 +81,10 @@ yarn run test
|
||||
# Architecture
|
||||

|
||||
|
||||
### Immoscout
|
||||
I have added **experimental** support for Immoscout. Immoscout is somewhat special, because they have decided to secure their service from bots using Re-Capture. Finding a way around this is barely possible. For _Fredy_ to be able to bypass this check, I'm using a service called [ScrapingAnt](https://scrapingant.com/). The trick is to use a headless browser, rotating proxies and (once successfully validated) to re-send the cookies each time.
|
||||
### Immoscout / Immonet
|
||||
I have added **experimental** support for Immoscout and Immonet. They both are somewhat special, because they have decided to secure their service from bots using Re-Capture. Finding a way around this is barely possible. For _Fredy_ to be able to bypass this check, I'm using a service called [ScrapingAnt](https://scrapingant.com/). The trick is to use a headless browser, rotating proxies and (once successfully validated) to re-send the cookies each time.
|
||||
|
||||
To be able to use Immoscout, you need to create an account at ScrapingAnt. Configure the API key in the "General Settings" tab (visible when logged in as administrator).
|
||||
To be able to use Immoscout / Immonet, you need to create an account at ScrapingAnt. Configure the API key in the "General Settings" tab (visible when logged in as administrator).
|
||||
The rest will be handled by _Fredy_. Keep in mind, the support is experimental. There might be bugs and you might not always pass the re-capture check, but most of the time it works rather well :)
|
||||
|
||||
If you need more than the 1000 API calls allowed per month, I'd suggest opting for a paid account... ScrapingAnt loves OpenSource, therefore they have decided to give all _Fredy_ users a 10% discount by using the code **FREDY10** (Disclaimer: I do not earn any money for recommending their service).
|
||||
|
||||
BIN
doc/screenshot1.png
Normal file
BIN
doc/screenshot1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 121 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 279 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 380 KiB |
BIN
doc/screenshot_2.png
Normal file
BIN
doc/screenshot_2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 323 KiB |
BIN
doc/screenshot_3.png
Normal file
BIN
doc/screenshot_3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 93 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 202 KiB |
@@ -4,12 +4,11 @@
|
||||
<meta charset="UTF-8"
|
||||
name="viewport"
|
||||
content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/semantic-ui@2/dist/semantic.min.css">
|
||||
<meta name="google" content="notranslate">
|
||||
|
||||
<title>Fredy</title>
|
||||
</head>
|
||||
<body>
|
||||
<body theme-mode="dark">
|
||||
|
||||
<div id="fredy" style="position: absolute;top: 0;left: 0;right: 0;bottom: 0;"></div>
|
||||
</body>
|
||||
|
||||
@@ -45,15 +45,15 @@ class FredyRuntime {
|
||||
_getListings(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = this._providerId;
|
||||
if (scrapingAnt.isImmoscout(id) && !scrapingAnt.isScrapingAntApiKeySet()) {
|
||||
const error = 'Immoscout can only be used with if you have set an apikey for scrapingAnt.';
|
||||
if (scrapingAnt.needScrapingAnt(id) && !scrapingAnt.isScrapingAntApiKeySet()) {
|
||||
const error = 'Immoscout or Immonet can only be used with if you have set an apikey for scrapingAnt.';
|
||||
/* eslint-disable no-console */
|
||||
console.log(error);
|
||||
/* eslint-enable no-console */
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
const u = scrapingAnt.isImmoscout(id) ? scrapingAnt.transformUrlForScrapingAnt(url, id) : url;
|
||||
const u = scrapingAnt.needScrapingAnt(id) ? scrapingAnt.transformUrlForScrapingAnt(url, id) : url;
|
||||
try {
|
||||
if (this._providerConfig.paginate != null) {
|
||||
xray(u, this._providerConfig.crawlContainer, [this._providerConfig.crawlFields])
|
||||
|
||||
@@ -13,7 +13,7 @@ import files from 'serve-static';
|
||||
import path from 'path';
|
||||
import { getDirName } from '../utils.js';
|
||||
const service = restana();
|
||||
const staticService = files(path.join(getDirName(), '../../ui/public'));
|
||||
const staticService = files(path.join(getDirName(), '../ui/public'));
|
||||
const PORT = config.port || 9998;
|
||||
|
||||
service.use(bodyParser.json());
|
||||
|
||||
@@ -28,7 +28,7 @@ jobRouter.get('/processingTimes', async (req, res) => {
|
||||
let scrapingAntData = null;
|
||||
if (config.scrapingAnt.apiKey != null && config.scrapingAnt.apiKey.length > 0) {
|
||||
try {
|
||||
const response = await fetch(`https://api.scrapingant.com/v1/usage?x-api-key=${config.scrapingAnt.apiKey}`);
|
||||
const response = await fetch(`https://api.scrapingant.com/v2/usage?x-api-key=${config.scrapingAnt.apiKey}`);
|
||||
scrapingAntData = await response.json();
|
||||
} catch (Exception) {
|
||||
console.error('Could not query plan data from scraping ant.', Exception);
|
||||
|
||||
@@ -9,7 +9,7 @@ const template = fs.readFileSync(path.resolve(__dirname + '/notification/emailTe
|
||||
const emailTemplate = Handlebars.compile(template);
|
||||
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||
const { apiPublicKey, apiPrivateKey, receiver, from } = notificationConfig.find(
|
||||
(adapter) => adapter.id === 'mailJet'
|
||||
(adapter) => adapter.id === 'mailjet'
|
||||
).fields;
|
||||
const to = receiver
|
||||
.trim()
|
||||
@@ -18,7 +18,7 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
|
||||
Email: r.trim(),
|
||||
}));
|
||||
return mailjet
|
||||
.connect(apiPublicKey, apiPrivateKey)
|
||||
.apiConnect(apiPublicKey, apiPrivateKey)
|
||||
.post('send', { version: 'v3.1' })
|
||||
.request({
|
||||
Messages: [
|
||||
|
||||
51
lib/notification/adapter/ntfy.js
Normal file
51
lib/notification/adapter/ntfy.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import { markdown2Html } from '../../services/markdown.js';
|
||||
import { getJob } from '../../services/storage/jobStorage.js';
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||
const { priority, server, topic } = notificationConfig.find((adapter) => adapter.id === 'ntfy').fields;
|
||||
const job = getJob(jobKey);
|
||||
const jobName = job == null ? jobKey : job.name;
|
||||
const promises = newListings.map((newListing) => {
|
||||
const message = `Address: ${newListing.address} Size: ${newListing.size.replace(/2m/g, '$m^2$')} Price: ${
|
||||
newListing.price
|
||||
}`;
|
||||
return fetch(server, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
topic: topic,
|
||||
message: message,
|
||||
title: newListing.title,
|
||||
tags: [serviceName, jobName],
|
||||
priority: parseInt(priority),
|
||||
click: newListing.link,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
return Promise.all(promises);
|
||||
};
|
||||
export const config = {
|
||||
id: 'ntfy',
|
||||
name: 'ntfy',
|
||||
readme: markdown2Html('lib/notification/adapter/ntfy.md'),
|
||||
description: 'Fredy will send new listings to your ntfy.',
|
||||
fields: {
|
||||
priority: {
|
||||
type: 'number',
|
||||
label: 'Priority',
|
||||
description: 'The priority of the send notification.',
|
||||
},
|
||||
server: {
|
||||
type: 'text',
|
||||
label: 'Server-URL',
|
||||
description: 'The server url to the send the notification to.',
|
||||
},
|
||||
topic: {
|
||||
type: 'text',
|
||||
label: 'topic',
|
||||
description:
|
||||
'The topic where fredy should send notifications to. The topic is a secret, only known to you, make sure it is something not generic.',
|
||||
},
|
||||
},
|
||||
};
|
||||
5
lib/notification/adapter/ntfy.md
Normal file
5
lib/notification/adapter/ntfy.md
Normal file
@@ -0,0 +1,5 @@
|
||||
### ntfy Adapter
|
||||
|
||||
For ntfy, you need to create a topic on your preferred ntfy instance. This is pretty easy. Please visit the steps in the [docs](https://docs.ntfy.sh/publish/) and follow the instructions.
|
||||
|
||||
As a result, you get the URL for configuration in fredy. In addition, the priority must be defined.
|
||||
@@ -1,14 +1,12 @@
|
||||
import utils from '../utils.js';
|
||||
let appliedBlackList = [];
|
||||
function normalize(o) {
|
||||
const id = parseInt(o.id.substring(o.id.indexOf('_') + 1, o.id.length));
|
||||
const id = o.id.substring(o.id.lastIndexOf('/') + 1, o.id.length);
|
||||
const size = o.size != null ? o.size.replace('Wohnfläche ', '') : 'N/A m²';
|
||||
const price = o.price.replace('Kaufpreis ', '');
|
||||
const address = o.address.split(' • ')[o.address.split(' • ').length - 1];
|
||||
const title = o.title || 'No title available';
|
||||
//normally we would just read the link from the source, but immonet decided to trick user by adding a click listener instead of
|
||||
//a href to do some weird reporting. (Very user friendly for handicaped ppl... not)
|
||||
const link = `https://www.immonet.de/angebot/${id}`;
|
||||
const link = o.id;
|
||||
return Object.assign(o, { id, address, price, size, title, link });
|
||||
}
|
||||
function applyBlacklist(o) {
|
||||
@@ -18,14 +16,14 @@ function applyBlacklist(o) {
|
||||
}
|
||||
const config = {
|
||||
url: null,
|
||||
crawlContainer: '#result-list-stage .item',
|
||||
crawlContainer: '.content-wrapper-tiles .ng-star-inserted',
|
||||
sortByDateParam: 'sortby=19',
|
||||
crawlFields: {
|
||||
id: '@id',
|
||||
price: 'div[id*="selPrice_"] | trim',
|
||||
size: 'div[id*="selArea_"] | trim',
|
||||
title: '.item a img@title',
|
||||
address: '.item .box-25 .ellipsis .text-100 | removeNewline | trim',
|
||||
id: '.card a@href',
|
||||
title: '.card h3 |trim',
|
||||
price: '.card .has-font-300 .is-bold | trim',
|
||||
size: '.card .has-font-300 .ml-100 | trim',
|
||||
address: '.card span:nth-child(2) | trim',
|
||||
},
|
||||
paginate: '#idResultList .margin-bottom-6.margin-bottom-sm-12 .panel a.pull-right@href',
|
||||
normalize: normalize,
|
||||
|
||||
@@ -32,7 +32,7 @@ const config = {
|
||||
};
|
||||
export const metaInformation = {
|
||||
name: 'Ebay Kleinanzeigen',
|
||||
baseUrl: 'https://www.ebay-kleinanzeigen.de/',
|
||||
baseUrl: 'https://www.kleinanzeigen.de/',
|
||||
id: 'kleinanzeigen',
|
||||
};
|
||||
export const init = (sourceConfig, blacklist, blacklistedDistricts) => {
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import fetch from 'node-fetch';
|
||||
import { config } from '../utils.js';
|
||||
import { makeUrlResidential } from './scrapingAnt.js';
|
||||
import https from 'https';
|
||||
//if ScrapingAnt got blocked, this http status is returned
|
||||
const BLOCKED_HTTP_STATUS = 423;
|
||||
const NOT_FOUND_HTTP_STATUS = 404;
|
||||
const MAX_RETRIES_SCRAPING_ANT = 10;
|
||||
const EXPECTED_STATUS_CODES = [BLOCKED_HTTP_STATUS, NOT_FOUND_HTTP_STATUS];
|
||||
const agent = new https.Agent({
|
||||
rejectUnauthorized: false,
|
||||
});
|
||||
|
||||
function makeDriver(headers = {}) {
|
||||
let cookies = '';
|
||||
async function scrapingAntDriver(context, callback, retryCounter = 0) {
|
||||
@@ -41,9 +46,10 @@ function makeDriver(headers = {}) {
|
||||
/* eslint-enable no-console */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The regular request driver is taking care of everyting, that doesn't need to be scraped by ScrapingAnt (which is
|
||||
* everything != Immoscout as of writing this)
|
||||
* everything != Immoscout & Immonet as of writing this)
|
||||
*/
|
||||
return async function driver(context, callback) {
|
||||
if (context.url.toLowerCase().indexOf('scrapingant') !== -1) {
|
||||
@@ -55,6 +61,7 @@ function makeDriver(headers = {}) {
|
||||
...headers,
|
||||
Cookie: cookies,
|
||||
},
|
||||
agent,
|
||||
});
|
||||
const result = await response.text();
|
||||
callback(null, result);
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
import { metaInformation } from '../provider/immoscout.js';
|
||||
import { metaInformation as immoScoutInfo } from '../provider/immoscout.js';
|
||||
import { metaInformation as immoNetInfo } from '../provider/immonet.js';
|
||||
import { config } from '../utils.js';
|
||||
const isImmoscout = (id) => {
|
||||
return id.toLowerCase() === metaInformation.id;
|
||||
|
||||
const additionalImmonetUrlParams = `&wait_for_selector=.content-wrapper-tiles&js_snippet=${Buffer.from(
|
||||
'window.scrollTo(0,document.body.scrollHeight);'
|
||||
).toString('base64')}`;
|
||||
|
||||
const needScrapingAnt = (id) => {
|
||||
return id.toLowerCase() === immoScoutInfo.id || id.toLowerCase() === immoNetInfo.id;
|
||||
};
|
||||
export const transformUrlForScrapingAnt = (url, id) => {
|
||||
if (isImmoscout(id)) {
|
||||
//only do calls to scrapingAnt when dealing with Immoscout
|
||||
url = `https://api.scrapingant.com/v1/general?url=${encodeURIComponent(url)}&proxy_type=datacenter`;
|
||||
let urlParams = '';
|
||||
if (needScrapingAnt(id)) {
|
||||
if (id.toLowerCase() === immoNetInfo.id) {
|
||||
urlParams = additionalImmonetUrlParams;
|
||||
}
|
||||
//only do calls to scrapingAnt when dealing with Immoscout/Immonet
|
||||
url = `https://api.scrapingant.com/v2/general?url=${encodeURIComponent(url)}&proxy_type=datacenter${urlParams}`;
|
||||
}
|
||||
return url;
|
||||
};
|
||||
@@ -16,4 +26,4 @@ export const isScrapingAntApiKeySet = () => {
|
||||
export const makeUrlResidential = (url) => {
|
||||
return url.replace('datacenter', 'residential');
|
||||
};
|
||||
export { isImmoscout };
|
||||
export { needScrapingAnt };
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import lodash from 'lodash';
|
||||
import { LowSync } from 'lowdb';
|
||||
export default class LowdashAdapter extends LowSync {
|
||||
constructor(adapter) {
|
||||
super(adapter);
|
||||
constructor(adapter, defaultData = {}) {
|
||||
super(adapter, defaultData);
|
||||
this.chain = lodash.chain(this).get('data');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,11 +7,10 @@ import LowdashAdapter from './LowDashAdapter.js';
|
||||
|
||||
const file = path.join(getDirName(), '../', 'db/jobs.json');
|
||||
const adapter = new JSONFileSync(file);
|
||||
const db = new LowdashAdapter(adapter);
|
||||
const db = new LowdashAdapter(adapter, { jobs: [] });
|
||||
|
||||
db.read();
|
||||
|
||||
db.data ||= { jobs: [] };
|
||||
|
||||
export const upsertJob = ({ jobId, name, blacklist = [], enabled = true, provider, notificationAdapter, userId }) => {
|
||||
const currentJob =
|
||||
|
||||
@@ -5,12 +5,10 @@ import LowdashAdapter from './LowDashAdapter.js';
|
||||
|
||||
const file = path.join(getDirName(), '../', 'db/jobListingData.json');
|
||||
const adapter = new JSONFileSync(file);
|
||||
const db = new LowdashAdapter(adapter);
|
||||
const db = new LowdashAdapter(adapter, {});
|
||||
|
||||
db.read();
|
||||
|
||||
db.data ||= {};
|
||||
|
||||
const buildKey = (jobKey, providerId, endpoint) => {
|
||||
let key = `${jobKey}`;
|
||||
if (jobKey == null && endpoint == null) {
|
||||
|
||||
@@ -6,24 +6,24 @@ import * as jobStorage from './jobStorage.js';
|
||||
import path from 'path';
|
||||
import LowdashAdapter from './LowDashAdapter.js';
|
||||
|
||||
const defaultData = {
|
||||
user: [
|
||||
//you probably want to change the default password ;)
|
||||
{
|
||||
id: nanoid(),
|
||||
lastLogin: Date.now(),
|
||||
username: 'admin',
|
||||
password: hasher.hash('admin'),
|
||||
isAdmin: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const file = path.join(getDirName(), '../', 'db/users.json');
|
||||
const adapter = new JSONFileSync(file);
|
||||
const db = new LowdashAdapter(adapter);
|
||||
const db = new LowdashAdapter(adapter, defaultData);
|
||||
|
||||
db.read();
|
||||
db.data ||= {
|
||||
user: [
|
||||
//you probably want to change the default password ;)
|
||||
{
|
||||
id: nanoid(),
|
||||
lastLogin: Date.now(),
|
||||
username: 'admin',
|
||||
password: hasher.hash('admin'),
|
||||
isAdmin: true,
|
||||
isDemo: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const getUsers = (withPassword) => {
|
||||
const jobs = jobStorage.getJobs();
|
||||
|
||||
57
package.json
57
package.json
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"name": "fredy",
|
||||
"version": "7.0.0",
|
||||
"version": "7.3.2",
|
||||
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"dev": "yarn && rm -rf ./ui/public/* && vite",
|
||||
"ui": "rm -rf ./ui/public/* && vite",
|
||||
"prod": "yarn && vite build --emptyOutDir",
|
||||
"format": "prettier --write lib/**/*.js ui/src/**/*.js test/**/*.js *.js --single-quote --print-width 120",
|
||||
"format": "prettier --write lib/**/*.js ui/src/**/*.jsx test/**/*.js *.js --single-quote --print-width 120",
|
||||
"test": "mocha --loader=esmock --timeout 3000000 test/**/*.test.js",
|
||||
"lint": "eslint ./index.js ./lib/**/*.js ./test/**/*.js"
|
||||
"lint": "eslint ./index.js ./lib/**/*.js ./test/**/*.js ./ui/src/**/*.jsx"
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
@@ -55,55 +55,54 @@
|
||||
"Firefox ESR"
|
||||
],
|
||||
"dependencies": {
|
||||
"@douyinfe/semi-ui": "2.42.4",
|
||||
"@rematch/core": "2.2.0",
|
||||
"@rematch/loading": "2.1.2",
|
||||
"@sendgrid/mail": "7.7.0",
|
||||
"@vitejs/plugin-react": "3.1.0",
|
||||
"better-sqlite3": "8.2.0",
|
||||
"@vitejs/plugin-react": "4.0.4",
|
||||
"better-sqlite3": "8.6.0",
|
||||
"body-parser": "1.20.2",
|
||||
"cookie-session": "2.0.0",
|
||||
"handlebars": "4.7.7",
|
||||
"highcharts": "10.3.3",
|
||||
"highcharts-react-official": "3.2.0",
|
||||
"lodash": "^4.17.21",
|
||||
"lowdb": "5.1.0",
|
||||
"handlebars": "4.7.8",
|
||||
"highcharts": "11.1.0",
|
||||
"highcharts-react-official": "3.2.1",
|
||||
"lodash": "4.17.21",
|
||||
"lowdb": "6.0.1",
|
||||
"markdown": "^0.5.0",
|
||||
"nanoid": "4.0.1",
|
||||
"node-fetch": "3.3.1",
|
||||
"node-mailjet": "6.0.2",
|
||||
"nanoid": "4.0.2",
|
||||
"node-fetch": "3.3.2",
|
||||
"node-mailjet": "6.0.4",
|
||||
"query-string": "8.1.0",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-redux": "8.0.5",
|
||||
"react-redux": "8.1.2",
|
||||
"react-router": "5.2.1",
|
||||
"react-router-dom": "5.3.0",
|
||||
"react-switch": "7.0.0",
|
||||
"redux": "4.2.1",
|
||||
"redux-thunk": "2.4.2",
|
||||
"restana": "4.9.7",
|
||||
"semantic-ui-react": "2.1.4",
|
||||
"serve-static": "1.15.0",
|
||||
"slack": "11.0.2",
|
||||
"string-similarity": "^4.0.4",
|
||||
"vite": "4.1.4",
|
||||
"vite": "4.4.9",
|
||||
"x-ray": "2.3.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esmock": "2.1.0",
|
||||
"@babel/core": "7.21.0",
|
||||
"@babel/eslint-parser": "7.19.1",
|
||||
"@babel/preset-env": "7.20.2",
|
||||
"@babel/preset-react": "7.18.6",
|
||||
"chai": "4.3.7",
|
||||
"eslint": "8.36.0",
|
||||
"eslint-config-prettier": "8.7.0",
|
||||
"eslint-plugin-react": "7.32.2",
|
||||
"@babel/core": "7.22.15",
|
||||
"@babel/eslint-parser": "7.22.15",
|
||||
"@babel/preset-env": "7.22.15",
|
||||
"@babel/preset-react": "7.22.15",
|
||||
"chai": "4.3.8",
|
||||
"eslint": "8.48.0",
|
||||
"eslint-config-prettier": "8.8.0",
|
||||
"eslint-plugin-react": "7.33.2",
|
||||
"esmock": "2.4.0",
|
||||
"history": "5.3.0",
|
||||
"husky": "4.3.8",
|
||||
"less": "4.1.3",
|
||||
"lint-staged": "13.2.0",
|
||||
"less": "4.2.0",
|
||||
"lint-staged": "13.2.2",
|
||||
"mocha": "10.2.0",
|
||||
"prettier": "2.8.4",
|
||||
"prettier": "2.8.8",
|
||||
"redux-logger": "3.0.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { get } from '../mocks/mockNotification.js';
|
||||
import { mockFredy, providerConfig } from '../utils.js';
|
||||
import chai from 'chai';
|
||||
import * as provider from '../../lib/provider/immonet.js';
|
||||
import * as scrapingAnt from '../../lib/services/scrapingAnt.js';
|
||||
const expect = chai.expect;
|
||||
describe('#immonet testsuite()', () => {
|
||||
after(() => {
|
||||
@@ -12,6 +13,13 @@ describe('#immonet testsuite()', () => {
|
||||
it('should test immonet provider', async () => {
|
||||
const Fredy = await mockFredy();
|
||||
return await new Promise((resolve) => {
|
||||
if (!scrapingAnt.isScrapingAntApiKeySet()) {
|
||||
/* eslint-disable no-console */
|
||||
console.info('Skipping Immonet test as ScrapingAnt Api Key is not set.');
|
||||
/* eslint-enable no-console */
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'immonet', similarityCache);
|
||||
fredy.execute().then((listing) => {
|
||||
expect(listing).to.be.a('array');
|
||||
@@ -20,17 +28,17 @@ describe('#immonet testsuite()', () => {
|
||||
expect(notificationObj.serviceName).to.equal('immonet');
|
||||
notificationObj.payload.forEach((notify) => {
|
||||
/** check the actual structure **/
|
||||
expect(notify.id).to.be.a('number');
|
||||
expect(notify.id).to.be.a('string');
|
||||
expect(notify.price).to.be.a('string');
|
||||
expect(notify.size).to.be.a('string');
|
||||
expect(notify.title).to.be.a('string');
|
||||
expect(notify.link).to.be.a('string');
|
||||
expect(notify.address).to.be.a('string');
|
||||
|
||||
/** check the values if possible **/
|
||||
expect(notify.price).that.does.include('€');
|
||||
expect(notify.size).that.does.include('m²');
|
||||
expect(notify.title).to.be.not.empty;
|
||||
expect(notify.link).that.does.include('https://www.immonet.de');
|
||||
expect(notify.address).to.be.not.empty;
|
||||
});
|
||||
resolve();
|
||||
|
||||
@@ -26,7 +26,7 @@ describe('#kleinanzeigen testsuite()', () => {
|
||||
expect(notify.address).to.be.a('string');
|
||||
/** check the values if possible **/
|
||||
expect(notify.title).to.be.not.empty;
|
||||
expect(notify.link).that.does.include('https://www.ebay-kleinanzeigen.de');
|
||||
expect(notify.link).that.does.include('https://www.kleinanzeigen.de');
|
||||
expect(notify.address).to.be.not.empty;
|
||||
});
|
||||
resolve();
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"enabled": true
|
||||
},
|
||||
"immonet": {
|
||||
"url": "https://www.immonet.de/immobiliensuche/sel.do?pageoffset=1&listsize=100&objecttype=1&locationname=Düsseldorf&acid=&actype=&district=8717&district=8718&district=8719&district=8720&district=8721&district=8723&district=8724&district=8725&district=8727&district=8728&district=8729&district=8730&district=8731&district=8732&district=8733&district=8737&district=8738&district=8741&district=8745&district=8747&district=8750&district=8752&district=8754&district=8755&district=8756&district=8759&district=8760&district=8761&district=8763&district=8764&district=8765&ajaxIsRadiusActive=false&sortby=19&suchart=1&radius=0&pcatmtypes=1_1&pCatMTypeStoragefield=&parentcat=1&marketingtype=1&fromprice=&toprice=420000&fromarea=90&toarea=&fromplotarea=&toplotarea=&fromrooms=3&torooms=&objectcat=225&objectcat=18&objectcat=17&objectcat=12&objectcat=16&objectcat=181&objectcat=14&objectcat=15&objectcat=226&objectcat=13&wbs=-1&fromyear=&toyear=",
|
||||
"url": "https://www.immonet.de/immobiliensuche/beta?pageoffset=1&listsize=100&objecttype=1&locationname=D%C3%BCsseldorf&acid=&actype=&district=8717&district=8718&district=8719&district=8720&district=8721&district=8723&district=8724&district=8725&district=8727&district=8728&district=8729&district=8730&district=8731&district=8732&district=8733&district=8737&district=8738&district=8741&district=8745&district=8747&district=8750&district=8752&district=8754&district=8755&district=8756&district=8759&district=8760&district=8761&district=8763&district=8764&district=8765&ajaxIsRadiusActive=false&sortby=19&suchart=1&radius=0&pcatmtypes=1_1&pCatMTypeStoragefield=&parentcat=1&marketingtype=1&fromprice=&toprice=420000&fromarea=90&toarea=&fromplotarea=&toplotarea=&fromrooms=3&torooms=&objectcat=225&objectcat=18&objectcat=17&objectcat=12&objectcat=16&objectcat=181&objectcat=14&objectcat=15&objectcat=226&objectcat=13&wbs=-1&fromyear=&toyear=",
|
||||
"enabled": true
|
||||
},
|
||||
"immowelt": {
|
||||
@@ -29,7 +29,7 @@
|
||||
"enabled": true
|
||||
},
|
||||
"kleinanzeigen": {
|
||||
"url": "https://www.ebay-kleinanzeigen.de/s-immobilien/duesseldorf/anzeige:angebote/wohnung/k0c195l2068r5",
|
||||
"url": "https://www.kleinanzeigen.de/s-immobilien/duesseldorf/anzeige:angebote/wohnung/k0c195l2068r5",
|
||||
"enabled": true
|
||||
},
|
||||
"neubauKompass": {
|
||||
|
||||
@@ -23,7 +23,6 @@ describe('#wgGesucht testsuite()', () => {
|
||||
expect(notify.id).to.be.a('string');
|
||||
expect(notify.title).to.be.a('string');
|
||||
expect(notify.details).to.be.a('string');
|
||||
expect(notify.size).to.be.a('string');
|
||||
expect(notify.price).to.be.a('string');
|
||||
expect(notify.link).to.be.a('string');
|
||||
});
|
||||
|
||||
@@ -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 or Immonet 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';
|
||||
|
||||
@@ -26,9 +25,12 @@ const validate = (selectedAdapter) => {
|
||||
results.push('All fields are mandatory and must be set.');
|
||||
continue;
|
||||
}
|
||||
if (uiElement.type === 'number' && (typeof uiElement.value !== 'number' || uiElement.value < 0)) {
|
||||
results.push('A number field cannot contain anything else and must be > 0.');
|
||||
continue;
|
||||
if (uiElement.type === 'number') {
|
||||
const numberValue = parseFloat(uiElement.value);
|
||||
if(isNaN(numberValue) || numberValue < 0) {
|
||||
results.push('A number field cannot contain anything else and must be > 0.');
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (uiElement.type === 'boolean' && typeof uiElement.value !== 'boolean') {
|
||||
results.push('A boolean field cannot be of a different type.');
|
||||
@@ -138,8 +140,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 +149,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' }}>
|
||||
If you chose Immoscout as a provider, make sure to also add the scrapingAnt apiKey to the config.json.
|
||||
<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 or Immonet 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