Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa67647bbb | ||
|
|
7a9d49899b | ||
|
|
9a87c58d3e | ||
|
|
fdd7e835e8 | ||
|
|
00d6a12b30 | ||
|
|
05218800d2 | ||
|
|
19d4721f9f | ||
|
|
a794645393 | ||
|
|
fd7e228972 | ||
|
|
b86e351007 | ||
|
|
19c4860da7 | ||
|
|
d98e06cfdf | ||
|
|
6ae0c9749b | ||
|
|
10e40e038e | ||
|
|
4ba6828939 | ||
|
|
d09770dae2 | ||
|
|
248e4d2562 | ||
|
|
7b8e961b49 | ||
|
|
f66ceccbb4 | ||
|
|
a3db725af6 | ||
|
|
0663bd945f | ||
|
|
bc355fb5fe | ||
|
|
797421f0d5 | ||
|
|
0b2b42fc75 | ||
|
|
472169693f |
1
.gitignore
vendored
@@ -6,3 +6,4 @@ npm-debug.log
|
||||
.DS_Store
|
||||
.idea
|
||||
.vscode
|
||||
tools/release/config.json
|
||||
|
||||
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
|
||||
```
|
||||
@@ -35,6 +35,7 @@ WORKDIR /fredy
|
||||
RUN apk add --no-cache chromium curl
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
IS_DOCKER=true \
|
||||
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
|
||||
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 4.7 MiB After Width: | Height: | Size: 4.8 MiB |
|
Before Width: | Height: | Size: 402 KiB After Width: | Height: | Size: 531 KiB |
@@ -8,20 +8,20 @@ import js from '@eslint/js';
|
||||
import prettier from 'eslint-config-prettier';
|
||||
import globals from 'globals';
|
||||
import react from 'eslint-plugin-react';
|
||||
import babelParser from '@babel/eslint-parser';
|
||||
|
||||
export default [
|
||||
{
|
||||
files: ['**/*.{js,jsx,ts,tsx}'],
|
||||
ignores: ['**/node_modules/**', '**/dist/**', '**/build/**', '**/public/**', 'db/**', 'conf/**'],
|
||||
},
|
||||
js.configs.recommended,
|
||||
prettier,
|
||||
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
languageOptions: {
|
||||
parser: babelParser,
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
ecmaVersion: 2021,
|
||||
parserOptions: {
|
||||
ecmaFeatures: { jsx: true },
|
||||
},
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
@@ -32,70 +32,14 @@ export default [
|
||||
after: 'readonly',
|
||||
it: 'readonly',
|
||||
},
|
||||
parserOptions: { requireConfigFile: false },
|
||||
},
|
||||
plugins: { react },
|
||||
rules: {
|
||||
eqeqeq: [2, 'allow-null'],
|
||||
strict: 0,
|
||||
'no-redeclare': [2, { builtinGlobals: false }],
|
||||
'class-methods-use-this': 'off',
|
||||
indent: ['off', 2],
|
||||
'linebreak-style': ['error', 'unix'],
|
||||
quotes: ['error', 'single', { avoidEscape: true, allowTemplateLiterals: true }],
|
||||
semi: ['error', 'always'],
|
||||
'no-console': ['error', { allow: ['warn', 'error'] }],
|
||||
'jsx-quotes': ['error', 'prefer-double'],
|
||||
'react/display-name': 'off',
|
||||
'react/forbid-prop-types': 'off',
|
||||
'react/jsx-closing-bracket-location': 'off',
|
||||
'react/jsx-curly-spacing': 'off',
|
||||
'react/jsx-handler-names': ['off', { eventHandlerPrefix: 'handle', eventHandlerPropPrefix: 'on' }],
|
||||
'react/jsx-indent-props': 'off',
|
||||
'react/jsx-key': 'off',
|
||||
'react/jsx-max-props-per-line': 'off',
|
||||
'react/jsx-no-bind': ['error', { ignoreRefs: true, allowArrowFunctions: true, allowBind: false }],
|
||||
'react/jsx-no-duplicate-props': ['error', { ignoreCase: true }],
|
||||
'react/jsx-no-literals': 'off',
|
||||
'react/jsx-no-undef': 'error',
|
||||
'react/jsx-pascal-case': ['error', { allowAllCaps: true, ignore: [] }],
|
||||
'react/sort-prop-types': ['off', { ignoreCase: true, callbacksLast: false, requiredFirst: false }],
|
||||
'react/jsx-sort-prop-types': 'off',
|
||||
'react/jsx-sort-props': 'off',
|
||||
'react/jsx-uses-react': 'error',
|
||||
'react/jsx-uses-vars': 'error',
|
||||
'react/no-danger': 'warn',
|
||||
'react/no-deprecated': 'error',
|
||||
'react/no-did-mount-set-state': 'error',
|
||||
'react/no-did-update-set-state': 'warn',
|
||||
'react/no-direct-mutation-state': 'off',
|
||||
'react/no-is-mounted': 'error',
|
||||
'react/no-set-state': 'off',
|
||||
'react/no-string-refs': 'warn',
|
||||
'react/no-unknown-property': 'error',
|
||||
'react/prop-types': ['error', { ignore: [], customValidators: [], skipUndeclared: true }],
|
||||
'react/react-in-jsx-scope': 'error',
|
||||
'react/require-extension': 'off',
|
||||
'react/require-render-return': 'error',
|
||||
'react/self-closing-comp': 'warn',
|
||||
'react/sort-comp': 'off',
|
||||
'react/jsx-wrap-multilines': ['warn', { declaration: true, assignment: true, return: true }],
|
||||
'react/wrap-multilines': 'off',
|
||||
'react/jsx-first-prop-new-line': 'off',
|
||||
'react/jsx-equals-spacing': ['warn', 'never'],
|
||||
'react/jsx-no-target-blank': 'error',
|
||||
'react/jsx-filename-extension': ['error', { extensions: ['.jsx'] }],
|
||||
'react/jsx-no-comment-textnodes': 'error',
|
||||
'react/no-comment-textnodes': 'off',
|
||||
'react/no-render-return-value': 'error',
|
||||
'react/require-optimization': ['off', { allowDecorators: [] }],
|
||||
'react/no-find-dom-node': 'warn',
|
||||
'react/forbid-component-props': ['off', { forbid: [] }],
|
||||
'react/no-danger-with-children': 'error',
|
||||
'react/no-unused-prop-types': ['warn', { customValidators: [], skipShapeProps: true }],
|
||||
'react/style-prop-object': 'error',
|
||||
'react/no-children-prop': 'warn',
|
||||
},
|
||||
settings: { react: { version: 'detect' } },
|
||||
rules: {
|
||||
...js.configs.recommended.rules,
|
||||
'no-console': ['error', { allow: ['warn', 'error'] }],
|
||||
},
|
||||
},
|
||||
|
||||
prettier,
|
||||
];
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1"
|
||||
/>
|
||||
<meta name="google" content="notranslate" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
|
||||
<title>Fredy || Real Estate Finder</title>
|
||||
|
||||
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 { runMigrations } from './lib/services/storage/migrations/migrate.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 logger from './lib/services/logger.js';
|
||||
import { initActiveCheckerCron } from './lib/services/crons/listing-alive-cron.js';
|
||||
@@ -54,7 +53,6 @@ await import('./lib/api/api.js');
|
||||
|
||||
if (settings.demoMode) {
|
||||
logger.info('Running in demo mode');
|
||||
cleanupDemoAtMidnight();
|
||||
}
|
||||
|
||||
ensureAdminUserExists();
|
||||
|
||||
@@ -63,13 +63,15 @@ class FredyPipelineExecutioner {
|
||||
* @param {string} providerId The ID of the provider currently in use.
|
||||
* @param {string} jobKey Key of the job that is currently running (from within the config).
|
||||
* @param {SimilarityCache} similarityCache Cache instance for checking similar entries.
|
||||
* @param browser
|
||||
*/
|
||||
constructor(providerConfig, notificationConfig, providerId, jobKey, similarityCache) {
|
||||
constructor(providerConfig, notificationConfig, providerId, jobKey, similarityCache, browser) {
|
||||
this._providerConfig = providerConfig;
|
||||
this._notificationConfig = notificationConfig;
|
||||
this._providerId = providerId;
|
||||
this._jobKey = jobKey;
|
||||
this._similarityCache = similarityCache;
|
||||
this._browser = browser;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -119,7 +121,7 @@ class FredyPipelineExecutioner {
|
||||
* @returns {Promise<Listing[]>} Resolves with an array of listings (empty when none found).
|
||||
*/
|
||||
_getListings(url) {
|
||||
const extractor = new Extractor();
|
||||
const extractor = new Extractor({ ...this._providerConfig.puppeteerOptions, browser: this._browser });
|
||||
return new Promise((resolve, reject) => {
|
||||
extractor
|
||||
.execute(url, this._providerConfig.waitForSelector)
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
export const FEATURES = {
|
||||
export const TRACKING_POIS = {
|
||||
DISTANCE_ADDRESS_ENTERED: 'DISTANCE_ADDRESS_ENTERED',
|
||||
WELCOME_FINISHED: 'WELCOME_FINISHED',
|
||||
WELCOME_SKIPPED: 'WELCOME_SKIPPED',
|
||||
};
|
||||
@@ -23,6 +23,7 @@ import { listingsRouter } from './routes/listingsRouter.js';
|
||||
import { getSettings } from '../services/storage/settingsStorage.js';
|
||||
import { dashboardRouter } from './routes/dashboardRouter.js';
|
||||
import { backupRouter } from './routes/backupRouter.js';
|
||||
import { trackingRouter } from './routes/trackingRoute.js';
|
||||
const service = restana();
|
||||
const staticService = files(path.join(getDirName(), '../ui/public'));
|
||||
const PORT = (await getSettings()).port || 9998;
|
||||
@@ -36,6 +37,7 @@ service.use('/api/version', authInterceptor());
|
||||
service.use('/api/listings', authInterceptor());
|
||||
service.use('/api/dashboard', authInterceptor());
|
||||
service.use('/api/user/settings', authInterceptor());
|
||||
service.use('/api/tracking', authInterceptor());
|
||||
|
||||
// /admin can only be accessed when user is having admin permissions
|
||||
service.use('/api/admin', adminInterceptor());
|
||||
@@ -50,6 +52,7 @@ service.use('/api/jobs', jobRouter);
|
||||
service.use('/api/login', loginRouter);
|
||||
service.use('/api/listings', listingsRouter);
|
||||
service.use('/api/dashboard', dashboardRouter);
|
||||
service.use('/api/tracking', trackingRouter);
|
||||
//this route is unsecured intentionally as it is being queried from the login page
|
||||
service.use('/api/demo', demoRouter);
|
||||
|
||||
|
||||
@@ -11,10 +11,13 @@ import logger from '../../services/logger.js';
|
||||
import { bus } from '../../services/events/event-bus.js';
|
||||
import { isRunning as isJobRunning } from '../../services/jobs/run-state.js';
|
||||
import { addClient as addSseClient, removeClient } from '../../services/sse/sse-broker.js';
|
||||
import { getSettings } from '../../services/storage/settingsStorage.js';
|
||||
|
||||
const service = restana();
|
||||
const jobRouter = service.newRouter();
|
||||
|
||||
const DEMO_JOB_NAME = 'Demo-Job';
|
||||
|
||||
function doesJobBelongsToUser(job, req) {
|
||||
const userId = req.session.currentUser;
|
||||
if (userId == null) {
|
||||
@@ -161,6 +164,7 @@ jobRouter.post('/:jobId/run', async (req, res) => {
|
||||
|
||||
jobRouter.post('/', async (req, res) => {
|
||||
const { provider, notificationAdapter, name, blacklist = [], jobId, enabled, shareWithUsers = [] } = req.body;
|
||||
const settings = await getSettings();
|
||||
try {
|
||||
let jobFromDb = jobStorage.getJob(jobId);
|
||||
|
||||
@@ -169,6 +173,11 @@ jobRouter.post('/', async (req, res) => {
|
||||
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({
|
||||
userId: req.session.currentUser,
|
||||
jobId,
|
||||
@@ -188,8 +197,14 @@ jobRouter.post('/', async (req, res) => {
|
||||
|
||||
jobRouter.delete('', async (req, res) => {
|
||||
const { jobId } = req.body;
|
||||
const settings = await getSettings();
|
||||
try {
|
||||
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)) {
|
||||
res.send(new Error('You are trying to remove a job that is not associated to your user'));
|
||||
} else {
|
||||
@@ -204,8 +219,15 @@ jobRouter.delete('', async (req, res) => {
|
||||
jobRouter.put('/:jobId/status', async (req, res) => {
|
||||
const { status } = req.body;
|
||||
const { jobId } = req.params;
|
||||
const settings = await getSettings();
|
||||
try {
|
||||
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)) {
|
||||
res.send(new Error('You are trying change a job that is not associated to your user'));
|
||||
} else {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { isAdmin as isAdminFn } from '../security.js';
|
||||
import logger from '../../services/logger.js';
|
||||
import { nullOrEmpty } from '../../utils.js';
|
||||
import { getJobs } from '../../services/storage/jobStorage.js';
|
||||
import { getSettings } from '../../services/storage/settingsStorage.js';
|
||||
|
||||
const service = restana();
|
||||
|
||||
@@ -74,6 +75,18 @@ listingsRouter.get('/map', async (req, res) => {
|
||||
res.send();
|
||||
});
|
||||
|
||||
listingsRouter.get('/:listingId', async (req, res) => {
|
||||
const { listingId } = req.params;
|
||||
const listing = listingStorage.getListingById(listingId, req.session.currentUser, isAdminFn(req));
|
||||
if (!listing) {
|
||||
res.statusCode = 404;
|
||||
res.body = { message: 'Listing not found' };
|
||||
return res.send();
|
||||
}
|
||||
res.body = listing;
|
||||
res.send();
|
||||
});
|
||||
|
||||
// Toggle watch state for the current user on a listing
|
||||
listingsRouter.post('/watch', async (req, res) => {
|
||||
try {
|
||||
@@ -94,9 +107,15 @@ listingsRouter.post('/watch', async (req, res) => {
|
||||
});
|
||||
|
||||
listingsRouter.delete('/job', async (req, res) => {
|
||||
const { jobId } = req.body;
|
||||
const { jobId, hardDelete = false } = req.body;
|
||||
const settings = await getSettings();
|
||||
try {
|
||||
listingStorage.deleteListingsByJobId(jobId);
|
||||
if (settings.demoMode) {
|
||||
res.send(new Error('Sorry, but you cannot remove listings in demo mode ;)'));
|
||||
return;
|
||||
}
|
||||
|
||||
listingStorage.deleteListingsByJobId(jobId, hardDelete);
|
||||
} catch (error) {
|
||||
res.send(new Error(error));
|
||||
logger.error(error);
|
||||
@@ -105,10 +124,10 @@ listingsRouter.delete('/job', async (req, res) => {
|
||||
});
|
||||
|
||||
listingsRouter.delete('/', async (req, res) => {
|
||||
const { ids } = req.body;
|
||||
const { ids, hardDelete = false } = req.body;
|
||||
try {
|
||||
if (Array.isArray(ids) && ids.length > 0) {
|
||||
listingStorage.deleteListingsById(ids);
|
||||
listingStorage.deleteListingsById(ids, hardDelete);
|
||||
}
|
||||
} catch (error) {
|
||||
res.send(new Error(error));
|
||||
|
||||
37
lib/api/routes/trackingRoute.js
Normal file
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import restana from 'restana';
|
||||
import { trackPoi } from '../../services/tracking/Tracker.js';
|
||||
import { TRACKING_POIS } from '../../TRACKING_POIS.js';
|
||||
import logger from '../../services/logger.js';
|
||||
|
||||
const service = restana();
|
||||
const trackingRouter = service.newRouter();
|
||||
|
||||
trackingRouter.get('/trackingPois', async (req, res) => {
|
||||
res.body = TRACKING_POIS;
|
||||
res.send();
|
||||
});
|
||||
|
||||
trackingRouter.post('/poi', async (req, res) => {
|
||||
const { poi } = req.body;
|
||||
if (!poi) {
|
||||
res.statusCode = 400;
|
||||
res.send({ error: 'Feature name is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await trackPoi(poi);
|
||||
res.send({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Error tracking feature', error);
|
||||
res.statusCode = 500;
|
||||
res.send({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
export { trackingRouter };
|
||||
@@ -5,14 +5,15 @@
|
||||
|
||||
import restana from 'restana';
|
||||
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 { autocompleteAddress } from '../../services/geocoding/autocompleteService.js';
|
||||
import { calculateDistanceForUser } from '../../services/geocoding/distanceService.js';
|
||||
import { fromJson } from '../../utils.js';
|
||||
import { trackFeature } from '../../services/tracking/Tracker.js';
|
||||
import { FEATURES } from '../../features.js';
|
||||
import { trackPoi } from '../../services/tracking/Tracker.js';
|
||||
import { TRACKING_POIS } from '../../TRACKING_POIS.js';
|
||||
import logger from '../../services/logger.js';
|
||||
import { runGeoCordTask } from '../../services/crons/geocoding-cron.js';
|
||||
|
||||
const service = restana();
|
||||
const userSettingsRouter = service.newRouter();
|
||||
@@ -43,14 +44,22 @@ userSettingsRouter.get('/autocomplete', async (req, res) => {
|
||||
userSettingsRouter.post('/home-address', async (req, res) => {
|
||||
const userId = req.session.currentUser;
|
||||
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 {
|
||||
if (home_address) {
|
||||
await trackFeature(FEATURES.DISTANCE_ADDRESS_ENTERED);
|
||||
await trackPoi(TRACKING_POIS.DISTANCE_ADDRESS_ENTERED);
|
||||
const coords = await geocodeAddress(home_address);
|
||||
if (coords && coords.lat !== -1) {
|
||||
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 });
|
||||
} else {
|
||||
res.statusCode = 400;
|
||||
@@ -67,4 +76,25 @@ userSettingsRouter.post('/home-address', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
userSettingsRouter.post('/news-hash', async (req, res) => {
|
||||
const userId = req.session.currentUser;
|
||||
const { news_hash } = req.body;
|
||||
|
||||
const globalSettings = await getSettings();
|
||||
if (globalSettings.demoMode) {
|
||||
res.statusCode = 403;
|
||||
res.send({ error: 'In demo mode, it is not allowed to change settings.' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
upsertSettings({ news_hash }, userId);
|
||||
res.send({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Error updating news hash', error);
|
||||
res.statusCode = 500;
|
||||
res.send({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
export { userSettingsRouter };
|
||||
|
||||
88
lib/notification/adapter/resend.js
Executable file
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { Resend } from 'resend';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import Handlebars from 'handlebars';
|
||||
import { markdown2Html } from '../../services/markdown.js';
|
||||
import { getDirName, normalizeImageUrl } from '../../utils.js';
|
||||
|
||||
const __dirname = getDirName();
|
||||
const template = fs.readFileSync(path.resolve(__dirname + '/notification/emailTemplate/template.hbs'), 'utf8');
|
||||
const emailTemplate = Handlebars.compile(template);
|
||||
|
||||
const mapListings = (serviceName, jobKey, listings) =>
|
||||
listings.map((l) => {
|
||||
const image = normalizeImageUrl(l.image);
|
||||
return {
|
||||
title: l.title || '',
|
||||
link: l.link || '',
|
||||
address: l.address || '',
|
||||
size: l.size || '',
|
||||
price: l.price || '',
|
||||
image,
|
||||
hasImage: Boolean(image),
|
||||
serviceName,
|
||||
jobKey,
|
||||
};
|
||||
});
|
||||
|
||||
export const send = async ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||
const { apiKey, receiver, from } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
||||
|
||||
const to = receiver
|
||||
.trim()
|
||||
.split(',')
|
||||
.map((r) => r.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const resend = new Resend(apiKey);
|
||||
|
||||
const listings = mapListings(serviceName, jobKey, newListings);
|
||||
|
||||
const html = emailTemplate({
|
||||
serviceName: `Job: (${jobKey}) | Service: ${serviceName}`,
|
||||
numberOfListings: listings.length,
|
||||
listings,
|
||||
});
|
||||
|
||||
const { error } = await resend.emails.send({
|
||||
from,
|
||||
to,
|
||||
subject: `Fredy found ${listings.length} new listing(s) for ${serviceName}`,
|
||||
html,
|
||||
});
|
||||
|
||||
if (!error) {
|
||||
return Promise.resolve();
|
||||
} else {
|
||||
return Promise.reject(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
export const config = {
|
||||
id: 'resend',
|
||||
name: 'Resend',
|
||||
description: 'Resend is being used to send new listings via mail.',
|
||||
readme: markdown2Html('lib/notification/adapter/resend.md'),
|
||||
fields: {
|
||||
apiKey: {
|
||||
type: 'text',
|
||||
label: 'Api Key',
|
||||
description: 'The Resend API key used to send emails.',
|
||||
},
|
||||
receiver: {
|
||||
type: 'email',
|
||||
label: 'Receiver Email',
|
||||
description: 'Comma-separated email addresses Fredy will send notifications to.',
|
||||
},
|
||||
from: {
|
||||
type: 'email',
|
||||
label: 'Sender Email',
|
||||
description: 'The verified email address or domain you send from in Resend.',
|
||||
},
|
||||
},
|
||||
};
|
||||
17
lib/notification/adapter/resend.md
Normal file
@@ -0,0 +1,17 @@
|
||||
### Resend Adapter
|
||||
|
||||
Resend is a modern email delivery service that Fredy can use to send notifications.
|
||||
|
||||
Setup:
|
||||
- Create a Resend account: https://resend.com/
|
||||
- Create an API key and add it to Fredy's configuration.
|
||||
- Choose the sender address (e.g., you@yourdomain.com). Verify the domain (https://resend.com/domains/) in Resend before using it.
|
||||
- Optional for local testing: you can use `onboarding@resend.dev`, but Resend may restrict who you can send to when using test domains.
|
||||
|
||||
Multiple recipients:
|
||||
- Separate email addresses with commas (e.g., some@email.com, someOther@email.com).
|
||||
|
||||
Notes & Troubleshooting:
|
||||
- Ensure the `from` address is verified or belongs to a verified domain in Resend.
|
||||
- If emails don't arrive, check your spam folder and Resend dashboard logs.
|
||||
- The template displays listing images via their public URLs; make sure images are reachable.
|
||||
@@ -1,53 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { isOneOf, buildHash } from '../utils.js';
|
||||
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||
let appliedBlackList = [];
|
||||
|
||||
function normalize(o) {
|
||||
const size = o.size != null ? o.size.replace('Wohnfläche ', '') : 'N/A m²';
|
||||
const price = o.price.replace('Kaufpreis ', '');
|
||||
const address = o.address?.split(' • ')?.pop() ?? null;
|
||||
const title = o.title || 'No title available';
|
||||
const link = o.link != null ? decodeURIComponent(o.link) : config.url;
|
||||
const id = buildHash(title, price);
|
||||
return Object.assign(o, { id, address, price, size, title, link });
|
||||
}
|
||||
function applyBlacklist(o) {
|
||||
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
||||
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
||||
return titleNotBlacklisted && descNotBlacklisted;
|
||||
}
|
||||
const config = {
|
||||
url: null,
|
||||
crawlContainer: 'div[data-testid="serp-core-classified-card-testid"]',
|
||||
sortByDateParam: 'sortby=19',
|
||||
waitForSelector: 'div[data-testid="serp-gridcontainer-testid"]',
|
||||
crawlFields: {
|
||||
id: 'button@title |trim',
|
||||
title: 'button@title |trim',
|
||||
price: 'div[data-testid="cardmfe-price-testid"] | trim',
|
||||
size: 'div[data-testid="cardmfe-keyfacts-testid"] | trim',
|
||||
address: 'div[data-testid="cardmfe-description-box-address"] | trim',
|
||||
image: 'div[data-testid="cardmfe-picture-box-test-id"] img@src',
|
||||
link: 'button@data-base',
|
||||
description: 'div[data-testid="cardmfe-description-text-test-id"] | trim',
|
||||
},
|
||||
normalize: normalize,
|
||||
filter: applyBlacklist,
|
||||
activeTester: checkIfListingIsActive,
|
||||
};
|
||||
export const init = (sourceConfig, blacklist) => {
|
||||
config.enabled = sourceConfig.enabled;
|
||||
config.url = sourceConfig.url;
|
||||
appliedBlackList = blacklist || [];
|
||||
};
|
||||
export const metaInformation = {
|
||||
name: 'Immonet',
|
||||
baseUrl: 'https://www.immonet.de/',
|
||||
id: 'immonet',
|
||||
};
|
||||
export { config };
|
||||
@@ -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 { getJobs } from '../storage/jobStorage.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();
|
||||
if (listings.length > 0) {
|
||||
for (const listing of listings) {
|
||||
@@ -32,8 +34,13 @@ async function runTask() {
|
||||
}
|
||||
|
||||
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
|
||||
await runTask();
|
||||
await runGeoCordTask();
|
||||
// then every 6 hours
|
||||
cron.schedule('0 */6 * * *', runTask);
|
||||
cron.schedule('0 */6 * * *', runGeoCordTask);
|
||||
}
|
||||
|
||||
@@ -5,12 +5,19 @@
|
||||
|
||||
import cron from 'node-cron';
|
||||
import runActiveChecker from '../listings/listingActiveService.js';
|
||||
import logger from '../logger.js';
|
||||
import { getSettings } from '../storage/settingsStorage.js';
|
||||
|
||||
async function runTask() {
|
||||
await runActiveChecker();
|
||||
}
|
||||
|
||||
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
|
||||
await runTask();
|
||||
// then every day at 1 am
|
||||
|
||||
@@ -19,52 +19,80 @@ import path from 'path';
|
||||
|
||||
puppeteer.use(StealthPlugin());
|
||||
|
||||
export default async function execute(url, waitForSelector, options) {
|
||||
let browser;
|
||||
let page;
|
||||
let result = null;
|
||||
export async function launchBrowser(url, options) {
|
||||
const preCfg = getPreLaunchConfig(url, options || {});
|
||||
const launchArgs = [
|
||||
'--no-sandbox',
|
||||
'--disable-gpu',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-crash-reporter',
|
||||
'--no-first-run',
|
||||
'--no-default-browser-check',
|
||||
preCfg.langArg,
|
||||
preCfg.windowSizeArg,
|
||||
...preCfg.extraArgs,
|
||||
];
|
||||
if (options?.proxyUrl) {
|
||||
launchArgs.push(`--proxy-server=${options.proxyUrl}`);
|
||||
}
|
||||
|
||||
let userDataDir;
|
||||
let removeUserDataDir = false;
|
||||
if (options && options.userDataDir) {
|
||||
userDataDir = options.userDataDir;
|
||||
} else {
|
||||
const prefix = path.join(os.tmpdir(), 'puppeteer-fredy-');
|
||||
userDataDir = fs.mkdtempSync(prefix);
|
||||
removeUserDataDir = true;
|
||||
}
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: options?.puppeteerHeadless ?? true,
|
||||
args: launchArgs,
|
||||
timeout: options?.puppeteerTimeout || 45_000,
|
||||
userDataDir,
|
||||
executablePath: options?.executablePath,
|
||||
});
|
||||
|
||||
browser.__fredy_userDataDir = userDataDir;
|
||||
browser.__fredy_removeUserDataDir = removeUserDataDir;
|
||||
|
||||
return browser;
|
||||
}
|
||||
|
||||
export async function closeBrowser(browser) {
|
||||
if (!browser) return;
|
||||
const userDataDir = browser.__fredy_userDataDir;
|
||||
const removeUserDataDir = browser.__fredy_removeUserDataDir;
|
||||
try {
|
||||
await browser.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (removeUserDataDir && userDataDir) {
|
||||
try {
|
||||
await fs.promises.rm(userDataDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default async function execute(url, waitForSelector, options) {
|
||||
let browser = options?.browser;
|
||||
let isExternalBrowser = !!browser;
|
||||
let page;
|
||||
let result;
|
||||
try {
|
||||
debug(`Sending request to ${url} using Puppeteer.`);
|
||||
|
||||
// Prepare a dedicated temporary userDataDir to avoid leaking /tmp/.org.chromium.* dirs
|
||||
if (options && options.userDataDir) {
|
||||
userDataDir = options.userDataDir;
|
||||
removeUserDataDir = !!options.cleanupUserDataDir;
|
||||
} else {
|
||||
const prefix = path.join(os.tmpdir(), 'puppeteer-fredy-');
|
||||
userDataDir = fs.mkdtempSync(prefix);
|
||||
removeUserDataDir = true;
|
||||
if (!isExternalBrowser) {
|
||||
browser = await launchBrowser(url, options);
|
||||
}
|
||||
|
||||
const launchArgs = [
|
||||
'--no-sandbox',
|
||||
'--disable-gpu',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-crash-reporter',
|
||||
'--no-first-run',
|
||||
'--no-default-browser-check',
|
||||
];
|
||||
if (options?.proxyUrl) {
|
||||
launchArgs.push(`--proxy-server=${options.proxyUrl}`);
|
||||
}
|
||||
// Prepare bot prevention pre-launch config
|
||||
const preCfg = getPreLaunchConfig(url, options || {});
|
||||
launchArgs.push(preCfg.langArg);
|
||||
launchArgs.push(preCfg.windowSizeArg);
|
||||
launchArgs.push(...preCfg.extraArgs);
|
||||
|
||||
browser = await puppeteer.launch({
|
||||
headless: options?.puppeteerHeadless ?? true,
|
||||
args: launchArgs,
|
||||
timeout: options?.puppeteerTimeout || 30_000,
|
||||
userDataDir,
|
||||
executablePath: options?.executablePath, // allow using system Chrome
|
||||
});
|
||||
|
||||
page = await browser.newPage();
|
||||
const preCfg = getPreLaunchConfig(url, options || {});
|
||||
await applyBotPreventionToPage(page, preCfg);
|
||||
// Provide languages value before navigation
|
||||
await applyLanguagePersistence(page, preCfg);
|
||||
@@ -104,7 +132,7 @@ export default async function execute(url, waitForSelector, options) {
|
||||
result = pageSource || (await page.content());
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.message?.includes('Timeout')) {
|
||||
if (error?.name?.includes('Timeout')) {
|
||||
logger.debug('Error executing with puppeteer executor', error);
|
||||
} else {
|
||||
logger.warn('Error executing with puppeteer executor', error);
|
||||
@@ -118,19 +146,8 @@ export default async function execute(url, waitForSelector, options) {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
if (browser != null) {
|
||||
await browser.close();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
if (removeUserDataDir && userDataDir) {
|
||||
await fs.promises.rm(userDataDir, { recursive: true, force: true });
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
if (browser != null && !isExternalBrowser) {
|
||||
await closeBrowser(browser);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
|
||||
@@ -13,6 +13,7 @@ import FredyPipelineExecutioner from '../../FredyPipelineExecutioner.js';
|
||||
import * as similarityCache from '../similarity-check/similarityCache.js';
|
||||
import { isRunning, markFinished, markRunning } from './run-state.js';
|
||||
import { sendToUsers } from '../sse/sse-broker.js';
|
||||
import * as puppeteerExtractor from '../extractor/puppeteerExtractor.js';
|
||||
|
||||
/**
|
||||
* Initializes the job execution service.
|
||||
@@ -94,7 +95,7 @@ export function initJobExecutionService({ providers, settings, intervalMs }) {
|
||||
* @param {{userId?: string, isAdmin?: boolean}} [context] - Who requested the run; determines job filtering.
|
||||
* @returns {void}
|
||||
*/
|
||||
function runAll(respectWorkingHours = true, context = undefined) {
|
||||
async function runAll(respectWorkingHours = true, context = undefined) {
|
||||
if (settings.demoMode) return;
|
||||
const now = Date.now();
|
||||
const withinHours = duringWorkingHoursOrNotSet(settings, now);
|
||||
@@ -103,15 +104,18 @@ export function initJobExecutionService({ providers, settings, intervalMs }) {
|
||||
return;
|
||||
}
|
||||
settings.lastRun = now;
|
||||
jobStorage
|
||||
const jobs = jobStorage
|
||||
.getJobs()
|
||||
.filter((job) => job.enabled)
|
||||
.filter((job) => {
|
||||
if (!context) return true; // startup/cron → all
|
||||
if (context.isAdmin) return true; // admin → all
|
||||
return context.userId ? job.userId === context.userId : false; // user → own
|
||||
})
|
||||
.forEach((job) => executeJob(job));
|
||||
});
|
||||
|
||||
for (const job of jobs) {
|
||||
await executeJob(job);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -154,28 +158,42 @@ export function initJobExecutionService({ providers, settings, intervalMs }) {
|
||||
} catch (err) {
|
||||
logger.warn('Failed to emit start status for job', job.id, err);
|
||||
}
|
||||
let browser;
|
||||
try {
|
||||
const jobProviders = job.provider.filter(
|
||||
(p) => providers.find((loaded) => loaded.metaInformation.id === p.id) != null,
|
||||
);
|
||||
const executions = jobProviders.map(async (prov) => {
|
||||
const matchedProvider = providers.find((loaded) => loaded.metaInformation.id === prov.id);
|
||||
matchedProvider.init(prov, job.blacklist);
|
||||
await new FredyPipelineExecutioner(
|
||||
matchedProvider.config,
|
||||
job.notificationAdapter,
|
||||
prov.id,
|
||||
job.id,
|
||||
similarityCache,
|
||||
).execute();
|
||||
});
|
||||
const results = await Promise.allSettled(executions);
|
||||
for (const r of results) {
|
||||
if (r.status === 'rejected') {
|
||||
logger.error(r.reason);
|
||||
for (const prov of jobProviders) {
|
||||
try {
|
||||
const matchedProvider = providers.find((loaded) => loaded.metaInformation.id === prov.id);
|
||||
matchedProvider.init(prov, job.blacklist);
|
||||
|
||||
if (browser && !browser.isConnected()) {
|
||||
logger.debug('Browser is disconnected, nullifying to launch a new one.');
|
||||
await puppeteerExtractor.closeBrowser(browser);
|
||||
browser = null;
|
||||
}
|
||||
|
||||
if (!browser && matchedProvider.config.getListings == null) {
|
||||
browser = await puppeteerExtractor.launchBrowser(matchedProvider.config.url, {});
|
||||
}
|
||||
|
||||
await new FredyPipelineExecutioner(
|
||||
matchedProvider.config,
|
||||
job.notificationAdapter,
|
||||
prov.id,
|
||||
job.id,
|
||||
similarityCache,
|
||||
browser,
|
||||
).execute();
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (browser) {
|
||||
await puppeteerExtractor.closeBrowser(browser);
|
||||
}
|
||||
markFinished(job.id);
|
||||
try {
|
||||
bus.emit('jobs:status', { jobId: job.id, running: false });
|
||||
|
||||
@@ -37,12 +37,12 @@ export const upsertJob = ({
|
||||
if (existing) {
|
||||
SqliteConnection.execute(
|
||||
`UPDATE jobs
|
||||
SET enabled = @enabled,
|
||||
name = @name,
|
||||
blacklist = @blacklist,
|
||||
provider = @provider,
|
||||
notification_adapter = @notification_adapter,
|
||||
shared_with_user = @shareWithUsers
|
||||
SET enabled = @enabled,
|
||||
name = @name,
|
||||
blacklist = @blacklist,
|
||||
provider = @provider,
|
||||
notification_adapter = @notification_adapter,
|
||||
shared_with_user = @shareWithUsers
|
||||
WHERE id = @id`,
|
||||
{
|
||||
id,
|
||||
@@ -87,10 +87,10 @@ export const getJob = (jobId) => {
|
||||
j.provider,
|
||||
j.shared_with_user,
|
||||
j.notification_adapter AS notificationAdapter,
|
||||
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id) AS numberOfFoundListings
|
||||
FROM jobs j
|
||||
WHERE j.id = @id
|
||||
LIMIT 1`,
|
||||
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id AND l.is_active = 1 AND l.manually_deleted = 0) AS numberOfFoundListings
|
||||
FROM jobs j
|
||||
WHERE j.id = @id
|
||||
LIMIT 1`,
|
||||
{ id: jobId },
|
||||
)[0];
|
||||
if (!row) return null;
|
||||
@@ -150,9 +150,10 @@ export const getJobs = () => {
|
||||
j.provider,
|
||||
j.shared_with_user,
|
||||
j.notification_adapter AS notificationAdapter,
|
||||
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id) AS numberOfFoundListings
|
||||
FROM jobs j
|
||||
ORDER BY j.name IS NULL, j.name`,
|
||||
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id AND l.is_active = 1 AND l.manually_deleted = 0) AS numberOfFoundListings
|
||||
FROM jobs j
|
||||
WHERE j.enabled = 1
|
||||
ORDER BY j.name IS NULL, j.name`,
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
@@ -250,11 +251,11 @@ export const queryJobs = ({
|
||||
j.provider,
|
||||
j.shared_with_user,
|
||||
j.notification_adapter AS notificationAdapter,
|
||||
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id) AS numberOfFoundListings
|
||||
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id AND l.is_active = 1 AND l.manually_deleted = 0) AS numberOfFoundListings
|
||||
FROM jobs j
|
||||
${whereSql}
|
||||
${orderSql}
|
||||
LIMIT @limit OFFSET @offset`,
|
||||
${orderSql}
|
||||
LIMIT @limit OFFSET @offset`,
|
||||
params,
|
||||
);
|
||||
|
||||
|
||||
@@ -370,10 +370,18 @@ export const queryListings = ({
|
||||
* Delete all listings for a given job id.
|
||||
*
|
||||
* @param {string} jobId - The job identifier whose listings should be removed.
|
||||
* @returns {any} The result from SqliteConnection.execute (may contain changes count).
|
||||
* @param {boolean} [hardDelete=false] - Whether to hard delete from DB or just mark as deleted.
|
||||
* @returns {any} The result from SqliteConnection.execute.
|
||||
*/
|
||||
export const deleteListingsByJobId = (jobId) => {
|
||||
export const deleteListingsByJobId = (jobId, hardDelete = false) => {
|
||||
if (!jobId) return;
|
||||
if (hardDelete) {
|
||||
return SqliteConnection.execute(
|
||||
`DELETE FROM listings
|
||||
WHERE job_id = @jobId`,
|
||||
{ jobId },
|
||||
);
|
||||
}
|
||||
return SqliteConnection.execute(
|
||||
`UPDATE listings
|
||||
SET manually_deleted = 1
|
||||
@@ -386,11 +394,19 @@ export const deleteListingsByJobId = (jobId) => {
|
||||
* Delete listings by a list of listing IDs.
|
||||
*
|
||||
* @param {string[]} ids - Array of listing IDs to delete.
|
||||
* @param {boolean} [hardDelete=false] - Whether to hard delete from DB or just mark as deleted.
|
||||
* @returns {any} The result from SqliteConnection.execute.
|
||||
*/
|
||||
export const deleteListingsById = (ids) => {
|
||||
export const deleteListingsById = (ids, hardDelete = false) => {
|
||||
if (!Array.isArray(ids) || ids.length === 0) return;
|
||||
const placeholders = ids.map(() => '?').join(',');
|
||||
if (hardDelete) {
|
||||
return SqliteConnection.execute(
|
||||
`DELETE FROM listings
|
||||
WHERE id IN (${placeholders})`,
|
||||
ids,
|
||||
);
|
||||
}
|
||||
return SqliteConnection.execute(
|
||||
`UPDATE listings
|
||||
SET manually_deleted = 1
|
||||
@@ -566,3 +582,49 @@ export const updateListingDistance = (id, distance) => {
|
||||
{ id, distance },
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Return a single listing by id.
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {string} userId
|
||||
* @param {boolean} isAdmin
|
||||
* @returns {Object|null}
|
||||
*/
|
||||
export const getListingById = (id, userId = null, isAdmin = false) => {
|
||||
const params = { id, userId: userId || '__NO_USER__' };
|
||||
let whereScoping = '';
|
||||
if (!isAdmin) {
|
||||
whereScoping = `AND (j.user_id = @userId OR EXISTS (SELECT 1 FROM json_each(j.shared_with_user) AS sw WHERE sw.value = @userId))`;
|
||||
}
|
||||
return (
|
||||
SqliteConnection.query(
|
||||
`SELECT l.*, j.name AS job_name, CASE WHEN wl.id IS NOT NULL THEN 1 ELSE 0 END AS isWatched
|
||||
FROM listings l
|
||||
LEFT JOIN jobs j ON j.id = l.job_id
|
||||
LEFT JOIN watch_list wl ON wl.listing_id = l.id AND wl.user_id = @userId
|
||||
WHERE l.id = @id AND l.manually_deleted = 0 ${whereScoping}`,
|
||||
params,
|
||||
)[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 },
|
||||
);
|
||||
};
|
||||
|
||||
@@ -88,7 +88,7 @@ export function up(db) {
|
||||
}
|
||||
} catch (e) {
|
||||
// If parsing fails, let it throw to rollback the migration
|
||||
throw new Error(`Failed to import users from ${usersJsonPath}: ${e.message}`);
|
||||
throw new Error(`Failed to import users from ${usersJsonPath}: ${e.message}`, { cause: e });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ export function up(db) {
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to import jobs from ${jobsJsonPath}: ${e.message}`);
|
||||
throw new Error(`Failed to import jobs from ${jobsJsonPath}: ${e.message}`, { cause: e });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import * as hasher from '../security/hash.js';
|
||||
import { nanoid } from 'nanoid';
|
||||
import SqliteConnection from './SqliteConnection.js';
|
||||
import { getSettings } from './settingsStorage.js';
|
||||
import { inDevMode } from '../../utils.js';
|
||||
|
||||
/**
|
||||
* Get all users.
|
||||
@@ -137,8 +138,12 @@ export const removeUser = (userId) => {
|
||||
export const ensureDemoUserExists = async () => {
|
||||
const settings = await getSettings();
|
||||
if (!settings.demoMode) {
|
||||
// Remove demo user (and cascade delete their jobs/listings)
|
||||
SqliteConnection.execute(`DELETE FROM users WHERE username = 'demo'`);
|
||||
if (!inDevMode()) {
|
||||
// Remove demo user (and cascade delete their jobs/listings)
|
||||
SqliteConnection.execute(`DELETE
|
||||
FROM users
|
||||
WHERE username = 'demo'`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Ensure demo user exists when demo mode is on
|
||||
|
||||
@@ -14,92 +14,89 @@ import { getSettings } from '../storage/settingsStorage.js';
|
||||
const deviceId = getUniqueId() || 'N/A';
|
||||
const version = await getPackageVersion();
|
||||
const FREDY_TRACKING_URL = 'https://fredy.orange-coding.net/tracking';
|
||||
const isDocker = process.env.IS_DOCKER != null;
|
||||
|
||||
export const trackMainEvent = async () => {
|
||||
const staticTrackingData = {
|
||||
operatingSystem: os.platform(),
|
||||
osVersion: os.release(),
|
||||
isDocker,
|
||||
arch: process.arch,
|
||||
language: process.env.LANG || 'en',
|
||||
nodeVersion: process.version || 'N/A',
|
||||
deviceId,
|
||||
version,
|
||||
};
|
||||
|
||||
const shouldTrack = async () => {
|
||||
const settings = await getSettings();
|
||||
return settings.analyticsEnabled && !inDevMode();
|
||||
};
|
||||
|
||||
const sendTrackingData = async (endpoint, payload) => {
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
if (settings.analyticsEnabled && !inDevMode()) {
|
||||
const activeProvider = new Set();
|
||||
const activeAdapter = new Set();
|
||||
|
||||
const jobs = getJobs();
|
||||
|
||||
if (jobs != null && jobs.length > 0) {
|
||||
jobs.forEach((job) => {
|
||||
job.provider.forEach((provider) => activeProvider.add(provider.id));
|
||||
job.notificationAdapter.forEach((adapter) => activeAdapter.add(adapter.id));
|
||||
});
|
||||
|
||||
const trackingObj = enrichTrackingObject({
|
||||
adapter: Array.from(activeAdapter),
|
||||
provider: Array.from(activeProvider),
|
||||
});
|
||||
|
||||
await fetch(`${FREDY_TRACKING_URL}/main`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(trackingObj),
|
||||
});
|
||||
}
|
||||
const response = await fetch(`${FREDY_TRACKING_URL}${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: payload ? JSON.stringify(payload) : undefined,
|
||||
});
|
||||
if (!response.ok) {
|
||||
logger.warn(`Error sending tracking data to ${endpoint}. Status: ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Error sending tracking data', error);
|
||||
logger.warn(`Error sending tracking data to ${endpoint}`, error);
|
||||
}
|
||||
};
|
||||
|
||||
export const trackFeature = async (feature) => {
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
if (settings.analyticsEnabled && !inDevMode()) {
|
||||
const trackingObj = await enrichTrackingObject({
|
||||
feature,
|
||||
});
|
||||
export const trackMainEvent = async () => {
|
||||
if (!(await shouldTrack())) return;
|
||||
|
||||
await fetch(`${FREDY_TRACKING_URL}/feature`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(trackingObj),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Error tracking feature', error);
|
||||
const activeProvider = new Set();
|
||||
const activeAdapter = new Set();
|
||||
|
||||
const jobs = getJobs();
|
||||
|
||||
if (jobs != null && jobs.length > 0) {
|
||||
jobs.forEach((job) => {
|
||||
job.provider.forEach((provider) => activeProvider.add(provider.id));
|
||||
job.notificationAdapter.forEach((adapter) => activeAdapter.add(adapter.id));
|
||||
});
|
||||
|
||||
const trackingObj = await enrichTrackingObject({
|
||||
adapter: Array.from(activeAdapter),
|
||||
provider: Array.from(activeProvider),
|
||||
});
|
||||
|
||||
await sendTrackingData('/main', trackingObj);
|
||||
}
|
||||
};
|
||||
|
||||
export const trackPoi = async (poi) => {
|
||||
if (!(await shouldTrack())) return;
|
||||
|
||||
const trackingObj = await enrichTrackingObject({
|
||||
feature: poi,
|
||||
});
|
||||
|
||||
await sendTrackingData('/feature', trackingObj);
|
||||
};
|
||||
|
||||
/**
|
||||
* Note, this will only be used when Fredy runs in demo mode
|
||||
*/
|
||||
export async function trackDemoAccessed() {
|
||||
const settings = await getSettings();
|
||||
if (settings.analyticsEnabled && !inDevMode() && settings.demoMode) {
|
||||
try {
|
||||
await fetch(`${FREDY_TRACKING_URL}/demo/accessed`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn('Error sending tracking data', error);
|
||||
}
|
||||
const trackingObj = await enrichTrackingObject({});
|
||||
await sendTrackingData('/demo/accessed', trackingObj);
|
||||
}
|
||||
}
|
||||
|
||||
async function enrichTrackingObject(trackingObject) {
|
||||
const settings = await getSettings();
|
||||
const operatingSystem = os.platform();
|
||||
const osVersion = os.release();
|
||||
const arch = process.arch;
|
||||
const language = process.env.LANG || 'en';
|
||||
const nodeVersion = process.version || 'N/A';
|
||||
|
||||
return {
|
||||
...trackingObject,
|
||||
...staticTrackingData,
|
||||
isDemo: settings.demoMode,
|
||||
operatingSystem,
|
||||
osVersion,
|
||||
arch,
|
||||
nodeVersion,
|
||||
language,
|
||||
deviceId,
|
||||
version,
|
||||
};
|
||||
}
|
||||
|
||||
13553
package-lock.json
generated
35
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "fredy",
|
||||
"version": "19.2.2",
|
||||
"version": "19.5.2",
|
||||
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
||||
"scripts": {
|
||||
"prepare": "husky",
|
||||
@@ -17,7 +17,8 @@
|
||||
"lint:fix": "yarn lint --fix",
|
||||
"migratedb": "node lib/services/storage/migrations/migrate.js",
|
||||
"migratedb:overwrite": "x-var MIGRATION_ALLOW_CHECKSUM_UPDATE=true node lib/services/storage/migrations/migrate.js",
|
||||
"copyright": "node ./copyright.js"
|
||||
"copyright": "node ./copyright.js",
|
||||
"release": "node ./tools/release/release.js"
|
||||
},
|
||||
"type": "module",
|
||||
"lint-staged": {
|
||||
@@ -59,11 +60,11 @@
|
||||
"Firefox ESR"
|
||||
],
|
||||
"dependencies": {
|
||||
"@douyinfe/semi-icons": "^2.90.13",
|
||||
"@douyinfe/semi-ui": "2.90.13",
|
||||
"@douyinfe/semi-ui-19": "^2.90.13",
|
||||
"@douyinfe/semi-icons": "^2.91.0",
|
||||
"@douyinfe/semi-ui": "2.91.0",
|
||||
"@douyinfe/semi-ui-19": "^2.91.0",
|
||||
"@sendgrid/mail": "8.1.6",
|
||||
"@vitejs/plugin-react": "5.1.2",
|
||||
"@vitejs/plugin-react": "5.1.4",
|
||||
"adm-zip": "^0.5.16",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"body-parser": "2.2.2",
|
||||
@@ -72,41 +73,45 @@
|
||||
"cookie-session": "2.1.1",
|
||||
"handlebars": "4.7.8",
|
||||
"lodash": "4.17.23",
|
||||
"maplibre-gl": "^5.16.0",
|
||||
"maplibre-gl": "^5.18.0",
|
||||
"nanoid": "5.1.6",
|
||||
"node-cron": "^4.2.1",
|
||||
"node-fetch": "3.3.2",
|
||||
"node-mailjet": "6.0.11",
|
||||
"p-throttle": "^8.1.0",
|
||||
"package-up": "^5.0.0",
|
||||
"puppeteer": "^24.36.0",
|
||||
"puppeteer": "^24.37.3",
|
||||
"puppeteer-extra": "^3.3.6",
|
||||
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
||||
"query-string": "9.3.1",
|
||||
"react": "19.2.3",
|
||||
"react": "19.2.4",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"react-dom": "19.2.3",
|
||||
"react-dom": "19.2.4",
|
||||
"react-range-slider-input": "^3.3.2",
|
||||
"react-router": "7.13.0",
|
||||
"react-router-dom": "7.13.0",
|
||||
"resend": "^6.9.2",
|
||||
"restana": "5.1.0",
|
||||
"semver": "^7.7.3",
|
||||
"semver": "^7.7.4",
|
||||
"serve-static": "2.2.1",
|
||||
"slack": "11.0.2",
|
||||
"vite": "7.3.1",
|
||||
"x-var": "^3.0.1",
|
||||
"zustand": "^5.0.10"
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.28.6",
|
||||
"@babel/core": "7.29.0",
|
||||
"@babel/eslint-parser": "7.28.6",
|
||||
"@babel/preset-env": "7.28.6",
|
||||
"@babel/preset-env": "7.29.0",
|
||||
"@babel/preset-react": "7.28.5",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"chai": "6.2.2",
|
||||
"eslint": "9.39.2",
|
||||
"chalk": "^5.6.2",
|
||||
"eslint": "10.0.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-plugin-react": "7.37.5",
|
||||
"esmock": "2.7.3",
|
||||
"globals": "^17.3.0",
|
||||
"history": "5.3.0",
|
||||
"husky": "9.1.7",
|
||||
"less": "4.5.1",
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||
import { get } from '../mocks/mockNotification.js';
|
||||
import { mockFredy, providerConfig } from '../utils.js';
|
||||
import { expect } from 'chai';
|
||||
import * as provider from '../../lib/provider/immonet.js';
|
||||
|
||||
describe('#immonet testsuite()', () => {
|
||||
it('should test immonet provider', async () => {
|
||||
const Fredy = await mockFredy();
|
||||
provider.init(providerConfig.immonet, [], []);
|
||||
|
||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'immonet', similarityCache);
|
||||
const listing = await fredy.execute();
|
||||
|
||||
expect(listing).to.be.a('array');
|
||||
const notificationObj = get();
|
||||
expect(notificationObj).to.be.a('object');
|
||||
expect(notificationObj.serviceName).to.equal('immonet');
|
||||
notificationObj.payload.forEach((notify) => {
|
||||
/** check the actual structure **/
|
||||
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.size).that.does.include('m²');
|
||||
expect(notify.title).to.be.not.empty;
|
||||
expect(notify.address).to.be.not.empty;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,10 +8,6 @@
|
||||
"url": "https://www.immobilien.de/Wohnen/Suchergebnisse-51797.html?search._digest=true&search._filter=wohnen&search.flaeche_von=50&search.objektart=wohnung&search.preis_bis=1200&search.typ=mieten&search.umkreis=15&search.wo=district%3A2434%2C2695%2C2621%2C2700%2C2967%2C2734%2C2909%2C2955%2C2392%2C2746%2C2767%2C2982%2C2904%2C2612%2C2892%2C2587%2C2871%2C2975%2C2591%2C2887%2C2569%2C2640%2C2735&sort_col=*created_ts&sort_dir=desc",
|
||||
"enabled": true
|
||||
},
|
||||
"immonet": {
|
||||
"url": "https://www.immonet.de/classified-search?distributionTypes=Buy,Buy_Auction,Compulsory_Auction&estateTypes=House,Apartment&locations=AD08DE2112&order=Default&m=homepage_new_search_classified_search_result",
|
||||
"enabled": true
|
||||
},
|
||||
"immowelt": {
|
||||
"url": "https://www.immowelt.de/classified-search?distributionTypes=Buy,Buy_Auction,Compulsory_Auction&estateTypes=House,Apartment&locations=AD08DE2350",
|
||||
"enabled": true
|
||||
|
||||
@@ -14,12 +14,6 @@
|
||||
"shouldBecome": "https://www.wg-gesucht.de/1-zimmer-wohnungen-in-Dusseldorf.30.1.1.0.html?sort_column=0&sort_order=0",
|
||||
"id": "wgGesucht"
|
||||
},
|
||||
|
||||
{
|
||||
"url": "https://www.immonet.de/immobiliensuche/sel.do?sortby=0&suchart=1&objecttype=1&marketingtype=2&parentcat=1&locationname=d%C3%BCsseldorf",
|
||||
"shouldBecome": "https://www.immonet.de/immobiliensuche/sel.do?sortby=19&suchart=1&objecttype=1&marketingtype=2&parentcat=1&locationname=d%C3%BCsseldorf",
|
||||
"id": "immonet"
|
||||
},
|
||||
{
|
||||
"url": "https://www.neubaukompass.de/neubau-immobilien/berlin-region/",
|
||||
"shouldBecome": "https://www.neubaukompass.de/neubau-immobilien/berlin-region/?Sortierung=Id&Richtung=DESC",
|
||||
|
||||
196
tools/release/release.js
Normal file
@@ -0,0 +1,196 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { execSync, spawn } from 'child_process';
|
||||
import fetch from 'node-fetch';
|
||||
import chalk from 'chalk';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
/**
|
||||
* Release Tool for Fredy
|
||||
*
|
||||
* This tool automates the process of creating a GitHub release.
|
||||
* It fetches the latest release, compares it with the current master branch,
|
||||
* allows manual editing of commit messages, and creates a new release on GitHub.
|
||||
*/
|
||||
|
||||
// Define __dirname for ESM
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Configuration and Paths
|
||||
const CONFIG_PATH = path.join(__dirname, 'config.json');
|
||||
const PACKAGE_JSON_PATH = path.join(__dirname, '../../package.json');
|
||||
const REPO = 'orangecoding/fredy';
|
||||
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
|
||||
const GITHUB_TOKEN = config.github_token;
|
||||
|
||||
/**
|
||||
* Main function to execute the release process
|
||||
*/
|
||||
async function createRelease() {
|
||||
/* eslint-disable no-console */
|
||||
try {
|
||||
console.log(chalk.cyan('🚀 Starting release process...'));
|
||||
|
||||
// 1. Load Configuration
|
||||
if (!fs.existsSync(CONFIG_PATH)) {
|
||||
console.error(chalk.red('❌ Error: config.json not found in tools/release/'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!GITHUB_TOKEN) {
|
||||
console.error(chalk.red('❌ Error: GitHub token not configured.'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 2. Get current version from package.json
|
||||
const packageJson = JSON.parse(fs.readFileSync(PACKAGE_JSON_PATH, 'utf8'));
|
||||
const version = packageJson.version;
|
||||
const tag = version; // Using version as tag
|
||||
|
||||
console.log(chalk.blue(`📦 Target version: ${version}`));
|
||||
|
||||
// 3. Check if release already exists
|
||||
console.log(chalk.yellow('🔍 Checking if release already exists...'));
|
||||
const existingReleaseResponse = await fetch(`https://api.github.com/repos/${REPO}/releases/tags/${tag}`, {
|
||||
headers: {
|
||||
Authorization: `token ${GITHUB_TOKEN}`,
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
},
|
||||
});
|
||||
|
||||
if (existingReleaseResponse.status === 200) {
|
||||
console.error(chalk.red(`❌ Error: A release with tag ${tag} already exists.`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 4. Fetch latest release to find the starting point for the diff
|
||||
console.log(chalk.yellow('📡 Fetching latest release from GitHub...'));
|
||||
const latestReleaseResponse = await fetch(`https://api.github.com/repos/${REPO}/releases/latest`, {
|
||||
headers: {
|
||||
Authorization: `token ${GITHUB_TOKEN}`,
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!latestReleaseResponse.ok) {
|
||||
console.error(chalk.red('❌ Error fetching latest release.'));
|
||||
const errorData = await latestReleaseResponse.json();
|
||||
console.error(chalk.red(JSON.stringify(errorData)));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const latestRelease = await latestReleaseResponse.json();
|
||||
const latestTag = latestRelease.tag_name;
|
||||
console.log(chalk.green(`✅ Latest release found: ${latestTag}`));
|
||||
|
||||
// 5. Ensure the latest tag is available locally
|
||||
console.log(chalk.yellow(`📡 Fetching tag ${latestTag} from remote...`));
|
||||
try {
|
||||
execSync(`git fetch origin tag ${latestTag} --no-tags`);
|
||||
} catch (error) {
|
||||
console.error(chalk.red(`❌ Error fetching tag ${latestTag} from origin.`));
|
||||
console.error(error.message);
|
||||
// We don't exit here, maybe it's already there but fetch failed for some reason
|
||||
}
|
||||
|
||||
// 6. Get commit messages between latest tag and current HEAD
|
||||
console.log(chalk.yellow(`Git diff: ${latestTag} .. HEAD`));
|
||||
let commitMessages;
|
||||
try {
|
||||
commitMessages = execSync(`git log ${latestTag}..HEAD --pretty=format:"- %s"`).toString().trim();
|
||||
} catch (error) {
|
||||
console.error(chalk.red('❌ Error running git log. Make sure the latest tag is available locally.'), error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!commitMessages) {
|
||||
console.log(chalk.magenta('⚠️ No new commits found since last release.'));
|
||||
commitMessages = '- No changes recorded';
|
||||
}
|
||||
|
||||
// 7. Open commit messages in editor for manual adjustment
|
||||
const tempFilePath = path.join(__dirname, 'CHANGELOG_EDIT.tmp');
|
||||
const initialContent = `# Release Notes for ${version}\n# Edit the messages below. Lines starting with # will be ignored.\n\n${commitMessages}`;
|
||||
fs.writeFileSync(tempFilePath, initialContent);
|
||||
|
||||
console.log(chalk.blue('📝 Opening editor for release notes (using nano or $EDITOR)...'));
|
||||
await openInEditor(tempFilePath);
|
||||
|
||||
// 8. Read edited content
|
||||
let editedContent = fs
|
||||
.readFileSync(tempFilePath, 'utf8')
|
||||
.split('\n')
|
||||
.filter((line) => !line.startsWith('#'))
|
||||
.join('\n')
|
||||
.trim();
|
||||
|
||||
fs.unlinkSync(tempFilePath); // Clean up temp file
|
||||
|
||||
if (!editedContent) {
|
||||
console.error(chalk.red('❌ Release notes are empty. Aborting release.'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 9. Create the new release
|
||||
console.log(chalk.cyan(`🚀 Creating release ${version} on GitHub...`));
|
||||
const createResponse = await fetch(`https://api.github.com/repos/${REPO}/releases`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `token ${GITHUB_TOKEN}`,
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
tag_name: tag,
|
||||
name: version,
|
||||
body: editedContent,
|
||||
draft: false,
|
||||
prerelease: false,
|
||||
}),
|
||||
});
|
||||
|
||||
if (createResponse.status === 201) {
|
||||
const data = await createResponse.json();
|
||||
console.log(chalk.green('🎉 Release successfully created!'));
|
||||
console.log(chalk.green(`🔗 URL: ${data.html_url}`));
|
||||
} else {
|
||||
const errorData = await createResponse.json();
|
||||
console.error(chalk.red('❌ Failed to create release.'));
|
||||
console.error(chalk.red(JSON.stringify(errorData, null, 2)));
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(chalk.red('💥 An unexpected error occurred:'));
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to open a file in a terminal editor
|
||||
* @param {string} filePath
|
||||
*/
|
||||
function openInEditor(filePath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const editor = process.env.EDITOR || 'nano';
|
||||
const child = spawn(editor, [filePath], {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
child.on('exit', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Editor exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
await createRelease();
|
||||
/* eslint-enable no-console */
|
||||
123
ui/src/App.jsx
@@ -19,7 +19,7 @@ import Jobs from './views/jobs/Jobs';
|
||||
|
||||
import './App.less';
|
||||
import TrackingModal from './components/tracking/TrackingModal.jsx';
|
||||
import { Banner, Divider } from '@douyinfe/semi-ui-19';
|
||||
import { Banner } from '@douyinfe/semi-ui-19';
|
||||
import VersionBanner from './components/version/VersionBanner.jsx';
|
||||
import Listings from './views/listings/Listings.jsx';
|
||||
import MapView from './views/listings/Map.jsx';
|
||||
@@ -28,6 +28,8 @@ import { Layout } from '@douyinfe/semi-ui-19';
|
||||
import FredyFooter from './components/footer/FredyFooter.jsx';
|
||||
import WatchlistManagement from './views/listings/management/WatchlistManagement.jsx';
|
||||
import Dashboard from './views/dashboard/Dashboard.jsx';
|
||||
import ListingDetail from './views/listings/ListingDetail.jsx';
|
||||
import NewsModal from './components/news/NewsModal.jsx';
|
||||
|
||||
export default function FredyApp() {
|
||||
const actions = useActions();
|
||||
@@ -47,6 +49,7 @@ export default function FredyApp() {
|
||||
await actions.generalSettings.getGeneralSettings();
|
||||
await actions.userSettings.getUserSettings();
|
||||
await actions.versionUpdate.getVersionUpdate();
|
||||
await actions.tracking.getTrackingPois();
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -59,7 +62,7 @@ export default function FredyApp() {
|
||||
};
|
||||
|
||||
const isAdmin = () => currentUser != null && currentUser.isAdmin;
|
||||
const { Footer, Sider, Content } = Layout;
|
||||
const { Sider, Content } = Layout;
|
||||
|
||||
return loading ? null : needsLogin() ? (
|
||||
<Routes>
|
||||
@@ -68,11 +71,11 @@ export default function FredyApp() {
|
||||
</Routes>
|
||||
) : (
|
||||
<Layout className="app">
|
||||
<Layout className="app">
|
||||
<Sider>
|
||||
<Navigation isAdmin={isAdmin()} />
|
||||
</Sider>
|
||||
<Content>
|
||||
<Sider>
|
||||
<Navigation isAdmin={isAdmin()} />
|
||||
</Sider>
|
||||
<Layout className="app__main">
|
||||
<Content className="app__content">
|
||||
{versionUpdate?.newVersion && <VersionBanner />}
|
||||
{settings.demoMode && (
|
||||
<>
|
||||
@@ -87,68 +90,58 @@ export default function FredyApp() {
|
||||
</>
|
||||
)}
|
||||
{settings.analyticsEnabled === null && !settings.demoMode && <TrackingModal />}
|
||||
<Divider />
|
||||
<div className="app__content">
|
||||
<Routes>
|
||||
<Route path="/403" element={<InsufficientPermission />} />
|
||||
<Route path="/jobs/new" element={<JobMutation />} />
|
||||
<Route path="/jobs/edit/:jobId" element={<JobMutation />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/jobs" element={<Jobs />} />
|
||||
<Route path="/listings" element={<Listings />} />
|
||||
<Route path="/map" element={<MapView />} />
|
||||
<Route path="/watchlistManagement" element={<WatchlistManagement />} />
|
||||
{!settings.demoMode && <NewsModal />}
|
||||
<Routes>
|
||||
<Route path="/403" element={<InsufficientPermission />} />
|
||||
<Route path="/jobs/new" element={<JobMutation />} />
|
||||
<Route path="/jobs/edit/:jobId" element={<JobMutation />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/jobs" element={<Jobs />} />
|
||||
<Route path="/listings" element={<Listings />} />
|
||||
<Route path="/listings/listing/:listingId" element={<ListingDetail />} />
|
||||
<Route path="/map" element={<MapView />} />
|
||||
<Route path="/watchlistManagement" element={<WatchlistManagement />} />
|
||||
|
||||
{/* Permission-aware routes */}
|
||||
<Route
|
||||
path="/users/new"
|
||||
element={
|
||||
<PermissionAwareRoute currentUser={currentUser}>
|
||||
<UserMutator />
|
||||
</PermissionAwareRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/users/edit/:userId"
|
||||
element={
|
||||
<PermissionAwareRoute currentUser={currentUser}>
|
||||
<UserMutator />
|
||||
</PermissionAwareRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/users"
|
||||
element={
|
||||
<PermissionAwareRoute currentUser={currentUser}>
|
||||
<Users />
|
||||
</PermissionAwareRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/userSettings"
|
||||
element={
|
||||
<PermissionAwareRoute currentUser={currentUser} adminOnly={false}>
|
||||
<UserSettings />
|
||||
</PermissionAwareRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/generalSettings"
|
||||
element={
|
||||
<PermissionAwareRoute currentUser={currentUser}>
|
||||
<GeneralSettings />
|
||||
</PermissionAwareRoute>
|
||||
}
|
||||
/>
|
||||
{/* Permission-aware routes */}
|
||||
<Route
|
||||
path="/users/new"
|
||||
element={
|
||||
<PermissionAwareRoute currentUser={currentUser}>
|
||||
<UserMutator />
|
||||
</PermissionAwareRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/users/edit/:userId"
|
||||
element={
|
||||
<PermissionAwareRoute currentUser={currentUser}>
|
||||
<UserMutator />
|
||||
</PermissionAwareRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/users"
|
||||
element={
|
||||
<PermissionAwareRoute currentUser={currentUser}>
|
||||
<Users />
|
||||
</PermissionAwareRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/userSettings" element={<UserSettings />} />
|
||||
<Route
|
||||
path="/generalSettings"
|
||||
element={
|
||||
<PermissionAwareRoute currentUser={currentUser}>
|
||||
<GeneralSettings />
|
||||
</PermissionAwareRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
</div>
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
</Content>
|
||||
</Layout>
|
||||
<Footer>
|
||||
<FredyFooter />
|
||||
</Footer>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,47 +1,31 @@
|
||||
.app {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
|
||||
&__main {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__content {
|
||||
margin: 1rem;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
position: relative;
|
||||
padding: 24px;
|
||||
background-color: var(--semi-color-bg-0);
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ui.inverted.segment {
|
||||
background: #31303078 !important;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
a {outline : none;}
|
||||
|
||||
.semi-icon:not(.semi-tabs-bar .semi-tabs-tab .semi-icon) {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { HashRouter } from 'react-router-dom';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import en_US from '@douyinfe/semi-ui-19/lib/es/locale/source/en_US';
|
||||
|
||||
BIN
ui/src/assets/news/1.png
Normal file
|
After Width: | Height: | Size: 531 KiB |
BIN
ui/src/assets/news/2.png
Normal file
|
After Width: | Height: | Size: 4.8 MiB |
BIN
ui/src/assets/news/3.png
Normal file
|
After Width: | Height: | Size: 3.7 MiB |
21
ui/src/assets/news/news.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"key": "00e6b81777a275f5a140fc9101cb943810db6a69f6eb3927319c5aee0c876509",
|
||||
"content":
|
||||
[
|
||||
{
|
||||
"title": "Welcome to Fredy",
|
||||
"text": "Thanks for choosing Fredy!<br/>With Fredy you can quickly set up scraping jobs that run automatically every few minutes and continuously search platforms like ImmoScout and similar sites for new listings that match your criteria. Instead of manually refreshing pages and risking to miss out on good offers, Fredy monitors the market in the background and notifies you as soon as something relevant appears.",
|
||||
"image": "1.png"
|
||||
},
|
||||
{
|
||||
"title": "Stay in Control with the Grid View",
|
||||
"text": "The grid view gives you a clean and structured overview of all matching listings. You can instantly compare prices, sizes, locations and key details without leaving Fredy. Open listings in a focused detail view, bookmark interesting ones and keep track of what you have already checked.",
|
||||
"image": "2.png"
|
||||
},
|
||||
{
|
||||
"title": "Explore Listings on the Map",
|
||||
"text": "Switch to the map view to understand the bigger picture. See exactly where listings are located and evaluate neighborhoods at a glance. This helps you spot hidden gems, avoid unwanted areas and make faster, better decisions based on location.<br/>If you want Fredy to calculate commute times for you, head over to the User-Specific Settings and add your home address.",
|
||||
"image": "3.png"
|
||||
}
|
||||
]
|
||||
}
|
||||
70
ui/src/components/ListingDeletionModal.jsx
Normal file
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Modal, Radio, RadioGroup, Typography } from '@douyinfe/semi-ui-19';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const ListingDeletionModal = ({
|
||||
visible,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
title = 'Delete Listings',
|
||||
showOptions = true,
|
||||
message = 'How would you like to delete the selected listing(s)?',
|
||||
}) => {
|
||||
const [deleteType, setDeleteType] = useState('soft');
|
||||
|
||||
const handleOk = () => {
|
||||
onConfirm(!showOptions || deleteType === 'hard');
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={title}
|
||||
visible={visible}
|
||||
onOk={handleOk}
|
||||
onCancel={onCancel}
|
||||
okText="Confirm"
|
||||
cancelText="Cancel"
|
||||
style={{ maxWidth: '500px' }}
|
||||
>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Text>{message}</Text>
|
||||
</div>
|
||||
{showOptions && (
|
||||
<RadioGroup value={deleteType} onChange={(e) => setDeleteType(e.target.value)} style={{ width: '100%' }}>
|
||||
<Radio value="soft" style={{ alignItems: 'flex-start', width: '100%' }}>
|
||||
<div style={{ marginLeft: 8 }}>
|
||||
<Text strong>Mark as deleted (Soft Delete)</Text>
|
||||
<br />
|
||||
<Text type="secondary">
|
||||
Listings are kept in the database but marked as hidden. They will <b>not</b> re-appear during the next
|
||||
scraping session.
|
||||
</Text>
|
||||
</div>
|
||||
</Radio>
|
||||
<Radio value="hard" style={{ marginTop: 16, alignItems: 'flex-start', width: '100%' }}>
|
||||
<div style={{ marginLeft: 8 }}>
|
||||
<Text strong>Remove from database (Hard Delete)</Text>
|
||||
<br />
|
||||
<Text type="secondary">
|
||||
Listings are completely removed from the database.
|
||||
<br />
|
||||
<Text type="warning">
|
||||
Consequence: They might re-appear when scraping the next time because Fredy won't know they were
|
||||
previously found.
|
||||
</Text>
|
||||
</Text>
|
||||
</div>
|
||||
</Radio>
|
||||
</RadioGroup>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListingDeletionModal;
|
||||
@@ -1,92 +1,80 @@
|
||||
@import "DashboardCardColors.less";
|
||||
|
||||
.color-variant(@bg, @border, @text) {
|
||||
background-color: @bg;
|
||||
border: 1px solid @border;
|
||||
color: @text;
|
||||
}
|
||||
|
||||
.dashboard-card {
|
||||
box-sizing: border-box;
|
||||
padding: .8rem;
|
||||
border-radius: .5rem;
|
||||
border-width: 1px;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 6px 20px rgba(0,0,0,0.08);
|
||||
/* Make all KPI boxes the same size regardless of content/font */
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
height: 10rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 140px;
|
||||
margin-bottom: 16px;
|
||||
transition: transform 0.2s;
|
||||
background-color: rgba(36, 36, 36, 0.9);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--semi-color-border);
|
||||
--pulse-color: rgba(255, 255, 255, 0.1);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
overflow: visible;
|
||||
|
||||
&.blue {
|
||||
.color-variant(@color-blue-bg, @color-blue-border, @color-blue-text);
|
||||
}
|
||||
|
||||
&.orange {
|
||||
.color-variant(@color-orange-bg, @color-orange-border, @color-orange-text);
|
||||
}
|
||||
|
||||
&.green {
|
||||
.color-variant(@color-green-bg, @color-green-border, @color-green-text);
|
||||
}
|
||||
|
||||
&.purple {
|
||||
.color-variant(@color-purple-bg, @color-purple-border, @color-purple-text);
|
||||
}
|
||||
|
||||
&.gray {
|
||||
.color-variant(@color-gray-bg, @color-gray-border, @color-gray-text);
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .6rem;
|
||||
/* Keep header from growing content height */
|
||||
min-height: 2rem;
|
||||
overflow: hidden;
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
border-radius: inherit;
|
||||
box-shadow: 0 4px 25px -2px var(--pulse-color);
|
||||
opacity: 0;
|
||||
animation: pulse 5s infinite ease-in-out;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
will-change: opacity;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
border-radius: .6rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&__content {
|
||||
margin-top: .4rem;
|
||||
font-size: .7rem;
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__value {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1.1;
|
||||
color: #fff;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
color: var(--semi-color-text-0);
|
||||
}
|
||||
|
||||
&__desc {
|
||||
opacity: .8;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
&.blue {
|
||||
--pulse-color: var(--semi-color-primary);
|
||||
box-shadow: 0 4px 20px -5px var(--pulse-color);
|
||||
}
|
||||
|
||||
&.orange {
|
||||
--pulse-color: var(--semi-color-warning);
|
||||
box-shadow: 0 4px 20px -5px var(--pulse-color);
|
||||
}
|
||||
|
||||
&.green {
|
||||
--pulse-color: var(--semi-color-success);
|
||||
box-shadow: 0 4px 20px -5px var(--pulse-color);
|
||||
}
|
||||
|
||||
&.purple {
|
||||
--pulse-color: var(--semi-color-info);
|
||||
box-shadow: 0 4px 20px -5px var(--pulse-color);
|
||||
}
|
||||
|
||||
&.gray {
|
||||
--pulse-color: rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 4px 20px -5px var(--pulse-color);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 0.1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,7 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
import React from 'react';
|
||||
|
||||
import { Card, Typography, Space } from '@douyinfe/semi-ui-19';
|
||||
import './DashboardCard.less';
|
||||
|
||||
export default function KpiCard({
|
||||
@@ -20,21 +15,28 @@ export default function KpiCard({
|
||||
color = 'gray',
|
||||
children,
|
||||
}) {
|
||||
const { Text } = Typography;
|
||||
return (
|
||||
<div className={`dashboard-card ${color}`}>
|
||||
<div className="dashboard-card__header">
|
||||
<div className="dashboard-card__icon">{icon}</div>
|
||||
<div className="dashboard-card__title">
|
||||
<span>{title}</span>
|
||||
<Card className={`dashboard-card ${color}`} bodyStyle={{ padding: '16px' }}>
|
||||
<Space vertical align="start" spacing="tight" style={{ width: '100%' }}>
|
||||
<Space>
|
||||
<div className="dashboard-card__icon">{icon}</div>
|
||||
<Text strong className="dashboard-card__title">
|
||||
{title}
|
||||
</Text>
|
||||
</Space>
|
||||
<div className="dashboard-card__content">
|
||||
<div className="dashboard-card__value" style={{ fontSize: valueFontSize }}>
|
||||
{value}
|
||||
{children}
|
||||
</div>
|
||||
{description && (
|
||||
<Text size="small" type="tertiary" className="dashboard-card__desc">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="dashboard-card__content">
|
||||
<p className="dashboard-card__value" style={{ fontSize: valueFontSize }}>
|
||||
{value}
|
||||
{children}
|
||||
</p>
|
||||
{description && <span className="dashboard-card__desc">{description}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,22 +3,25 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import './FredyFooter.less';
|
||||
import { useSelector } from '../../services/state/store.js';
|
||||
import { Typography } from '@douyinfe/semi-ui-19';
|
||||
import { Typography, Layout, Space, Divider } from '@douyinfe/semi-ui-19';
|
||||
|
||||
export default function FredyFooter() {
|
||||
const { Text } = Typography;
|
||||
const { Footer } = Layout;
|
||||
const version = useSelector((state) => state.versionUpdate.versionUpdate);
|
||||
|
||||
return (
|
||||
<div className="fredyFooter">
|
||||
<div className="fredyFooter__version">
|
||||
<Text type="tertiary">Fredy V{version?.localFredyVersion || 'N/A'}</Text>
|
||||
</div>
|
||||
<div className="fredyFooter__copyRight">
|
||||
<Text link={{ href: 'https://github.com/orangecoding', target: '_blank' }}>Made with ❤️</Text>
|
||||
</div>
|
||||
</div>
|
||||
<Footer className="fredyFooter">
|
||||
<Space split={<Divider layout="vertical" />}>
|
||||
<Text type="tertiary" size="small">
|
||||
Fredy V{version?.localFredyVersion || 'N/A'}
|
||||
</Text>
|
||||
<Text size="small" link={{ href: 'https://github.com/orangecoding', target: '_blank' }}>
|
||||
Made with ❤️
|
||||
</Text>
|
||||
</Space>
|
||||
</Footer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,12 @@
|
||||
.fredyFooter {
|
||||
background:rgb(53, 54, 60);
|
||||
color: white;
|
||||
background-color: var(--semi-color-bg-1);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
height: 1.7rem;
|
||||
border-radius: .3rem;
|
||||
border-top: 1px solid #45464b;
|
||||
|
||||
&__version {
|
||||
padding-left: .5rem;
|
||||
font-size: small;
|
||||
|
||||
}
|
||||
&__copyRight {
|
||||
padding-right: 1rem;
|
||||
|
||||
}
|
||||
padding: 0 1rem;
|
||||
height: 32px;
|
||||
border-top: 1px solid var(--semi-color-border);
|
||||
z-index: 1000;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Col,
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
IconPlusCircle,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import ListingDeletionModal from '../../ListingDeletionModal.jsx';
|
||||
import { useActions, useSelector } from '../../../services/state/store.js';
|
||||
import { xhrDelete, xhrPut, xhrPost } from '../../../services/xhr.js';
|
||||
import debounce from 'lodash/debounce';
|
||||
@@ -60,6 +61,9 @@ const JobGrid = () => {
|
||||
const [activityFilter, setActivityFilter] = useState(null);
|
||||
const [showFilterBar, setShowFilterBar] = useState(false);
|
||||
|
||||
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
||||
const [pendingDeletion, setPendingDeletion] = useState(null); // { type: 'job'|'listings', jobId }
|
||||
|
||||
const pendingJobIdRef = useRef(null);
|
||||
const evtSourceRef = useRef(null);
|
||||
|
||||
@@ -125,24 +129,35 @@ const JobGrid = () => {
|
||||
};
|
||||
}, [handleFilterChange]);
|
||||
|
||||
const onJobRemoval = async (jobId) => {
|
||||
try {
|
||||
await xhrDelete('/api/jobs', { jobId });
|
||||
Toast.success('Job successfully removed');
|
||||
loadData();
|
||||
actions.jobsData.getJobs(); // refresh select list too
|
||||
} catch (error) {
|
||||
Toast.error(error);
|
||||
}
|
||||
const onJobRemoval = (jobId) => {
|
||||
setPendingDeletion({ type: 'job', jobId });
|
||||
setDeleteModalVisible(true);
|
||||
};
|
||||
|
||||
const onListingRemoval = async (jobId) => {
|
||||
const onListingRemoval = (jobId) => {
|
||||
setPendingDeletion({ type: 'listings', jobId });
|
||||
setDeleteModalVisible(true);
|
||||
};
|
||||
|
||||
const confirmDeletion = async (hardDelete) => {
|
||||
const { type, jobId } = pendingDeletion;
|
||||
try {
|
||||
await xhrDelete('/api/listings/job', { jobId });
|
||||
Toast.success('Listings successfully removed');
|
||||
if (type === 'job') {
|
||||
await xhrDelete('/api/jobs', { jobId });
|
||||
Toast.success('Job and listings successfully removed');
|
||||
} else if (type === 'listings') {
|
||||
await xhrDelete('/api/listings/job', { jobId, hardDelete });
|
||||
Toast.success('Listings successfully removed');
|
||||
}
|
||||
loadData();
|
||||
if (type === 'job') {
|
||||
actions.jobsData.getJobs(); // refresh select list too
|
||||
}
|
||||
} catch (error) {
|
||||
Toast.error(error);
|
||||
Toast.error(error.message || 'Error performing deletion');
|
||||
} finally {
|
||||
setDeleteModalVisible(false);
|
||||
setPendingDeletion(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -185,31 +200,21 @@ const JobGrid = () => {
|
||||
|
||||
return (
|
||||
<div className="jobGrid">
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Button
|
||||
style={{ width: '7rem', margin: 0 }}
|
||||
type="primary"
|
||||
icon={<IconPlusCircle />}
|
||||
className="jobs__newButton"
|
||||
onClick={() => navigate('/jobs/new')}
|
||||
>
|
||||
<Space vertical align="start" style={{ width: '100%', marginBottom: '16px' }} spacing="medium">
|
||||
<Button type="primary" icon={<IconPlusCircle />} onClick={() => navigate('/jobs/new')}>
|
||||
New Job
|
||||
</Button>
|
||||
|
||||
<div className="jobGrid__searchbar">
|
||||
<div className="jobGrid__searchbar" style={{ width: '100%' }}>
|
||||
<Input prefix={<IconSearch />} showClear placeholder="Search" onChange={handleFilterChange} />
|
||||
<Popover content="Filter / Sort Results" style={{ color: 'white', padding: '.5rem' }}>
|
||||
<div>
|
||||
<Button
|
||||
icon={<IconFilter />}
|
||||
onClick={() => {
|
||||
setShowFilterBar(!showFilterBar);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Popover>
|
||||
<Button
|
||||
icon={<IconFilter />}
|
||||
style={{ marginLeft: '8px' }}
|
||||
onClick={() => {
|
||||
setShowFilterBar(!showFilterBar);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Space>
|
||||
|
||||
{showFilterBar && (
|
||||
<div className="jobGrid__toolbar">
|
||||
@@ -277,7 +282,6 @@ const JobGrid = () => {
|
||||
<Card
|
||||
className="jobGrid__card"
|
||||
bodyStyle={{ padding: '16px' }}
|
||||
headerLine={true}
|
||||
title={
|
||||
<div className="jobGrid__header">
|
||||
<Title heading={5} ellipsis={{ showTooltip: true }} className="jobGrid__title">
|
||||
@@ -351,6 +355,8 @@ const JobGrid = () => {
|
||||
<div>
|
||||
<Button
|
||||
type="primary"
|
||||
style={{ background: '#21aa21b5' }}
|
||||
size="small"
|
||||
theme="solid"
|
||||
icon={<IconPlayCircle />}
|
||||
disabled={job.isOnlyShared || job.running}
|
||||
@@ -362,7 +368,7 @@ const JobGrid = () => {
|
||||
<div>
|
||||
<Button
|
||||
type="secondary"
|
||||
theme="solid"
|
||||
size="small"
|
||||
icon={<IconEdit />}
|
||||
disabled={job.isOnlyShared}
|
||||
onClick={() => navigate(`/jobs/edit/${job.id}`)}
|
||||
@@ -373,7 +379,7 @@ const JobGrid = () => {
|
||||
<div>
|
||||
<Button
|
||||
type="tertiary"
|
||||
theme="solid"
|
||||
size="small"
|
||||
icon={<IconCopy />}
|
||||
disabled={job.isOnlyShared}
|
||||
onClick={() => navigate('/jobs/new', { state: { cloneFrom: job.id } })}
|
||||
@@ -384,7 +390,7 @@ const JobGrid = () => {
|
||||
<div>
|
||||
<Button
|
||||
type="danger"
|
||||
theme="solid"
|
||||
size="small"
|
||||
icon={<IconDescend2 />}
|
||||
disabled={job.isOnlyShared}
|
||||
onClick={() => onListingRemoval(job.id)}
|
||||
@@ -395,7 +401,7 @@ const JobGrid = () => {
|
||||
<div>
|
||||
<Button
|
||||
type="danger"
|
||||
theme="solid"
|
||||
size="small"
|
||||
icon={<IconDelete />}
|
||||
disabled={job.isOnlyShared}
|
||||
onClick={() => onJobRemoval(job.id)}
|
||||
@@ -419,6 +425,21 @@ const JobGrid = () => {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ListingDeletionModal
|
||||
visible={deleteModalVisible}
|
||||
title={pendingDeletion?.type === 'job' ? 'Delete Job' : 'Delete Listings'}
|
||||
showOptions={pendingDeletion?.type !== 'job'}
|
||||
message={
|
||||
pendingDeletion?.type === 'job'
|
||||
? 'Are you sure you want to delete this job? All associated listings will be removed from the database.'
|
||||
: 'How would you like to delete the selected listing(s)?'
|
||||
}
|
||||
onConfirm={confirmDeletion}
|
||||
onCancel={() => {
|
||||
setDeleteModalVisible(false);
|
||||
setPendingDeletion(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
.jobGrid {
|
||||
&__card {
|
||||
height: 100%;
|
||||
transition: transform 0.2s;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
background-color: rgba(36, 36, 36, 0.9);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--semi-color-border);
|
||||
box-shadow: 0 0 15px -3px rgb(78 78 78 / 50%);
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--semi-shadow-elevated);
|
||||
box-shadow: 0 0 15px -3px rgb(78 78 78 / 70%);
|
||||
background-color: rgba(36, 36, 36, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,12 +24,14 @@
|
||||
|
||||
&__toolbar {
|
||||
&__card {
|
||||
border-radius: 5px;
|
||||
border-radius: var(--semi-border-radius-medium);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .3rem;
|
||||
background: #232429;
|
||||
background: rgba(36, 36, 36, 0.9);
|
||||
backdrop-filter: blur(8px);
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--semi-color-border);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Col,
|
||||
@@ -32,7 +32,10 @@ import {
|
||||
IconSearch,
|
||||
IconFilter,
|
||||
IconActivity,
|
||||
IconEyeOpened,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import ListingDeletionModal from '../../ListingDeletionModal.jsx';
|
||||
import no_image from '../../../assets/no_image.jpg';
|
||||
import * as timeService from '../../../services/time/timeService.js';
|
||||
import { xhrDelete, xhrPost } from '../../../services/xhr.js';
|
||||
@@ -49,6 +52,7 @@ const ListingsGrid = () => {
|
||||
const providers = useSelector((state) => state.provider);
|
||||
const jobs = useSelector((state) => state.jobsData.jobs);
|
||||
const actions = useActions();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 40;
|
||||
@@ -62,6 +66,9 @@ const ListingsGrid = () => {
|
||||
const [providerFilter, setProviderFilter] = useState(null);
|
||||
const [showFilterBar, setShowFilterBar] = useState(false);
|
||||
|
||||
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
||||
const [listingToDelete, setListingToDelete] = useState(null);
|
||||
|
||||
const loadData = () => {
|
||||
actions.listingsData.getListingsData({
|
||||
page,
|
||||
@@ -103,6 +110,19 @@ const ListingsGrid = () => {
|
||||
setPage(_page);
|
||||
};
|
||||
|
||||
const confirmDeletion = async (hardDelete) => {
|
||||
try {
|
||||
await xhrDelete('/api/listings/', { ids: [listingToDelete], hardDelete });
|
||||
Toast.success('Listing successfully removed');
|
||||
loadData();
|
||||
} catch (error) {
|
||||
Toast.error(error.message || 'Error deleting listing');
|
||||
} finally {
|
||||
setDeleteModalVisible(false);
|
||||
setListingToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const cap = (val) => {
|
||||
return String(val).charAt(0).toUpperCase() + String(val).slice(1);
|
||||
};
|
||||
@@ -223,6 +243,8 @@ const ListingsGrid = () => {
|
||||
<Col key={item.id} xs={24} sm={12} md={8} lg={6} xl={4} xxl={6}>
|
||||
<Card
|
||||
className={`listingsGrid__card ${!item.is_active ? 'listingsGrid__card--inactive' : ''}`}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate(`/listings/listing/${item.id}`)}
|
||||
cover={
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div className="listingsGrid__imageContainer">
|
||||
@@ -289,24 +311,28 @@ const ListingsGrid = () => {
|
||||
</Space>
|
||||
<Divider margin=".6rem" />
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<div className="listingsGrid__linkButton">
|
||||
<div className="listingsGrid__linkButton" onClick={(e) => e.stopPropagation()}>
|
||||
<a href={item.link} target="_blank" rel="noopener noreferrer">
|
||||
<IconLink />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="secondary"
|
||||
size="small"
|
||||
title="View Details"
|
||||
onClick={() => navigate(`/listings/listing/${item.id}`)}
|
||||
icon={<IconEyeOpened />}
|
||||
/>
|
||||
|
||||
<Button
|
||||
title="Remove"
|
||||
type="danger"
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await xhrDelete('/api/listings/', { ids: [item.id] });
|
||||
Toast.success('Listing(s) successfully removed');
|
||||
loadData();
|
||||
} catch (error) {
|
||||
Toast.error(error);
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setListingToDelete(item.id);
|
||||
setDeleteModalVisible(true);
|
||||
}}
|
||||
icon={<IconDelete />}
|
||||
/>
|
||||
@@ -327,6 +353,14 @@ const ListingsGrid = () => {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ListingDeletionModal
|
||||
visible={deleteModalVisible}
|
||||
onConfirm={confirmDeletion}
|
||||
onCancel={() => {
|
||||
setDeleteModalVisible(false);
|
||||
setListingToDelete(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -33,11 +33,15 @@
|
||||
|
||||
&__card {
|
||||
height: 100%;
|
||||
transition: transform 0.2s;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
background-color: rgba(36, 36, 36, 0.9);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--semi-color-border);
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--semi-shadow-elevated);
|
||||
background-color: rgba(36, 36, 36, 1);
|
||||
}
|
||||
|
||||
&--inactive {
|
||||
@@ -90,13 +94,15 @@
|
||||
}
|
||||
|
||||
&__toolbar {
|
||||
|
||||
&__card {
|
||||
border-radius: 5px;
|
||||
border-radius: var(--semi-border-radius-medium);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .3rem;
|
||||
background: #232429;
|
||||
background: rgba(36, 36, 36, 0.9);
|
||||
backdrop-filter: blur(8px);
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--semi-color-border);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,7 +111,7 @@
|
||||
}
|
||||
|
||||
&__linkButton {
|
||||
background: var(--semi-color-fill-0);
|
||||
background: var(--semi-color-primary);
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
font-weight: 600;
|
||||
@@ -115,5 +121,18 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 3px;
|
||||
|
||||
a {
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--semi-color-primary-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Typography } from '@douyinfe/semi-ui-19';
|
||||
|
||||
export default function Headline({ text, size = 3 } = {}) {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import logo from '../../assets/logo.png';
|
||||
import logoWhite from '../../assets/logo_white.png';
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Button } from '@douyinfe/semi-ui-19';
|
||||
import { xhrPost } from '../../services/xhr';
|
||||
import { IconUser } from '@douyinfe/semi-icons';
|
||||
|
||||
@@ -6,5 +6,6 @@
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Nav } from '@douyinfe/semi-ui-19';
|
||||
import { IconStar, IconSetting, IconTerminal, IconHistogram, IconSidebar } from '@douyinfe/semi-icons';
|
||||
import logoWhite from '../../assets/logo_white.png';
|
||||
@@ -70,20 +70,18 @@ export default function Navigation({ isAdmin }) {
|
||||
|
||||
return (
|
||||
<Nav
|
||||
style={{ height: '100%' }}
|
||||
style={{ height: '100%', maxWidth: collapsed ? '60px' : '240px' }}
|
||||
items={items}
|
||||
isCollapsed={collapsed}
|
||||
selectedKeys={[parsePathName(location.pathname)]}
|
||||
onSelect={(key) => {
|
||||
navigate(key.itemKey);
|
||||
}}
|
||||
header={<img src={collapsed ? heart : logoWhite} width={collapsed ? '80' : '160'} alt="Fredy Logo" />}
|
||||
header={<img src={collapsed ? heart : logoWhite} width={collapsed ? '30' : '120'} alt="Fredy Logo" />}
|
||||
footer={
|
||||
<Nav.Footer className="navigate__footer">
|
||||
<Logout text={!collapsed} />
|
||||
<Button icon={<IconSidebar />} onClick={() => setCollapsed(!collapsed)}>
|
||||
{!collapsed && 'Collapse'}
|
||||
</Button>
|
||||
<Button icon={<IconSidebar />} onClick={() => setCollapsed(!collapsed)} />
|
||||
</Nav.Footer>
|
||||
}
|
||||
/>
|
||||
|
||||
70
ui/src/components/news/NewsModal.jsx
Normal file
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { UserGuide } from '@douyinfe/semi-ui-19';
|
||||
import { useScreenWidth } from '../../hooks/screenWidth';
|
||||
import heart from '../../assets/heart.png';
|
||||
import newsConfig from '../../assets/news/news.json';
|
||||
import { useActions, useSelector } from '../../services/state/store';
|
||||
|
||||
import './NewsModal.less';
|
||||
|
||||
const newsImages = import.meta.glob('../../assets/news/*', { eager: true, query: '?url', import: 'default' });
|
||||
|
||||
const NewsModal = () => {
|
||||
const screenWidth = useScreenWidth();
|
||||
const newsHash = useSelector((state) => state.userSettings.settings.news_hash);
|
||||
const userSettingsLoaded = useSelector((state) => state.userSettings.loaded);
|
||||
const pois = useSelector((state) => state.tracking.pois);
|
||||
const actions = useActions();
|
||||
|
||||
if (newsConfig == null || newsConfig.length === 0 || screenWidth <= 768) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const steps = newsConfig.content.map((item) => ({
|
||||
title: (
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<img src={heart} width="30" alt="Fredy Logo" style={{ marginRight: '10px' }} />
|
||||
<b>{item.title}</b>
|
||||
</div>
|
||||
),
|
||||
description: (
|
||||
<div style={{ textAlign: 'left' }}>
|
||||
{item.image && newsImages[`../../assets/news/${item.image}`] && (
|
||||
<img
|
||||
src={newsImages[`../../assets/news/${item.image}`]}
|
||||
alt={item.title}
|
||||
style={{ width: '100%', marginBottom: 10, borderRadius: 4 }}
|
||||
/>
|
||||
)}
|
||||
<p dangerouslySetInnerHTML={{ __html: item.text }} />
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const handleClose = (poi) => {
|
||||
actions.userSettings.setNewsHash(newsConfig.key);
|
||||
if (poi) {
|
||||
actions.tracking.trackPoi(poi);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<UserGuide
|
||||
mode="modal"
|
||||
mask={true}
|
||||
steps={steps}
|
||||
visible={userSettingsLoaded && newsHash !== newsConfig.key}
|
||||
onFinish={() => handleClose(pois.WELCOME_FINISHED)}
|
||||
onSkip={() => handleClose(pois.WELCOME_SKIPPED)}
|
||||
modalProps={{
|
||||
width: '10rem',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewsModal;
|
||||
3
ui/src/components/news/NewsModal.less
Normal file
@@ -0,0 +1,3 @@
|
||||
.semi-userGuide-modal-body-title {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import insufficientPermission from '../../assets/insufficient_permission.png';
|
||||
|
||||
export default function InsufficientPermission() {
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { Navigate } from 'react-router-dom';
|
||||
|
||||
export default function PermissionAwareRoute({ currentUser, children }) {
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import './Placeholder.less';
|
||||
|
||||
function getPlaceholder(rowCount, className) {
|
||||
|
||||
@@ -3,17 +3,16 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Card } from '@douyinfe/semi-ui-19';
|
||||
|
||||
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;
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="segmentParts"
|
||||
className={`segmentParts ${className}`}
|
||||
title={
|
||||
(helpText || name) && (
|
||||
<Meta title={name} description={helpText} avatar={Icon == null ? null : <Icon size="extra-extra-small" />} />
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { Empty, Table, Button } from '@douyinfe/semi-ui-19';
|
||||
import { IconDelete, IconEdit } from '@douyinfe/semi-icons';
|
||||
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { Empty, Table, Button } from '@douyinfe/semi-ui-19';
|
||||
import { IconDelete, IconEdit } from '@douyinfe/semi-icons';
|
||||
import { Typography } from '@douyinfe/semi-ui';
|
||||
|
||||
export default function ProviderTable({ providerData = [], onRemove, onEdit } = {}) {
|
||||
const { Text } = Typography;
|
||||
return (
|
||||
<Table
|
||||
pagination={false}
|
||||
@@ -22,11 +22,7 @@ export default function ProviderTable({ providerData = [], onRemove, onEdit } =
|
||||
title: 'URL',
|
||||
dataIndex: 'url',
|
||||
render: (_, data) => {
|
||||
return (
|
||||
<a href={data.url} target="_blank" rel="noopener noreferrer">
|
||||
Visit site
|
||||
</a>
|
||||
);
|
||||
return <Text link={{ href: data.url, target: '_blank' }}>Open Provider</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
||||
import { format } from '../../services/time/timeService';
|
||||
import { Table, Button, Empty } from '@douyinfe/semi-ui-19';
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Modal } from '@douyinfe/semi-ui-19';
|
||||
import Logo from '../logo/Logo.jsx';
|
||||
import { xhrPost } from '../../services/xhr.js';
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Collapse, Descriptions } from '@douyinfe/semi-ui-19';
|
||||
import { useSelector } from '../../services/state/store.js';
|
||||
import { MarkdownRender } from '@douyinfe/semi-ui-19';
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
import { create } from 'zustand';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
import { xhrGet } from '../xhr.js';
|
||||
import { xhrGet, xhrPost } from '../xhr.js';
|
||||
import queryString from 'query-string';
|
||||
|
||||
const logger = (config) => (set, get, api) =>
|
||||
@@ -27,10 +27,21 @@ const logger = (config) => (set, get, api) =>
|
||||
api,
|
||||
);
|
||||
|
||||
/**
|
||||
* Middleware to track loading state of async actions.
|
||||
*/
|
||||
const loadingTracker = (config) => (set, get, api) => {
|
||||
const wrappedSet = (partial, replace) => {
|
||||
set(partial, replace);
|
||||
};
|
||||
|
||||
return config(wrappedSet, get, api);
|
||||
};
|
||||
|
||||
// Create the Zustand store with slices and actions
|
||||
export const useFredyState = create(
|
||||
logger(
|
||||
(set) => {
|
||||
loadingTracker((set) => {
|
||||
// Async actions that directly set state (no separate reducer concept)
|
||||
const effects = {
|
||||
dashboard: {
|
||||
@@ -169,6 +180,23 @@ export const useFredyState = create(
|
||||
}
|
||||
},
|
||||
},
|
||||
tracking: {
|
||||
async getTrackingPois() {
|
||||
try {
|
||||
const response = await xhrGet('/api/tracking/trackingPois');
|
||||
set((state) => ({ tracking: { ...state.tracking, pois: Object.freeze(response.json) } }));
|
||||
} catch (Exception) {
|
||||
console.error('Error while trying to get resource for api/tracking. Error:', Exception);
|
||||
}
|
||||
},
|
||||
async trackPoi(poi) {
|
||||
try {
|
||||
await xhrPost('/api/tracking/poi', { poi });
|
||||
} catch (Exception) {
|
||||
console.error('Error while trying to track poi. Error:', Exception);
|
||||
}
|
||||
},
|
||||
},
|
||||
listingsData: {
|
||||
async getListingsData({
|
||||
page = 1,
|
||||
@@ -195,6 +223,18 @@ export const useFredyState = create(
|
||||
console.error('Error while trying to get resource for api/listings. Error:', Exception);
|
||||
}
|
||||
},
|
||||
async getListing(listingId) {
|
||||
try {
|
||||
const response = await xhrGet(`/api/listings/${listingId}`);
|
||||
set((state) => ({
|
||||
listingsData: { ...state.listingsData, currentListing: response.json },
|
||||
}));
|
||||
return response.json;
|
||||
} catch (Exception) {
|
||||
console.error(`Error while trying to get resource for api/listings/${listingId}. Error:`, Exception);
|
||||
throw Exception;
|
||||
}
|
||||
},
|
||||
async getListingsForMap({ jobId, minPrice, maxPrice } = {}) {
|
||||
try {
|
||||
const qryString = queryString.stringify(
|
||||
@@ -222,9 +262,46 @@ export const useFredyState = create(
|
||||
async getUserSettings() {
|
||||
try {
|
||||
const response = await xhrGet('/api/user/settings');
|
||||
set((state) => ({ userSettings: { ...state.userSettings, settings: response.json } }));
|
||||
set((state) => ({ userSettings: { ...state.userSettings, settings: response.json, loaded: true } }));
|
||||
} catch (Exception) {
|
||||
console.error('Error while trying to get resource for api/user/settings. Error:', Exception);
|
||||
// Mark as loaded even on error to prevent blocking the UI
|
||||
set((state) => ({ userSettings: { ...state.userSettings, loaded: true } }));
|
||||
}
|
||||
},
|
||||
async setNewsHash(newsHash) {
|
||||
try {
|
||||
await xhrPost('/api/user/settings/news-hash', { news_hash: newsHash });
|
||||
set((state) => ({
|
||||
userSettings: {
|
||||
...state.userSettings,
|
||||
settings: { ...state.userSettings.settings, news_hash: newsHash },
|
||||
},
|
||||
}));
|
||||
} catch (Exception) {
|
||||
console.error('Error while trying to update news hash. Error:', Exception);
|
||||
throw Exception;
|
||||
}
|
||||
},
|
||||
async setHomeAddress(address) {
|
||||
try {
|
||||
const response = await xhrPost('/api/user/settings/home-address', { home_address: address });
|
||||
if (response.status === 200) {
|
||||
set((state) => ({
|
||||
userSettings: {
|
||||
...state.userSettings,
|
||||
settings: {
|
||||
...state.userSettings.settings,
|
||||
home_address: { address, coords: response.json.coords },
|
||||
},
|
||||
},
|
||||
}));
|
||||
return response.json;
|
||||
}
|
||||
throw response;
|
||||
} catch (Exception) {
|
||||
console.error('Error while trying to update home address. Error:', Exception);
|
||||
throw Exception;
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -239,12 +316,14 @@ export const useFredyState = create(
|
||||
page: 1,
|
||||
result: [],
|
||||
mapListings: [],
|
||||
currentListing: null,
|
||||
maxPrice: 0,
|
||||
},
|
||||
generalSettings: { settings: {} },
|
||||
userSettings: { settings: {} },
|
||||
userSettings: { settings: {}, loaded: false },
|
||||
demoMode: { demoMode: false },
|
||||
versionUpdate: {},
|
||||
tracking: { pois: {} },
|
||||
provider: [],
|
||||
jobsData: {
|
||||
jobs: [],
|
||||
@@ -263,6 +342,7 @@ export const useFredyState = create(
|
||||
generalSettings: { ...effects.generalSettings },
|
||||
demoMode: { ...effects.demoMode },
|
||||
versionUpdate: { ...effects.versionUpdate },
|
||||
tracking: { ...effects.tracking },
|
||||
listingsData: { ...effects.listingsData },
|
||||
provider: { ...effects.provider },
|
||||
jobsData: { ...effects.jobsData },
|
||||
@@ -270,12 +350,34 @@ export const useFredyState = create(
|
||||
userSettings: { ...effects.userSettings },
|
||||
};
|
||||
|
||||
// Wrap actions to track loading state
|
||||
const wrappedActions = {};
|
||||
Object.keys(actions).forEach((slice) => {
|
||||
wrappedActions[slice] = {};
|
||||
Object.keys(actions[slice]).forEach((actionName) => {
|
||||
const originalAction = actions[slice][actionName];
|
||||
if (typeof originalAction === 'function') {
|
||||
wrappedActions[slice][actionName] = async (...args) => {
|
||||
const fullActionName = `${slice}.${actionName}`;
|
||||
set((state) => ({ loading: { ...state.loading, [fullActionName]: true } }));
|
||||
try {
|
||||
return await originalAction(...args);
|
||||
} finally {
|
||||
set((state) => ({ loading: { ...state.loading, [fullActionName]: false } }));
|
||||
}
|
||||
};
|
||||
} else {
|
||||
wrappedActions[slice][actionName] = originalAction;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
...initial,
|
||||
__actions: { actions },
|
||||
loading: {},
|
||||
__actions: { actions: wrappedActions },
|
||||
};
|
||||
},
|
||||
{ name: 'fredy' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -299,3 +401,27 @@ export function useSelector(selector, equalityFn = shallow) {
|
||||
export function useActions() {
|
||||
return useFredyState((s) => s.__actions.actions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to check if a specific action is currently loading.
|
||||
* @param {Function} action - The action function from useActions()
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function useIsLoading(action) {
|
||||
const actions = useActions();
|
||||
const loading = useSelector((state) => state.loading);
|
||||
|
||||
// Find the action name by comparing the function
|
||||
let actionPath = null;
|
||||
for (const slice in actions) {
|
||||
for (const name in actions[slice]) {
|
||||
if (actions[slice][name] === action) {
|
||||
actionPath = `${slice}.${name}`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (actionPath) break;
|
||||
}
|
||||
|
||||
return !!loading[actionPath];
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
import { useSelector, useActions } from '../../services/state/store';
|
||||
import KpiCard from '../../components/cards/KpiCard.jsx';
|
||||
import PieChartCard from '../../components/cards/PieChartCard.jsx';
|
||||
import Headline from '../../components/headline/Headline.jsx';
|
||||
|
||||
import './Dashboard.less';
|
||||
import { SegmentPart } from '../../components/segment/SegmentPart.jsx';
|
||||
@@ -39,12 +38,10 @@ export default function Dashboard() {
|
||||
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<Headline text="Dashboard" size={3} />
|
||||
|
||||
<Row gutter={16} className="dashboard__row">
|
||||
<Row gutter={[16, 16]} className="dashboard__row">
|
||||
<Col span={12} xs={24} sm={24} md={24} lg={24} xl={12}>
|
||||
<SegmentPart name="General" Icon={IconTerminal}>
|
||||
<Row gutter={16} className="dashboard__row">
|
||||
<Row gutter={[16, 16]} className="dashboard__row">
|
||||
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
|
||||
<KpiCard
|
||||
title="Search Interval"
|
||||
@@ -104,7 +101,7 @@ export default function Dashboard() {
|
||||
</Col>
|
||||
<Col span={12} xs={24} sm={24} md={24} lg={24} xl={12}>
|
||||
<SegmentPart name="Overview" Icon={IconStar}>
|
||||
<Row gutter={16} className="dashboard__row">
|
||||
<Row gutter={[16, 16]} className="dashboard__row">
|
||||
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
|
||||
<KpiCard
|
||||
title="Jobs"
|
||||
@@ -136,7 +133,14 @@ export default function Dashboard() {
|
||||
<KpiCard
|
||||
title="Avg. Price"
|
||||
color="purple"
|
||||
value={`${!kpis.avgPriceOfListings ? '---' : kpis.avgPriceOfListings} €`}
|
||||
value={`${
|
||||
!kpis.avgPriceOfListings
|
||||
? '---'
|
||||
: new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
}).format(kpis.avgPriceOfListings)
|
||||
}`}
|
||||
icon={<IconNoteMoney />}
|
||||
description="Avg. Price of listings"
|
||||
/>
|
||||
@@ -146,8 +150,13 @@ export default function Dashboard() {
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<SegmentPart name="Provider Insights" Icon={IconStar} helpText="Percentage of found listings over all providers">
|
||||
<PieChartCard title="Jobs per Provider" data={pieData} isLoading={false} />
|
||||
<SegmentPart
|
||||
name="Provider Insights"
|
||||
Icon={IconStar}
|
||||
helpText="Percentage of found listings over all providers"
|
||||
className="dashboard__provider-insights"
|
||||
>
|
||||
<PieChartCard data={pieData} />
|
||||
</SegmentPart>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,33 @@
|
||||
.dashboard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
|
||||
&__row {
|
||||
margin-bottom: 1rem;
|
||||
/* Ensure grid items wrap to next line on narrow screens */
|
||||
margin-bottom: 24px;
|
||||
flex-wrap: wrap;
|
||||
/* Vertical gap of 1rem between wrapped grid items (no px) */
|
||||
|
||||
.semi-col {
|
||||
margin-bottom: 1rem;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import JobGrid from '../../components/grid/jobs/JobGrid.jsx';
|
||||
import './Jobs.less';
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React, { Fragment, useState } from 'react';
|
||||
import { Fragment, useState } from 'react';
|
||||
|
||||
import NotificationAdapterMutator from './components/notificationAdapter/NotificationAdapterMutator';
|
||||
import NotificationAdapterTable from '../../../components/table/NotificationAdapterTable';
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { transform } from '../../../../../services/transformer/notificationAdapterTransformer';
|
||||
import { xhrPost } from '../../../../../services/xhr';
|
||||
@@ -197,27 +197,6 @@ export default function NotificationAdapterMutator({
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{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 }} />}
|
||||
/>
|
||||
)}
|
||||
{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 }} />}
|
||||
/>
|
||||
)}
|
||||
|
||||
{description != null ? (
|
||||
<p>{description}</p>
|
||||
) : (
|
||||
@@ -264,6 +243,28 @@ export default function NotificationAdapterMutator({
|
||||
<br />
|
||||
{selectedAdapter.readme != null && <Help readme={selectedAdapter.readme} />}
|
||||
<br />
|
||||
|
||||
{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 }} />}
|
||||
/>
|
||||
)}
|
||||
{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 }} />}
|
||||
/>
|
||||
)}
|
||||
|
||||
{getFieldsFor(selectedAdapter)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Banner, MarkdownRender } from '@douyinfe/semi-ui-19';
|
||||
|
||||
export default function Help({ readme }) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
import { Banner, Modal, Select, Input } from '@douyinfe/semi-ui-19';
|
||||
import { transform } from '../../../../../services/transformer/providerTransformer';
|
||||
|
||||
388
ui/src/views/listings/ListingDetail.jsx
Normal file
@@ -0,0 +1,388 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useSelector, useActions } from '../../services/state/store.js';
|
||||
import {
|
||||
Typography,
|
||||
Button,
|
||||
Space,
|
||||
Card,
|
||||
Row,
|
||||
Col,
|
||||
Image,
|
||||
Tag,
|
||||
Divider,
|
||||
Descriptions,
|
||||
Banner,
|
||||
Spin,
|
||||
Toast,
|
||||
} from '@douyinfe/semi-ui-19';
|
||||
import {
|
||||
IconArrowLeft,
|
||||
IconMapPin,
|
||||
IconCart,
|
||||
IconClock,
|
||||
IconBriefcase,
|
||||
IconActivity,
|
||||
IconLink,
|
||||
IconStar,
|
||||
IconStarStroked,
|
||||
IconRealSize,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import no_image from '../../assets/no_image.jpg';
|
||||
import * as timeService from '../../services/time/timeService.js';
|
||||
import { distanceMeters, getBoundsFromCoords } from './mapUtils.js';
|
||||
import { xhrPost } from '../../services/xhr.js';
|
||||
|
||||
import './ListingDetail.less';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
const STYLES = {
|
||||
STANDARD: 'https://tiles.openfreemap.org/styles/bright',
|
||||
};
|
||||
|
||||
export default function ListingDetail() {
|
||||
const { listingId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const actions = useActions();
|
||||
const listing = useSelector((state) => state.listingsData.currentListing);
|
||||
const homeAddress = useSelector((state) => state.userSettings.settings.home_address);
|
||||
const mapContainer = useRef(null);
|
||||
const map = useRef(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchListing() {
|
||||
try {
|
||||
setLoading(true);
|
||||
await actions.listingsData.getListing(listingId);
|
||||
} catch (e) {
|
||||
console.error('Failed to load listing details:', e);
|
||||
Toast.error('Failed to load listing details');
|
||||
navigate('/listings');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
fetchListing();
|
||||
}, [listingId]);
|
||||
|
||||
const hasGeo =
|
||||
listing?.latitude != null && listing?.longitude != null && listing?.latitude !== -1 && listing?.longitude !== -1;
|
||||
|
||||
useEffect(() => {
|
||||
if (loading || !listing || !mapContainer.current || !hasGeo) return;
|
||||
|
||||
if (map.current) {
|
||||
map.current.remove();
|
||||
}
|
||||
|
||||
map.current = new maplibregl.Map({
|
||||
container: mapContainer.current,
|
||||
style: STYLES.STANDARD,
|
||||
center: [listing.longitude, listing.latitude],
|
||||
zoom: 14,
|
||||
cooperativeGestures: true,
|
||||
});
|
||||
|
||||
map.current.addControl(new maplibregl.NavigationControl(), 'top-right');
|
||||
|
||||
new maplibregl.Marker({ color: '#3FB1CE' })
|
||||
.setLngLat([listing.longitude, listing.latitude])
|
||||
.setPopup(new maplibregl.Popup({ offset: 25 }).setHTML(`<h4>Listing Location</h4><p>${listing.address}</p>`))
|
||||
.addTo(map.current);
|
||||
|
||||
if (homeAddress?.coords) {
|
||||
new maplibregl.Marker({ color: 'red' })
|
||||
.setLngLat([homeAddress.coords.lng, homeAddress.coords.lat])
|
||||
.setPopup(new maplibregl.Popup({ offset: 25 }).setHTML(`<h4>Home Address</h4><p>${homeAddress.address}</p>`))
|
||||
.addTo(map.current);
|
||||
|
||||
const bounds = getBoundsFromCoords([
|
||||
[listing.longitude, listing.latitude],
|
||||
[homeAddress.coords.lng, homeAddress.coords.lat],
|
||||
]);
|
||||
|
||||
map.current.fitBounds(bounds, {
|
||||
padding: 50,
|
||||
maxZoom: 15,
|
||||
});
|
||||
|
||||
const drawLine = () => {
|
||||
if (!map.current || !map.current.isStyleLoaded()) return;
|
||||
|
||||
const distance = distanceMeters(
|
||||
listing.latitude,
|
||||
listing.longitude,
|
||||
homeAddress.coords.lat,
|
||||
homeAddress.coords.lng,
|
||||
);
|
||||
|
||||
const midpoint = [
|
||||
(listing.longitude + homeAddress.coords.lng) / 2,
|
||||
(listing.latitude + homeAddress.coords.lat) / 2,
|
||||
];
|
||||
|
||||
if (map.current.getSource('route')) {
|
||||
map.current.getSource('route').setData({
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
{
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: [
|
||||
[listing.longitude, listing.latitude],
|
||||
[homeAddress.coords.lng, homeAddress.coords.lat],
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: midpoint,
|
||||
},
|
||||
properties: {
|
||||
distance: `${Math.round(distance)} m`,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
} else {
|
||||
map.current.addSource('route', {
|
||||
type: 'geojson',
|
||||
data: {
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
{
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: [
|
||||
[listing.longitude, listing.latitude],
|
||||
[homeAddress.coords.lng, homeAddress.coords.lat],
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: midpoint,
|
||||
},
|
||||
properties: {
|
||||
distance: `${Math.round(distance)} m`,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
map.current.addLayer({
|
||||
id: 'route',
|
||||
type: 'line',
|
||||
source: 'route',
|
||||
layout: {
|
||||
'line-join': 'round',
|
||||
'line-cap': 'round',
|
||||
},
|
||||
paint: {
|
||||
'line-color': '#3FB1CE',
|
||||
'line-width': 4,
|
||||
'line-dasharray': [2, 1],
|
||||
},
|
||||
filter: ['==', '$type', 'LineString'],
|
||||
});
|
||||
|
||||
map.current.addLayer({
|
||||
id: 'route-distance',
|
||||
type: 'symbol',
|
||||
source: 'route',
|
||||
layout: {
|
||||
'text-field': ['get', 'distance'],
|
||||
'text-size': 14,
|
||||
'text-offset': [0, -1],
|
||||
'text-allow-overlap': true,
|
||||
},
|
||||
paint: {
|
||||
'text-color': '#ffffff',
|
||||
'text-halo-color': '#3FB1CE',
|
||||
'text-halo-width': 2,
|
||||
},
|
||||
filter: ['==', '$type', 'Point'],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (map.current.isStyleLoaded()) {
|
||||
drawLine();
|
||||
} else {
|
||||
map.current.on('load', drawLine);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (map.current) {
|
||||
map.current.remove();
|
||||
map.current = null;
|
||||
}
|
||||
};
|
||||
}, [listing, loading, homeAddress]);
|
||||
|
||||
const handleWatch = async () => {
|
||||
try {
|
||||
await xhrPost('/api/listings/watch', { listingId: listing.id });
|
||||
Toast.success(listing.isWatched === 1 ? 'Removed from Watchlist' : 'Added to Watchlist');
|
||||
actions.listingsData.getListing(listingId);
|
||||
} catch (e) {
|
||||
console.error('Failed to operate Watchlist:', e);
|
||||
Toast.error('Failed to operate Watchlist');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!listing) return null;
|
||||
|
||||
const data = [
|
||||
{
|
||||
key: 'Job',
|
||||
value: listing.job_name,
|
||||
Icon: <IconBriefcase />,
|
||||
},
|
||||
{
|
||||
key: 'Provider',
|
||||
value: listing.provider.charAt(0).toUpperCase() + listing.provider.slice(1),
|
||||
Icon: <IconBriefcase />,
|
||||
},
|
||||
{ key: 'Price', value: `${listing.price} €`, Icon: <IconCart /> },
|
||||
{
|
||||
key: 'Size',
|
||||
value: listing.size ? `${listing.size} m²` : 'N/A',
|
||||
Icon: <IconRealSize />,
|
||||
},
|
||||
{
|
||||
key: 'Added',
|
||||
value: timeService.format(listing.created_at),
|
||||
Icon: <IconClock />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="listing-detail">
|
||||
<div className="listing-detail__back">
|
||||
<Button icon={<IconArrowLeft />} onClick={() => navigate(-1)} theme="borderless">
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="listing-detail__card">
|
||||
<div className="listing-detail__header">
|
||||
<Space vertical align="start" spacing="tight">
|
||||
<Title heading={2} className="listing-detail__title">
|
||||
{listing.title}
|
||||
</Title>
|
||||
<Space align="center">
|
||||
<IconMapPin style={{ fontSize: '18px', color: 'var(--semi-color-primary)' }} />
|
||||
<Text type="secondary">{listing.address || 'No address provided'}</Text>
|
||||
</Space>
|
||||
</Space>
|
||||
<Space wrap className="listing-detail__header-actions">
|
||||
<Button
|
||||
icon={
|
||||
listing.isWatched === 1 ? (
|
||||
<IconStar style={{ color: 'var(--semi-color-warning)' }} />
|
||||
) : (
|
||||
<IconStarStroked />
|
||||
)
|
||||
}
|
||||
onClick={handleWatch}
|
||||
theme="light"
|
||||
>
|
||||
{listing.isWatched === 1 ? 'Watched' : 'Watch'}
|
||||
</Button>
|
||||
<Text link={{ href: listing.link, target: '_blank' }} icon={<IconLink />} underline>
|
||||
Open listing
|
||||
</Text>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Row>
|
||||
<Col span={24} lg={12}>
|
||||
<div className="listing-detail__image-container">
|
||||
<Image
|
||||
src={listing.image_url}
|
||||
fallback={no_image}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
preview={true}
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={24} lg={12}>
|
||||
<div className="listing-detail__info-section">
|
||||
<Title heading={4} style={{ marginBottom: '1rem' }}>
|
||||
Details
|
||||
</Title>
|
||||
<Descriptions column={1}>
|
||||
{data.map((item, index) => (
|
||||
<Descriptions.Item key={index}>
|
||||
<Space>
|
||||
{item.Icon}
|
||||
{item.value}
|
||||
</Space>
|
||||
</Descriptions.Item>
|
||||
))}
|
||||
</Descriptions>
|
||||
<Divider margin="1.5rem" />
|
||||
<Title heading={4} style={{ marginBottom: '1rem' }}>
|
||||
Description
|
||||
</Title>
|
||||
<Text type="secondary" style={{ whiteSpace: 'pre-wrap' }}>
|
||||
{listing.description || 'No description available.'}
|
||||
</Text>
|
||||
|
||||
{listing.distance_to_destination && (
|
||||
<>
|
||||
<Divider margin="1.5rem" />
|
||||
<Space align="center">
|
||||
<IconActivity style={{ fontSize: '18px', color: 'var(--semi-color-primary)' }} />
|
||||
<Text strong>Distance to home:</Text>
|
||||
<Tag color="blue">{listing.distance_to_destination} m</Tag>
|
||||
</Space>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<div className="listing-detail__map-wrapper">
|
||||
<Title heading={3}>Location</Title>
|
||||
{!hasGeo ? (
|
||||
<Banner
|
||||
type="warning"
|
||||
bordered
|
||||
description="This listing has no valid geocoordinates, so we cannot show it on the map."
|
||||
style={{ marginTop: '1rem' }}
|
||||
/>
|
||||
) : (
|
||||
<div ref={mapContainer} className="listing-detail__map-container" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
110
ui/src/views/listings/ListingDetail.less
Normal file
@@ -0,0 +1,110 @@
|
||||
.listing-detail {
|
||||
padding-bottom: 2rem;
|
||||
|
||||
&__back {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
&__card {
|
||||
background-color: rgba(36, 36, 36, 0.9);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--semi-color-border);
|
||||
margin-bottom: 2rem;
|
||||
overflow: hidden;
|
||||
|
||||
.semi-card-body {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding: 1.5rem;
|
||||
gap: 1rem;
|
||||
border-bottom: 1px solid var(--semi-color-border);
|
||||
|
||||
@media (max-width: 768px) {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
& > .semi-space {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
margin-top: 0.5rem;
|
||||
|
||||
.semi-button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0 !important;
|
||||
word-break: break-word;
|
||||
@media (max-width: 768px) {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__image-container {
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
background-color: var(--semi-color-fill-0);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
height: 250px;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
&__info-section {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
&__map-container {
|
||||
height: 400px;
|
||||
width: 100%;
|
||||
border-radius: var(--semi-border-radius-medium);
|
||||
margin-top: 1rem;
|
||||
border: 1px solid var(--semi-color-border);
|
||||
|
||||
@media (max-width: 768px) {
|
||||
height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
&__map-wrapper {
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.info-tag {
|
||||
font-size: 0.9rem;
|
||||
padding: 0.2rem 0.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
.listing-detail-popup {
|
||||
.map-popup-content {
|
||||
padding: 5px;
|
||||
h4 {
|
||||
margin: 5px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,6 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import ListingsGrid from '../../components/grid/listings/ListingsGrid.jsx';
|
||||
|
||||
export default function Listings() {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { renderToString } from 'react-dom/server';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
@@ -11,14 +11,15 @@ import { useSelector, useActions } from '../../services/state/store.js';
|
||||
import { distanceMeters, generateCircleCoords, getBoundsFromCenter, getBoundsFromCoords } from './mapUtils.js';
|
||||
import { Select, Space, Typography, Button, Popover, Divider, Switch, Banner, Toast } from '@douyinfe/semi-ui-19';
|
||||
import { IconFilter, IconLink } from '@douyinfe/semi-icons';
|
||||
import { IconDelete } from '@douyinfe/semi-icons';
|
||||
import { IconDelete, IconEyeOpened } from '@douyinfe/semi-icons';
|
||||
|
||||
import no_image from '../../assets/no_image.jpg';
|
||||
import RangeSlider from 'react-range-slider-input';
|
||||
import 'react-range-slider-input/dist/style.css';
|
||||
import './Map.less';
|
||||
import { xhrDelete } from '../../services/xhr.js';
|
||||
import { Link } from 'react-router';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import ListingDeletionModal from '../../components/ListingDeletionModal.jsx';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -73,6 +74,7 @@ export default function MapView() {
|
||||
const markers = useRef([]);
|
||||
const homeMarker = useRef(null);
|
||||
const actions = useActions();
|
||||
const navigate = useNavigate();
|
||||
const listings = useSelector((state) => state.listingsData.mapListings);
|
||||
const homeAddress = useSelector((state) => state.userSettings.settings.home_address);
|
||||
const [style, setStyle] = useState('STANDARD');
|
||||
@@ -84,6 +86,22 @@ export default function MapView() {
|
||||
const [showFilterBar, setShowFilterBar] = useState(false);
|
||||
const [distanceFilter, setDistanceFilter] = useState(0);
|
||||
|
||||
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
||||
const [listingToDelete, setListingToDelete] = useState(null);
|
||||
|
||||
const confirmListingDeletion = async (hardDelete) => {
|
||||
try {
|
||||
await xhrDelete('/api/listings/', { ids: [listingToDelete], hardDelete });
|
||||
Toast.success('Listing successfully removed');
|
||||
fetchListings();
|
||||
} catch (error) {
|
||||
Toast.error(error.message || 'Error deleting listing');
|
||||
} finally {
|
||||
setDeleteModalVisible(false);
|
||||
setListingToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setPriceRange([0, getMaxPrice()]);
|
||||
}, [listings]);
|
||||
@@ -103,20 +121,20 @@ export default function MapView() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
window.deleteListing = async (id) => {
|
||||
try {
|
||||
await xhrDelete('/api/listings/', { ids: [id] });
|
||||
Toast.success('Listing successfully removed');
|
||||
fetchListings();
|
||||
} catch (error) {
|
||||
Toast.error(error.message || 'Error deleting listing');
|
||||
}
|
||||
window.deleteListing = (id) => {
|
||||
setListingToDelete(id);
|
||||
setDeleteModalVisible(true);
|
||||
};
|
||||
|
||||
window.viewDetails = (id) => {
|
||||
navigate(`/listings/listing/${id}`);
|
||||
};
|
||||
|
||||
return () => {
|
||||
delete window.deleteListing;
|
||||
delete window.viewDetails;
|
||||
};
|
||||
}, []);
|
||||
}, [navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (map.current) return;
|
||||
@@ -349,7 +367,15 @@ export default function MapView() {
|
||||
}
|
||||
};
|
||||
|
||||
addCircleLayer();
|
||||
const updateLayers = () => {
|
||||
addCircleLayer();
|
||||
};
|
||||
|
||||
if (map.current.isStyleLoaded()) {
|
||||
updateLayers();
|
||||
} else {
|
||||
map.current.on('load', updateLayers);
|
||||
}
|
||||
|
||||
filterListings().forEach((listing) => {
|
||||
if (
|
||||
@@ -364,7 +390,10 @@ export default function MapView() {
|
||||
|
||||
const popupContent = `
|
||||
<div class="map-popup-content">
|
||||
<img src="${listing.image_url || no_image}" alt="${listing.title}" />
|
||||
<img
|
||||
src="${listing.image_url}"
|
||||
onerror="this.onerror=null;this.src='${no_image}'"
|
||||
/>
|
||||
<h4>${listing.title}</h4>
|
||||
<div class="info">
|
||||
<span><strong>Price:</strong> ${listing.price ? listing.price + ' €' : 'N/A'}</span>
|
||||
@@ -378,6 +407,13 @@ export default function MapView() {
|
||||
${renderToString(<IconLink />)}
|
||||
</a>
|
||||
</div>
|
||||
<button
|
||||
class="map-popup-content__detailsButton"
|
||||
title="View Details"
|
||||
onclick="viewDetails('${listing.id}')"
|
||||
>
|
||||
${renderToString(<IconEyeOpened />)}
|
||||
</button>
|
||||
<button
|
||||
class="map-popup-content__deleteButton"
|
||||
title="Remove"
|
||||
@@ -538,6 +574,14 @@ export default function MapView() {
|
||||
/>
|
||||
|
||||
<div ref={mapContainer} className="map-container" />
|
||||
<ListingDeletionModal
|
||||
visible={deleteModalVisible}
|
||||
onConfirm={confirmListingDeletion}
|
||||
onCancel={() => {
|
||||
setDeleteModalVisible(false);
|
||||
setListingToDelete(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
.map-view-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 120px); /* Adjust based on header/footer height */
|
||||
padding: 1rem;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.map-filter-bar {
|
||||
@@ -78,6 +79,29 @@
|
||||
}
|
||||
}
|
||||
|
||||
&__detailsButton {
|
||||
background: var(--semi-color-tertiary);
|
||||
color: white;
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
|
||||
&:hover {
|
||||
background: var(--semi-color-tertiary-hover);
|
||||
}
|
||||
|
||||
svg {
|
||||
fill: currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
&__deleteButton {
|
||||
background: var(--semi-color-danger);
|
||||
color: white;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { IconHorn } from '@douyinfe/semi-icons';
|
||||
import { SegmentPart } from '../../../components/segment/SegmentPart.jsx';
|
||||
import { Banner, Button, Checkbox, Space } from '@douyinfe/semi-ui-19';
|
||||
|
||||
@@ -58,54 +58,62 @@ export default function Login() {
|
||||
return (
|
||||
<div className="login">
|
||||
<div className="login__bgImage" style={{ background: `url("${cityBackground}")` }} />
|
||||
<Logo />
|
||||
<form>
|
||||
<div className="login__loginWrapper">
|
||||
{error && <Banner type="danger" closeIcon={null} description={error} />}
|
||||
<Input
|
||||
size="large"
|
||||
prefix={<IconUser />}
|
||||
placeholder="Username"
|
||||
value={username}
|
||||
showClear
|
||||
autoFocus
|
||||
onChange={(value) => setUserName(value)}
|
||||
onKeyPress={async (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
await tryLogin();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="login__loginWrapper">
|
||||
<div className="login__logoWrapper">
|
||||
<Logo width={250} white />
|
||||
</div>
|
||||
|
||||
<Input
|
||||
size="large"
|
||||
mode="password"
|
||||
prefix={<IconLock />}
|
||||
value={password}
|
||||
placeholder="Password"
|
||||
onChange={(value) => setPassword(value)}
|
||||
onKeyPress={async (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
await tryLogin();
|
||||
}
|
||||
}}
|
||||
{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' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button type="primary" onClick={tryLogin} theme="solid" style={{ marginTop: '1rem' }}>
|
||||
<form onSubmit={(e) => e.preventDefault()}>
|
||||
{error && <Banner type="danger" closeIcon={null} description={error} style={{ marginBottom: '1rem' }} />}
|
||||
<div className="login__inputGroup">
|
||||
<Input
|
||||
size="large"
|
||||
prefix={<IconUser />}
|
||||
placeholder="Username"
|
||||
value={username}
|
||||
showClear
|
||||
autoFocus
|
||||
onChange={(value) => setUserName(value)}
|
||||
onKeyPress={async (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
await tryLogin();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="login__inputGroup">
|
||||
<Input
|
||||
size="large"
|
||||
mode="password"
|
||||
prefix={<IconLock />}
|
||||
value={password}
|
||||
placeholder="Password"
|
||||
onChange={(value) => setPassword(value)}
|
||||
onKeyPress={async (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
await tryLogin();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button block type="primary" onClick={tryLogin} theme="solid" style={{ marginTop: '1rem' }}>
|
||||
Login
|
||||
</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."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,32 +4,80 @@
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
&__bgImage {
|
||||
background-size: cover;
|
||||
filter: blur(8px);
|
||||
-webkit-filter: blur(8px);
|
||||
background-position: center;
|
||||
filter: blur(10px) brightness(0.4);
|
||||
-webkit-filter: blur(10px) brightness(0.4);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
top: -20px;
|
||||
left: -20px;
|
||||
right: -20px;
|
||||
bottom: -20px;
|
||||
z-index: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
&__loginWrapper {
|
||||
border: 1px solid #555050;
|
||||
border-radius: 30px;
|
||||
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
background-color: #151313ab;
|
||||
background: rgba(20, 20, 25, 0.7);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 24px;
|
||||
padding: 3rem;
|
||||
width: 90%;
|
||||
max-width: 420px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 2rem;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.7);
|
||||
|
||||
}
|
||||
|
||||
&__logoWrapper {
|
||||
margin-bottom: 2.5rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
|
||||
.logo {
|
||||
position: static !important;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
form {
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.2rem;
|
||||
}
|
||||
|
||||
&__inputGroup {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// Mobile responsiveness
|
||||
@media (max-width: 480px) {
|
||||
&__loginWrapper {
|
||||
padding: 2rem 1.5rem;
|
||||
width: 95%;
|
||||
border-radius: 20px;
|
||||
background: rgba(20, 20, 25, 0.85);
|
||||
|
||||
&::after {
|
||||
opacity: 0.2;
|
||||
filter: blur(10px);
|
||||
}
|
||||
}
|
||||
|
||||
&__logoWrapper {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Modal } from '@douyinfe/semi-ui-19';
|
||||
const UserRemovalModal = function UserRemovalModal({ onOk, onCancel }) {
|
||||
return (
|
||||
|
||||
@@ -3,22 +3,20 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { Divider, Button, AutoComplete, Toast, Typography, Banner } from '@douyinfe/semi-ui-19';
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { Divider, Button, AutoComplete, Toast, Banner } from '@douyinfe/semi-ui-19';
|
||||
import { IconSave, IconHome } from '@douyinfe/semi-icons';
|
||||
import { useSelector, useActions } from '../../services/state/store';
|
||||
import { xhrGet, xhrPost } from '../../services/xhr';
|
||||
import { useSelector, useActions, useIsLoading } from '../../services/state/store';
|
||||
import { xhrGet } from '../../services/xhr';
|
||||
import { SegmentPart } from '../../components/segment/SegmentPart';
|
||||
import debounce from 'lodash/debounce';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
const UserSettings = () => {
|
||||
const actions = useActions();
|
||||
const homeAddress = useSelector((state) => state.userSettings.settings.home_address);
|
||||
const [address, setAddress] = useState(homeAddress?.address || '');
|
||||
const [coords, setCoords] = useState(homeAddress?.coords || null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const saving = useIsLoading(actions.userSettings.setHomeAddress);
|
||||
const [dataSource, setDataSource] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -27,20 +25,15 @@ const UserSettings = () => {
|
||||
}, [homeAddress]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const response = await xhrPost('/api/user/settings/home-address', { home_address: address });
|
||||
if (response.status === 200) {
|
||||
setCoords(response.json.coords);
|
||||
await actions.userSettings.getUserSettings();
|
||||
Toast.success('Settings saved successfully');
|
||||
} else {
|
||||
Toast.error(response.json.error || 'Failed to save settings');
|
||||
}
|
||||
const responseJson = await actions.userSettings.setHomeAddress(address);
|
||||
setCoords(responseJson.coords);
|
||||
await actions.userSettings.getUserSettings();
|
||||
Toast.success(
|
||||
'Settings saved successfully. We will now start calculating distances for you. This may take a while and runs in the background.',
|
||||
);
|
||||
} catch (error) {
|
||||
Toast.error(error.json?.error || 'Error while saving settings');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -70,8 +63,6 @@ const UserSettings = () => {
|
||||
|
||||
return (
|
||||
<div className="user-settings">
|
||||
<Title heading={2}>User Specific Settings</Title>
|
||||
<Divider />
|
||||
<SegmentPart
|
||||
name="Distance claculation"
|
||||
Icon={IconHome}
|
||||
|
||||