mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b86e351007 | ||
|
|
19c4860da7 | ||
|
|
d98e06cfdf | ||
|
|
6ae0c9749b | ||
|
|
10e40e038e | ||
|
|
4ba6828939 | ||
|
|
d09770dae2 | ||
|
|
248e4d2562 | ||
|
|
7b8e961b49 | ||
|
|
f66ceccbb4 | ||
|
|
a3db725af6 | ||
|
|
0663bd945f |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,3 +6,4 @@ npm-debug.log
|
||||
.DS_Store
|
||||
.idea
|
||||
.vscode
|
||||
tools/release/config.json
|
||||
|
||||
94
CHANGELOG.md
94
CHANGELOG.md
@@ -1,94 +0,0 @@
|
||||
Newer release changelog see https://github.com/orangecoding/fredy/releases
|
||||
|
||||
---
|
||||
|
||||
###### [V5.5.0]
|
||||
|
||||
- Upgrading dependencies
|
||||
- fixing provider
|
||||
- allow multiple instances of 1 provider
|
||||
- **BREAKING**: Minimum node version is now 16
|
||||
|
||||
###### [V5.4.6]
|
||||
|
||||
- Adding Instana node.js monitoring
|
||||
-
|
||||
|
||||
###### [V5.4.5]
|
||||
|
||||
- Adding Instana node.js monitoring
|
||||
|
||||
###### [V5.4.4]
|
||||
|
||||
- Add support for Immo Südwest Presse (immo.swp.de)
|
||||
- Telegram: Use job name instead of ID and link in title
|
||||
- Fix race condition if user ID is in session but not in user store
|
||||
- Allow visiting the original provider URL
|
||||
|
||||
###### [V5.4.3]
|
||||
|
||||
- re-writing readme
|
||||
- improving docker build
|
||||
- using github's actions to build docker and test automatically
|
||||
|
||||
###### [V5.4.2]
|
||||
|
||||
- Fixing prod build
|
||||
|
||||
###### [V5.4.1]
|
||||
|
||||
- Upgrading dependencies
|
||||
- Provider urls are now automagically been changed to include the correct sort order for search results
|
||||
|
||||
```
|
||||
Note: It has been an point of confusion since the very beginning of Fredy, that people simply copied the url, but
|
||||
did not take care of sorting the search results by date. If this is not done, Fredy will most likely not see the latest
|
||||
results, thus cannot report them. This release fixes it by adding the necessary params (or replaces them).
|
||||
```
|
||||
|
||||
###### [V5.3.0]
|
||||
|
||||
- Upgrading dependencies
|
||||
- It's now possible to send mails to multiple receiver using comma separation for MailJet & Sendgrid
|
||||
- Fixing Immowelt scraping
|
||||
|
||||
###### [V5.2.0]
|
||||
|
||||
- Upgrading dependencies
|
||||
- Adding new similarity check layer (Duplicates are being removed now)
|
||||
- Adding paging for search results
|
||||
|
||||
###### [V5.1.0]
|
||||
|
||||
- Upgrading dependencies
|
||||
- NodeJS 12.13 is now the minimum supported version
|
||||
- Adding general settings as new configuration page to ui
|
||||
- Adding new feature working hours
|
||||
|
||||
###### [V5.0.0]
|
||||
|
||||
- Upgrading dependencies
|
||||
- NodeJS 12 is now the minimum supported version
|
||||
|
||||
###### [V4.0.0]
|
||||
|
||||
Bringing back Immoscout :tada:
|
||||
|
||||
###### [V3.0.0]
|
||||
|
||||
This is basically a re-write, your old config file will not be compatible anymore. Please re-created your search jobs
|
||||
on the new ui and use the values from your previous config file if needed.
|
||||
|
||||
```
|
||||
- We're getting rid of manual config changes, Fredy, now ships with a UI so that it's easy for you to create and edit jobs
|
||||
```
|
||||
|
||||
###### [V2.0.0]
|
||||
|
||||
```
|
||||
- Fredy can now run multiple search job on one instance
|
||||
- Changed lot's of the structure of Fredy to make this happen
|
||||
[BREAKING CHANGES]
|
||||
- The config has been changed, the config of V1.x will not work any longer
|
||||
- Sources have been renamed to provider
|
||||
```
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -107,7 +107,7 @@ 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 {
|
||||
if (settings.demoMode) {
|
||||
@@ -115,7 +115,7 @@ listingsRouter.delete('/job', async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
listingStorage.deleteListingsByJobId(jobId);
|
||||
listingStorage.deleteListingsByJobId(jobId, hardDelete);
|
||||
} catch (error) {
|
||||
res.send(new Error(error));
|
||||
logger.error(error);
|
||||
@@ -124,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));
|
||||
|
||||
@@ -8,6 +8,8 @@ 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';
|
||||
|
||||
export async function runGeoCordTask() {
|
||||
const listings = getListingsToGeocode();
|
||||
@@ -32,6 +34,11 @@ export async function runGeoCordTask() {
|
||||
}
|
||||
|
||||
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 runGeoCordTask();
|
||||
// then every 6 hours
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -22,7 +22,7 @@ puppeteer.use(StealthPlugin());
|
||||
export default async function execute(url, waitForSelector, options) {
|
||||
let browser;
|
||||
let page;
|
||||
let result = null;
|
||||
let result;
|
||||
let userDataDir;
|
||||
let removeUserDataDir = false;
|
||||
try {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,56 +14,70 @@ 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 trackMainEvent = async () => {
|
||||
if (!(await shouldTrack())) return;
|
||||
|
||||
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 trackFeature = async (feature) => {
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
if (settings.analyticsEnabled && !inDevMode()) {
|
||||
const trackingObj = await enrichTrackingObject({
|
||||
feature,
|
||||
});
|
||||
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 trackingObj = await enrichTrackingObject({
|
||||
feature,
|
||||
});
|
||||
|
||||
await sendTrackingData('/feature', trackingObj);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -72,34 +86,17 @@ export const trackFeature = async (feature) => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
30
package.json
30
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "fredy",
|
||||
"version": "19.3.2",
|
||||
"version": "19.3.9",
|
||||
"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,14 +73,14 @@
|
||||
"cookie-session": "2.1.1",
|
||||
"handlebars": "4.7.8",
|
||||
"lodash": "4.17.23",
|
||||
"maplibre-gl": "^5.17.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.1",
|
||||
"puppeteer": "^24.37.2",
|
||||
"puppeteer-extra": "^3.3.6",
|
||||
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
||||
"query-string": "9.3.1",
|
||||
@@ -90,23 +91,26 @@
|
||||
"react-router": "7.13.0",
|
||||
"react-router-dom": "7.13.0",
|
||||
"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",
|
||||
|
||||
196
tools/release/release.js
Normal file
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 */
|
||||
@@ -17,6 +17,8 @@
|
||||
padding: 24px;
|
||||
background-color: var(--semi-color-bg-0);
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
padding: 12px;
|
||||
|
||||
@@ -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';
|
||||
|
||||
70
ui/src/components/ListingDeletionModal.jsx
Normal file
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;
|
||||
@@ -2,30 +2,29 @@
|
||||
width: 100%;
|
||||
height: 140px;
|
||||
margin-bottom: 16px;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
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;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
background-color: rgba(36, 36, 36, 1);
|
||||
|
||||
&.blue {
|
||||
box-shadow: 0 8px 24px -5px var(--semi-color-primary);
|
||||
}
|
||||
&.orange {
|
||||
box-shadow: 0 8px 24px -5px var(--semi-color-warning);
|
||||
}
|
||||
&.green {
|
||||
box-shadow: 0 8px 24px -5px var(--semi-color-success);
|
||||
}
|
||||
&.purple {
|
||||
box-shadow: 0 8px 24px -5px var(--semi-color-info);
|
||||
}
|
||||
&.gray {
|
||||
box-shadow: 0 8px 24px -5px rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
&::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 {
|
||||
@@ -46,22 +45,36 @@
|
||||
}
|
||||
|
||||
&.blue {
|
||||
box-shadow: 0 4px 20px -5px var(--semi-color-primary);
|
||||
--pulse-color: var(--semi-color-primary);
|
||||
box-shadow: 0 4px 20px -5px var(--pulse-color);
|
||||
}
|
||||
|
||||
&.orange {
|
||||
box-shadow: 0 4px 20px -5px var(--semi-color-warning);
|
||||
--pulse-color: var(--semi-color-warning);
|
||||
box-shadow: 0 4px 20px -5px var(--pulse-color);
|
||||
}
|
||||
|
||||
&.green {
|
||||
box-shadow: 0 4px 20px -5px var(--semi-color-success);
|
||||
--pulse-color: var(--semi-color-success);
|
||||
box-shadow: 0 4px 20px -5px var(--pulse-color);
|
||||
}
|
||||
|
||||
&.purple {
|
||||
box-shadow: 0 4px 20px -5px var(--semi-color-info);
|
||||
--pulse-color: var(--semi-color-info);
|
||||
box-shadow: 0 4px 20px -5px var(--pulse-color);
|
||||
}
|
||||
|
||||
&.gray {
|
||||
box-shadow: 0 4px 20px -5px rgba(255, 255, 255, 0.2);
|
||||
--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,7 +3,6 @@
|
||||
* 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';
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* 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, Layout, Space, Divider } from '@douyinfe/semi-ui-19';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -410,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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
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';
|
||||
@@ -65,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,
|
||||
@@ -106,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);
|
||||
};
|
||||
@@ -312,15 +329,10 @@ const ListingsGrid = () => {
|
||||
title="Remove"
|
||||
type="danger"
|
||||
size="small"
|
||||
onClick={async (e) => {
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await xhrDelete('/api/listings/', { ids: [item.id] });
|
||||
Toast.success('Listing(s) successfully removed');
|
||||
loadData();
|
||||
} catch (error) {
|
||||
Toast.error(error);
|
||||
}
|
||||
setListingToDelete(item.id);
|
||||
setDeleteModalVisible(true);
|
||||
}}
|
||||
icon={<IconDelete />}
|
||||
/>
|
||||
@@ -341,6 +353,14 @@ const ListingsGrid = () => {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ListingDeletionModal
|
||||
visible={deleteModalVisible}
|
||||
onConfirm={confirmDeletion}
|
||||
onCancel={() => {
|
||||
setDeleteModalVisible(false);
|
||||
setListingToDelete(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,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';
|
||||
import { Typography } from '@douyinfe/semi-ui';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,8 +38,6 @@ export default function Dashboard() {
|
||||
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<Headline text="Dashboard" size={3} />
|
||||
|
||||
<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}>
|
||||
@@ -153,7 +150,12 @@ export default function Dashboard() {
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<SegmentPart name="Provider Insights" Icon={IconStar} helpText="Percentage of found listings over all providers">
|
||||
<SegmentPart
|
||||
name="Provider Insights"
|
||||
Icon={IconStar}
|
||||
helpText="Percentage of found listings over all providers"
|
||||
className="dashboard__provider-insights"
|
||||
>
|
||||
<PieChartCard data={pieData} />
|
||||
</SegmentPart>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
.dashboard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
|
||||
&__row {
|
||||
margin-bottom: 24px;
|
||||
flex-wrap: wrap;
|
||||
@@ -7,4 +11,23 @@
|
||||
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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useSelector, useActions } from '../../services/state/store.js';
|
||||
import {
|
||||
@@ -324,7 +324,12 @@ export default function ListingDetail() {
|
||||
<Row>
|
||||
<Col span={24} lg={12}>
|
||||
<div className="listing-detail__image-container">
|
||||
<Image src={listing.image_url || no_image} fallback={no_image} preview={true} />
|
||||
<Image
|
||||
src={listing.image_url}
|
||||
fallback={no_image}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
preview={true}
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={24} lg={12}>
|
||||
|
||||
@@ -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';
|
||||
@@ -19,6 +19,7 @@ import 'react-range-slider-input/dist/style.css';
|
||||
import './Map.less';
|
||||
import { xhrDelete } from '../../services/xhr.js';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import ListingDeletionModal from '../../components/ListingDeletionModal.jsx';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -85,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]);
|
||||
@@ -104,14 +121,9 @@ 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) => {
|
||||
@@ -378,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>
|
||||
@@ -559,6 +574,14 @@ export default function MapView() {
|
||||
/>
|
||||
|
||||
<div ref={mapContainer} className="map-container" />
|
||||
<ListingDeletionModal
|
||||
visible={deleteModalVisible}
|
||||
onConfirm={confirmListingDeletion}
|
||||
onCancel={() => {
|
||||
setDeleteModalVisible(false);
|
||||
setListingToDelete(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,16 +3,14 @@
|
||||
* 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 { 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);
|
||||
@@ -72,8 +70,6 @@ const UserSettings = () => {
|
||||
|
||||
return (
|
||||
<div className="user-settings">
|
||||
<Title heading={2}>User Specific Settings</Title>
|
||||
<Divider />
|
||||
<SegmentPart
|
||||
name="Distance claculation"
|
||||
Icon={IconHome}
|
||||
|
||||
Reference in New Issue
Block a user