Compare commits

..

12 Commits

Author SHA1 Message Date
orangecoding
b86e351007 fixing lint even harder 2026-02-16 13:50:50 +01:00
orangecoding
19c4860da7 fixing eslint harder 2026-02-16 12:59:34 +01:00
orangecoding
d98e06cfdf fixing eslint 2026-02-16 12:40:41 +01:00
orangecoding
6ae0c9749b update dependencies 2026-02-16 12:30:59 +01:00
orangecoding
10e40e038e adding check if fredy is running in docker 2026-02-16 12:29:02 +01:00
orangecoding
4ba6828939 adding release tool 2026-02-05 12:02:18 +01:00
orangecoding
d09770dae2 fancy, almost impossible to see animation on dashboard 2026-02-05 09:54:42 +01:00
orangecoding
248e4d2562 improve tracking 2026-02-04 14:41:55 +01:00
orangecoding
7b8e961b49 adding confirmation dialog if to remove listing entirely from db or just hide it 2026-02-03 14:04:40 +01:00
orangecoding
f66ceccbb4 next release version 2026-01-29 13:01:39 +01:00
orangecoding
a3db725af6 fixing image rendering 2026-01-29 13:01:07 +01:00
orangecoding
0663bd945f smaller demo improvements 2026-01-29 09:46:23 +01:00
49 changed files with 1185 additions and 731 deletions

1
.gitignore vendored
View File

@@ -6,3 +6,4 @@ npm-debug.log
.DS_Store
.idea
.vscode
tools/release/config.json

View File

@@ -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
```

View File

@@ -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

View File

@@ -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,
];

View File

@@ -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));

View File

@@ -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

View File

@@ -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

View File

@@ -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 {

View File

@@ -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,
);

View File

@@ -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

View File

@@ -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 });
}
}
}

View File

@@ -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,
};
}

View File

@@ -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
View 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 */

View File

@@ -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;

View File

@@ -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';

View 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;

View File

@@ -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;
}
}

View File

@@ -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';

View File

@@ -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';

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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 } = {}) {

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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() {

View File

@@ -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 }) {

View File

@@ -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) {

View File

@@ -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" />} />

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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>

View File

@@ -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;
}
}
}
}

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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 }) {

View File

@@ -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';

View File

@@ -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}>

View File

@@ -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() {

View File

@@ -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>
);
}

View File

@@ -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';

View File

@@ -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 (

View File

@@ -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}

947
yarn.lock

File diff suppressed because it is too large Load Diff