mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0663bd945f | ||
|
|
bc355fb5fe | ||
|
|
797421f0d5 | ||
|
|
0b2b42fc75 |
94
CHANGELOG.md
94
CHANGELOG.md
@@ -1,94 +0,0 @@
|
|||||||
Newer release changelog see https://github.com/orangecoding/fredy/releases
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
###### [V5.5.0]
|
|
||||||
|
|
||||||
- Upgrading dependencies
|
|
||||||
- fixing provider
|
|
||||||
- allow multiple instances of 1 provider
|
|
||||||
- **BREAKING**: Minimum node version is now 16
|
|
||||||
|
|
||||||
###### [V5.4.6]
|
|
||||||
|
|
||||||
- Adding Instana node.js monitoring
|
|
||||||
-
|
|
||||||
|
|
||||||
###### [V5.4.5]
|
|
||||||
|
|
||||||
- Adding Instana node.js monitoring
|
|
||||||
|
|
||||||
###### [V5.4.4]
|
|
||||||
|
|
||||||
- Add support for Immo Südwest Presse (immo.swp.de)
|
|
||||||
- Telegram: Use job name instead of ID and link in title
|
|
||||||
- Fix race condition if user ID is in session but not in user store
|
|
||||||
- Allow visiting the original provider URL
|
|
||||||
|
|
||||||
###### [V5.4.3]
|
|
||||||
|
|
||||||
- re-writing readme
|
|
||||||
- improving docker build
|
|
||||||
- using github's actions to build docker and test automatically
|
|
||||||
|
|
||||||
###### [V5.4.2]
|
|
||||||
|
|
||||||
- Fixing prod build
|
|
||||||
|
|
||||||
###### [V5.4.1]
|
|
||||||
|
|
||||||
- Upgrading dependencies
|
|
||||||
- Provider urls are now automagically been changed to include the correct sort order for search results
|
|
||||||
|
|
||||||
```
|
|
||||||
Note: It has been an point of confusion since the very beginning of Fredy, that people simply copied the url, but
|
|
||||||
did not take care of sorting the search results by date. If this is not done, Fredy will most likely not see the latest
|
|
||||||
results, thus cannot report them. This release fixes it by adding the necessary params (or replaces them).
|
|
||||||
```
|
|
||||||
|
|
||||||
###### [V5.3.0]
|
|
||||||
|
|
||||||
- Upgrading dependencies
|
|
||||||
- It's now possible to send mails to multiple receiver using comma separation for MailJet & Sendgrid
|
|
||||||
- Fixing Immowelt scraping
|
|
||||||
|
|
||||||
###### [V5.2.0]
|
|
||||||
|
|
||||||
- Upgrading dependencies
|
|
||||||
- Adding new similarity check layer (Duplicates are being removed now)
|
|
||||||
- Adding paging for search results
|
|
||||||
|
|
||||||
###### [V5.1.0]
|
|
||||||
|
|
||||||
- Upgrading dependencies
|
|
||||||
- NodeJS 12.13 is now the minimum supported version
|
|
||||||
- Adding general settings as new configuration page to ui
|
|
||||||
- Adding new feature working hours
|
|
||||||
|
|
||||||
###### [V5.0.0]
|
|
||||||
|
|
||||||
- Upgrading dependencies
|
|
||||||
- NodeJS 12 is now the minimum supported version
|
|
||||||
|
|
||||||
###### [V4.0.0]
|
|
||||||
|
|
||||||
Bringing back Immoscout :tada:
|
|
||||||
|
|
||||||
###### [V3.0.0]
|
|
||||||
|
|
||||||
This is basically a re-write, your old config file will not be compatible anymore. Please re-created your search jobs
|
|
||||||
on the new ui and use the values from your previous config file if needed.
|
|
||||||
|
|
||||||
```
|
|
||||||
- We're getting rid of manual config changes, Fredy, now ships with a UI so that it's easy for you to create and edit jobs
|
|
||||||
```
|
|
||||||
|
|
||||||
###### [V2.0.0]
|
|
||||||
|
|
||||||
```
|
|
||||||
- Fredy can now run multiple search job on one instance
|
|
||||||
- Changed lot's of the structure of Fredy to make this happen
|
|
||||||
[BREAKING CHANGES]
|
|
||||||
- The config has been changed, the config of V1.x will not work any longer
|
|
||||||
- Sources have been renamed to provider
|
|
||||||
```
|
|
||||||
2
index.js
2
index.js
@@ -8,7 +8,6 @@ import { checkIfConfigIsAccessible, getProviders, refreshConfig } from './lib/ut
|
|||||||
import * as similarityCache from './lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from './lib/services/similarity-check/similarityCache.js';
|
||||||
import { runMigrations } from './lib/services/storage/migrations/migrate.js';
|
import { runMigrations } from './lib/services/storage/migrations/migrate.js';
|
||||||
import { ensureDemoUserExists, ensureAdminUserExists } from './lib/services/storage/userStorage.js';
|
import { ensureDemoUserExists, ensureAdminUserExists } from './lib/services/storage/userStorage.js';
|
||||||
import { cleanupDemoAtMidnight } from './lib/services/crons/demoCleanup-cron.js';
|
|
||||||
import { initTrackerCron } from './lib/services/crons/tracker-cron.js';
|
import { initTrackerCron } from './lib/services/crons/tracker-cron.js';
|
||||||
import logger from './lib/services/logger.js';
|
import logger from './lib/services/logger.js';
|
||||||
import { initActiveCheckerCron } from './lib/services/crons/listing-alive-cron.js';
|
import { initActiveCheckerCron } from './lib/services/crons/listing-alive-cron.js';
|
||||||
@@ -54,7 +53,6 @@ await import('./lib/api/api.js');
|
|||||||
|
|
||||||
if (settings.demoMode) {
|
if (settings.demoMode) {
|
||||||
logger.info('Running in demo mode');
|
logger.info('Running in demo mode');
|
||||||
cleanupDemoAtMidnight();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ensureAdminUserExists();
|
ensureAdminUserExists();
|
||||||
|
|||||||
@@ -11,10 +11,13 @@ import logger from '../../services/logger.js';
|
|||||||
import { bus } from '../../services/events/event-bus.js';
|
import { bus } from '../../services/events/event-bus.js';
|
||||||
import { isRunning as isJobRunning } from '../../services/jobs/run-state.js';
|
import { isRunning as isJobRunning } from '../../services/jobs/run-state.js';
|
||||||
import { addClient as addSseClient, removeClient } from '../../services/sse/sse-broker.js';
|
import { addClient as addSseClient, removeClient } from '../../services/sse/sse-broker.js';
|
||||||
|
import { getSettings } from '../../services/storage/settingsStorage.js';
|
||||||
|
|
||||||
const service = restana();
|
const service = restana();
|
||||||
const jobRouter = service.newRouter();
|
const jobRouter = service.newRouter();
|
||||||
|
|
||||||
|
const DEMO_JOB_NAME = 'Demo-Job';
|
||||||
|
|
||||||
function doesJobBelongsToUser(job, req) {
|
function doesJobBelongsToUser(job, req) {
|
||||||
const userId = req.session.currentUser;
|
const userId = req.session.currentUser;
|
||||||
if (userId == null) {
|
if (userId == null) {
|
||||||
@@ -161,6 +164,7 @@ jobRouter.post('/:jobId/run', async (req, res) => {
|
|||||||
|
|
||||||
jobRouter.post('/', async (req, res) => {
|
jobRouter.post('/', async (req, res) => {
|
||||||
const { provider, notificationAdapter, name, blacklist = [], jobId, enabled, shareWithUsers = [] } = req.body;
|
const { provider, notificationAdapter, name, blacklist = [], jobId, enabled, shareWithUsers = [] } = req.body;
|
||||||
|
const settings = await getSettings();
|
||||||
try {
|
try {
|
||||||
let jobFromDb = jobStorage.getJob(jobId);
|
let jobFromDb = jobStorage.getJob(jobId);
|
||||||
|
|
||||||
@@ -169,6 +173,11 @@ jobRouter.post('/', async (req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (settings.demoMode && jobFromDb && jobFromDb.name === DEMO_JOB_NAME) {
|
||||||
|
res.send(new Error('Sorry, but you cannot change the Status of our Demo Job ;)'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
jobStorage.upsertJob({
|
jobStorage.upsertJob({
|
||||||
userId: req.session.currentUser,
|
userId: req.session.currentUser,
|
||||||
jobId,
|
jobId,
|
||||||
@@ -188,8 +197,14 @@ jobRouter.post('/', async (req, res) => {
|
|||||||
|
|
||||||
jobRouter.delete('', async (req, res) => {
|
jobRouter.delete('', async (req, res) => {
|
||||||
const { jobId } = req.body;
|
const { jobId } = req.body;
|
||||||
|
const settings = await getSettings();
|
||||||
try {
|
try {
|
||||||
const job = jobStorage.getJob(jobId);
|
const job = jobStorage.getJob(jobId);
|
||||||
|
if (settings.demoMode && job.name === DEMO_JOB_NAME) {
|
||||||
|
res.send(new Error('Sorry, but you cannot remove the Demo Job ;)'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!doesJobBelongsToUser(job, req)) {
|
if (!doesJobBelongsToUser(job, req)) {
|
||||||
res.send(new Error('You are trying to remove a job that is not associated to your user'));
|
res.send(new Error('You are trying to remove a job that is not associated to your user'));
|
||||||
} else {
|
} else {
|
||||||
@@ -204,8 +219,15 @@ jobRouter.delete('', async (req, res) => {
|
|||||||
jobRouter.put('/:jobId/status', async (req, res) => {
|
jobRouter.put('/:jobId/status', async (req, res) => {
|
||||||
const { status } = req.body;
|
const { status } = req.body;
|
||||||
const { jobId } = req.params;
|
const { jobId } = req.params;
|
||||||
|
const settings = await getSettings();
|
||||||
try {
|
try {
|
||||||
const job = jobStorage.getJob(jobId);
|
const job = jobStorage.getJob(jobId);
|
||||||
|
|
||||||
|
if (settings.demoMode && job.name === DEMO_JOB_NAME) {
|
||||||
|
res.send(new Error('Sorry, but you cannot change the Status of our Demo Job ;)'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!doesJobBelongsToUser(job, req)) {
|
if (!doesJobBelongsToUser(job, req)) {
|
||||||
res.send(new Error('You are trying change a job that is not associated to your user'));
|
res.send(new Error('You are trying change a job that is not associated to your user'));
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { isAdmin as isAdminFn } from '../security.js';
|
|||||||
import logger from '../../services/logger.js';
|
import logger from '../../services/logger.js';
|
||||||
import { nullOrEmpty } from '../../utils.js';
|
import { nullOrEmpty } from '../../utils.js';
|
||||||
import { getJobs } from '../../services/storage/jobStorage.js';
|
import { getJobs } from '../../services/storage/jobStorage.js';
|
||||||
|
import { getSettings } from '../../services/storage/settingsStorage.js';
|
||||||
|
|
||||||
const service = restana();
|
const service = restana();
|
||||||
|
|
||||||
@@ -107,7 +108,13 @@ listingsRouter.post('/watch', async (req, res) => {
|
|||||||
|
|
||||||
listingsRouter.delete('/job', async (req, res) => {
|
listingsRouter.delete('/job', async (req, res) => {
|
||||||
const { jobId } = req.body;
|
const { jobId } = req.body;
|
||||||
|
const settings = await getSettings();
|
||||||
try {
|
try {
|
||||||
|
if (settings.demoMode) {
|
||||||
|
res.send(new Error('Sorry, but you cannot remove listings in demo mode ;)'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
listingStorage.deleteListingsByJobId(jobId);
|
listingStorage.deleteListingsByJobId(jobId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.send(new Error(error));
|
res.send(new Error(error));
|
||||||
|
|||||||
@@ -5,14 +5,15 @@
|
|||||||
|
|
||||||
import restana from 'restana';
|
import restana from 'restana';
|
||||||
import SqliteConnection from '../../services/storage/SqliteConnection.js';
|
import SqliteConnection from '../../services/storage/SqliteConnection.js';
|
||||||
import { upsertSettings } from '../../services/storage/settingsStorage.js';
|
import { getSettings, upsertSettings } from '../../services/storage/settingsStorage.js';
|
||||||
|
import { resetGeocoordinatesAndDistanceForUser } from '../../services/storage/listingsStorage.js';
|
||||||
import { geocodeAddress } from '../../services/geocoding/geoCodingService.js';
|
import { geocodeAddress } from '../../services/geocoding/geoCodingService.js';
|
||||||
import { autocompleteAddress } from '../../services/geocoding/autocompleteService.js';
|
import { autocompleteAddress } from '../../services/geocoding/autocompleteService.js';
|
||||||
import { calculateDistanceForUser } from '../../services/geocoding/distanceService.js';
|
|
||||||
import { fromJson } from '../../utils.js';
|
import { fromJson } from '../../utils.js';
|
||||||
import { trackFeature } from '../../services/tracking/Tracker.js';
|
import { trackFeature } from '../../services/tracking/Tracker.js';
|
||||||
import { FEATURES } from '../../features.js';
|
import { FEATURES } from '../../features.js';
|
||||||
import logger from '../../services/logger.js';
|
import logger from '../../services/logger.js';
|
||||||
|
import { runGeoCordTask } from '../../services/crons/geocoding-cron.js';
|
||||||
|
|
||||||
const service = restana();
|
const service = restana();
|
||||||
const userSettingsRouter = service.newRouter();
|
const userSettingsRouter = service.newRouter();
|
||||||
@@ -43,6 +44,12 @@ userSettingsRouter.get('/autocomplete', async (req, res) => {
|
|||||||
userSettingsRouter.post('/home-address', async (req, res) => {
|
userSettingsRouter.post('/home-address', async (req, res) => {
|
||||||
const userId = req.session.currentUser;
|
const userId = req.session.currentUser;
|
||||||
const { home_address } = req.body;
|
const { home_address } = req.body;
|
||||||
|
const settings = await getSettings();
|
||||||
|
|
||||||
|
if (settings.demoMode) {
|
||||||
|
res.send(new Error('In demo mode, it is not allowed to change the home address.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (home_address) {
|
if (home_address) {
|
||||||
@@ -50,7 +57,9 @@ userSettingsRouter.post('/home-address', async (req, res) => {
|
|||||||
const coords = await geocodeAddress(home_address);
|
const coords = await geocodeAddress(home_address);
|
||||||
if (coords && coords.lat !== -1) {
|
if (coords && coords.lat !== -1) {
|
||||||
upsertSettings({ home_address: { address: home_address, coords } }, userId);
|
upsertSettings({ home_address: { address: home_address, coords } }, userId);
|
||||||
calculateDistanceForUser(userId);
|
resetGeocoordinatesAndDistanceForUser(userId);
|
||||||
|
//we do NOT wait for this to finish, as we don't want to block the response
|
||||||
|
runGeoCordTask();
|
||||||
res.send({ success: true, coords });
|
res.send({ success: true, coords });
|
||||||
} else {
|
} else {
|
||||||
res.statusCode = 400;
|
res.statusCode = 400;
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (c) 2026 by Christian Kellner.
|
|
||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { removeJobsByUserId } from '../storage/jobStorage.js';
|
|
||||||
import { getUsers } from '../storage/userStorage.js';
|
|
||||||
import logger from '../logger.js';
|
|
||||||
import cron from 'node-cron';
|
|
||||||
import { getSettings } from '../storage/settingsStorage.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* if we are running in demo environment, we have to cleanup the db files (specifically the jobs table)
|
|
||||||
*/
|
|
||||||
export function cleanupDemoAtMidnight() {
|
|
||||||
cron.schedule('0 0 * * *', cleanup);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function cleanup() {
|
|
||||||
const settings = await getSettings();
|
|
||||||
if (settings.demoMode) {
|
|
||||||
const demoUser = getUsers(false).find((user) => user.username === 'demo');
|
|
||||||
if (demoUser == null) {
|
|
||||||
logger.error('Demo user not found, cannot remove Jobs');
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
removeJobsByUserId(demoUser.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,8 +8,10 @@ import { getListingsToGeocode, updateListingGeocoordinates } from '../storage/li
|
|||||||
import { geocodeAddress, isGeocodingPaused } from '../geocoding/geoCodingService.js';
|
import { geocodeAddress, isGeocodingPaused } from '../geocoding/geoCodingService.js';
|
||||||
import { getJobs } from '../storage/jobStorage.js';
|
import { getJobs } from '../storage/jobStorage.js';
|
||||||
import { calculateDistanceForJob } from '../geocoding/distanceService.js';
|
import { calculateDistanceForJob } from '../geocoding/distanceService.js';
|
||||||
|
import { getSettings } from '../storage/settingsStorage.js';
|
||||||
|
import logger from '../logger.js';
|
||||||
|
|
||||||
async function runTask() {
|
export async function runGeoCordTask() {
|
||||||
const listings = getListingsToGeocode();
|
const listings = getListingsToGeocode();
|
||||||
if (listings.length > 0) {
|
if (listings.length > 0) {
|
||||||
for (const listing of listings) {
|
for (const listing of listings) {
|
||||||
@@ -32,8 +34,13 @@ async function runTask() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function initGeocodingCron() {
|
export async function initGeocodingCron() {
|
||||||
|
const settings = await getSettings();
|
||||||
|
if (settings.demoMode) {
|
||||||
|
logger.info('Do not start geo service as we are in demo mode');
|
||||||
|
return;
|
||||||
|
}
|
||||||
// run directly on start
|
// run directly on start
|
||||||
await runTask();
|
await runGeoCordTask();
|
||||||
// then every 6 hours
|
// then every 6 hours
|
||||||
cron.schedule('0 */6 * * *', runTask);
|
cron.schedule('0 */6 * * *', runGeoCordTask);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,19 @@
|
|||||||
|
|
||||||
import cron from 'node-cron';
|
import cron from 'node-cron';
|
||||||
import runActiveChecker from '../listings/listingActiveService.js';
|
import runActiveChecker from '../listings/listingActiveService.js';
|
||||||
|
import logger from '../logger.js';
|
||||||
|
import { getSettings } from '../storage/settingsStorage.js';
|
||||||
|
|
||||||
async function runTask() {
|
async function runTask() {
|
||||||
await runActiveChecker();
|
await runActiveChecker();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function initActiveCheckerCron() {
|
export async function initActiveCheckerCron() {
|
||||||
|
const settings = await getSettings();
|
||||||
|
if (settings.demoMode) {
|
||||||
|
logger.info('Do not start listing active checker as we are in demo mode');
|
||||||
|
return;
|
||||||
|
}
|
||||||
//run directly on start
|
//run directly on start
|
||||||
await runTask();
|
await runTask();
|
||||||
// then every day at 1 am
|
// then every day at 1 am
|
||||||
|
|||||||
@@ -592,3 +592,23 @@ export const getListingById = (id, userId = null, isAdmin = false) => {
|
|||||||
)[0] || null
|
)[0] || null
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets geocoordinates and distance for all listings related to a user.
|
||||||
|
*
|
||||||
|
* @param {string} userId
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
export const resetGeocoordinatesAndDistanceForUser = (userId) => {
|
||||||
|
SqliteConnection.execute(
|
||||||
|
`UPDATE listings
|
||||||
|
SET latitude = NULL,
|
||||||
|
longitude = NULL,
|
||||||
|
distance_to_destination = NULL
|
||||||
|
WHERE job_id IN (
|
||||||
|
SELECT id FROM jobs j
|
||||||
|
WHERE j.user_id = @userId
|
||||||
|
)`,
|
||||||
|
{ userId },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import * as hasher from '../security/hash.js';
|
|||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import SqliteConnection from './SqliteConnection.js';
|
import SqliteConnection from './SqliteConnection.js';
|
||||||
import { getSettings } from './settingsStorage.js';
|
import { getSettings } from './settingsStorage.js';
|
||||||
|
import { inDevMode } from '../../utils.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all users.
|
* Get all users.
|
||||||
@@ -137,8 +138,12 @@ export const removeUser = (userId) => {
|
|||||||
export const ensureDemoUserExists = async () => {
|
export const ensureDemoUserExists = async () => {
|
||||||
const settings = await getSettings();
|
const settings = await getSettings();
|
||||||
if (!settings.demoMode) {
|
if (!settings.demoMode) {
|
||||||
// Remove demo user (and cascade delete their jobs/listings)
|
if (!inDevMode()) {
|
||||||
SqliteConnection.execute(`DELETE FROM users WHERE username = 'demo'`);
|
// Remove demo user (and cascade delete their jobs/listings)
|
||||||
|
SqliteConnection.execute(`DELETE
|
||||||
|
FROM users
|
||||||
|
WHERE username = 'demo'`);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Ensure demo user exists when demo mode is on
|
// Ensure demo user exists when demo mode is on
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "fredy",
|
"name": "fredy",
|
||||||
"version": "19.3.0",
|
"version": "19.3.3",
|
||||||
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
|
|||||||
@@ -124,14 +124,7 @@ export default function FredyApp() {
|
|||||||
</PermissionAwareRoute>
|
</PermissionAwareRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route path="/userSettings" element={<UserSettings />} />
|
||||||
path="/userSettings"
|
|
||||||
element={
|
|
||||||
<PermissionAwareRoute currentUser={currentUser} adminOnly={false}>
|
|
||||||
<UserSettings />
|
|
||||||
</PermissionAwareRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
<Route
|
||||||
path="/generalSettings"
|
path="/generalSettings"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -17,6 +17,8 @@
|
|||||||
padding: 24px;
|
padding: 24px;
|
||||||
background-color: var(--semi-color-bg-0);
|
background-color: var(--semi-color-bg-0);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
|
|||||||
@@ -8,12 +8,12 @@ import { Card } from '@douyinfe/semi-ui-19';
|
|||||||
|
|
||||||
import './SegmentParts.less';
|
import './SegmentParts.less';
|
||||||
|
|
||||||
export const SegmentPart = ({ name, Icon = null, children, helpText = null }) => {
|
export const SegmentPart = ({ name, Icon = null, children, helpText = null, className = '' }) => {
|
||||||
const { Meta } = Card;
|
const { Meta } = Card;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
className="segmentParts"
|
className={`segmentParts ${className}`}
|
||||||
title={
|
title={
|
||||||
(helpText || name) && (
|
(helpText || name) && (
|
||||||
<Meta title={name} description={helpText} avatar={Icon == null ? null : <Icon size="extra-extra-small" />} />
|
<Meta title={name} description={helpText} avatar={Icon == null ? null : <Icon size="extra-extra-small" />} />
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
import { useSelector, useActions } from '../../services/state/store';
|
import { useSelector, useActions } from '../../services/state/store';
|
||||||
import KpiCard from '../../components/cards/KpiCard.jsx';
|
import KpiCard from '../../components/cards/KpiCard.jsx';
|
||||||
import PieChartCard from '../../components/cards/PieChartCard.jsx';
|
import PieChartCard from '../../components/cards/PieChartCard.jsx';
|
||||||
import Headline from '../../components/headline/Headline.jsx';
|
|
||||||
|
|
||||||
import './Dashboard.less';
|
import './Dashboard.less';
|
||||||
import { SegmentPart } from '../../components/segment/SegmentPart.jsx';
|
import { SegmentPart } from '../../components/segment/SegmentPart.jsx';
|
||||||
@@ -39,8 +38,6 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dashboard">
|
<div className="dashboard">
|
||||||
<Headline text="Dashboard" size={3} />
|
|
||||||
|
|
||||||
<Row gutter={[16, 16]} className="dashboard__row">
|
<Row gutter={[16, 16]} className="dashboard__row">
|
||||||
<Col span={12} xs={24} sm={24} md={24} lg={24} xl={12}>
|
<Col span={12} xs={24} sm={24} md={24} lg={24} xl={12}>
|
||||||
<SegmentPart name="General" Icon={IconTerminal}>
|
<SegmentPart name="General" Icon={IconTerminal}>
|
||||||
@@ -136,7 +133,14 @@ export default function Dashboard() {
|
|||||||
<KpiCard
|
<KpiCard
|
||||||
title="Avg. Price"
|
title="Avg. Price"
|
||||||
color="purple"
|
color="purple"
|
||||||
value={`${!kpis.avgPriceOfListings ? '---' : kpis.avgPriceOfListings} €`}
|
value={`${
|
||||||
|
!kpis.avgPriceOfListings
|
||||||
|
? '---'
|
||||||
|
: new Intl.NumberFormat('de-DE', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'EUR',
|
||||||
|
}).format(kpis.avgPriceOfListings)
|
||||||
|
}`}
|
||||||
icon={<IconNoteMoney />}
|
icon={<IconNoteMoney />}
|
||||||
description="Avg. Price of listings"
|
description="Avg. Price of listings"
|
||||||
/>
|
/>
|
||||||
@@ -146,7 +150,12 @@ export default function Dashboard() {
|
|||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<SegmentPart name="Provider Insights" Icon={IconStar} helpText="Percentage of found listings over all providers">
|
<SegmentPart
|
||||||
|
name="Provider Insights"
|
||||||
|
Icon={IconStar}
|
||||||
|
helpText="Percentage of found listings over all providers"
|
||||||
|
className="dashboard__provider-insights"
|
||||||
|
>
|
||||||
<PieChartCard data={pieData} />
|
<PieChartCard data={pieData} />
|
||||||
</SegmentPart>
|
</SegmentPart>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
.dashboard {
|
.dashboard {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
&__row {
|
&__row {
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -7,4 +11,23 @@
|
|||||||
margin-bottom: 0; // Handled by Row gutter
|
margin-bottom: 0; // Handled by Row gutter
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__provider-insights {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin: 0 !important;
|
||||||
|
|
||||||
|
.semi-card-body {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
max-height: 300px;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -315,7 +315,7 @@ export default function ListingDetail() {
|
|||||||
>
|
>
|
||||||
{listing.isWatched === 1 ? 'Watched' : 'Watch'}
|
{listing.isWatched === 1 ? 'Watched' : 'Watch'}
|
||||||
</Button>
|
</Button>
|
||||||
<Text link={{ href: listing.link }} icon={<IconLink />} underline>
|
<Text link={{ href: listing.link, target: '_blank' }} icon={<IconLink />} underline>
|
||||||
Open listing
|
Open listing
|
||||||
</Text>
|
</Text>
|
||||||
</Space>
|
</Space>
|
||||||
|
|||||||
@@ -62,6 +62,18 @@ export default function Login() {
|
|||||||
<div className="login__logoWrapper">
|
<div className="login__logoWrapper">
|
||||||
<Logo width={250} white />
|
<Logo width={250} white />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{demoMode && (
|
||||||
|
<Banner
|
||||||
|
fullMode={true}
|
||||||
|
type="info"
|
||||||
|
bordered
|
||||||
|
closeIcon={null}
|
||||||
|
description="This is the demo version of Fredy. Use 'demo' as both the username and password to log in."
|
||||||
|
style={{ marginBottom: '1.5rem' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<form onSubmit={(e) => e.preventDefault()}>
|
<form onSubmit={(e) => e.preventDefault()}>
|
||||||
{error && <Banner type="danger" closeIcon={null} description={error} style={{ marginBottom: '1rem' }} />}
|
{error && <Banner type="danger" closeIcon={null} description={error} style={{ marginBottom: '1rem' }} />}
|
||||||
<div className="login__inputGroup">
|
<div className="login__inputGroup">
|
||||||
@@ -100,17 +112,6 @@ export default function Login() {
|
|||||||
<Button block type="primary" onClick={tryLogin} theme="solid" style={{ marginTop: '1rem' }}>
|
<Button block type="primary" onClick={tryLogin} theme="solid" style={{ marginTop: '1rem' }}>
|
||||||
Login
|
Login
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{demoMode && (
|
|
||||||
<Banner
|
|
||||||
fullMode={true}
|
|
||||||
type="info"
|
|
||||||
bordered
|
|
||||||
closeIcon={null}
|
|
||||||
description="This is the demo version of Fredy. Use 'demo' as both the username and password to log in."
|
|
||||||
style={{ marginTop: '1.5rem' }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,15 +4,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useEffect, useState, useMemo } from 'react';
|
import React, { useEffect, useState, useMemo } from 'react';
|
||||||
import { Divider, Button, AutoComplete, Toast, Typography, Banner } from '@douyinfe/semi-ui-19';
|
import { Divider, Button, AutoComplete, Toast, Banner } from '@douyinfe/semi-ui-19';
|
||||||
import { IconSave, IconHome } from '@douyinfe/semi-icons';
|
import { IconSave, IconHome } from '@douyinfe/semi-icons';
|
||||||
import { useSelector, useActions } from '../../services/state/store';
|
import { useSelector, useActions } from '../../services/state/store';
|
||||||
import { xhrGet, xhrPost } from '../../services/xhr';
|
import { xhrGet, xhrPost } from '../../services/xhr';
|
||||||
import { SegmentPart } from '../../components/segment/SegmentPart';
|
import { SegmentPart } from '../../components/segment/SegmentPart';
|
||||||
import debounce from 'lodash/debounce';
|
import debounce from 'lodash/debounce';
|
||||||
|
|
||||||
const { Title } = Typography;
|
|
||||||
|
|
||||||
const UserSettings = () => {
|
const UserSettings = () => {
|
||||||
const actions = useActions();
|
const actions = useActions();
|
||||||
const homeAddress = useSelector((state) => state.userSettings.settings.home_address);
|
const homeAddress = useSelector((state) => state.userSettings.settings.home_address);
|
||||||
@@ -33,7 +31,9 @@ const UserSettings = () => {
|
|||||||
if (response.status === 200) {
|
if (response.status === 200) {
|
||||||
setCoords(response.json.coords);
|
setCoords(response.json.coords);
|
||||||
await actions.userSettings.getUserSettings();
|
await actions.userSettings.getUserSettings();
|
||||||
Toast.success('Settings saved successfully');
|
Toast.success(
|
||||||
|
'Settings saved successfully. We will now start calculating distances for you. This may take a while and runs in the background.',
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
Toast.error(response.json.error || 'Failed to save settings');
|
Toast.error(response.json.error || 'Failed to save settings');
|
||||||
}
|
}
|
||||||
@@ -70,8 +70,6 @@ const UserSettings = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="user-settings">
|
<div className="user-settings">
|
||||||
<Title heading={2}>User Specific Settings</Title>
|
|
||||||
<Divider />
|
|
||||||
<SegmentPart
|
<SegmentPart
|
||||||
name="Distance claculation"
|
name="Distance claculation"
|
||||||
Icon={IconHome}
|
Icon={IconHome}
|
||||||
|
|||||||
Reference in New Issue
Block a user