Compare commits

..

11 Commits

Author SHA1 Message Date
orangecoding
acbaab05ed next release version 2026-01-26 12:07:43 +01:00
orangecoding
72fffc526b deleting a listing now sets it to deleted in the db, preventing it from reappearing when scraping happens 2026-01-26 12:07:21 +01:00
orangecoding
9e5989ece3 zoom into map where most markers are 2026-01-26 11:54:47 +01:00
orangecoding
afc200c9e1 improved tooltip in map, improved user-settings handling 2026-01-26 11:50:16 +01:00
orangecoding
59226491f2 improved tooltip in map, improved user-settings handling 2026-01-26 11:20:02 +01:00
orangecoding
28f7760120 adapt link to listing in grid view to behave like a real link 2026-01-26 10:43:38 +01:00
orangecoding
2465514b7a fixing immoscout translator, allowing balcony and garden for purchases 2026-01-26 10:20:21 +01:00
Christian Kellner
9dde377fe6 possibility to display distance (#262) 2026-01-25 13:52:56 +01:00
Katrin Leinweber
28a3a7f372 Use EUR-symbol to match Map.jsx (see d43c5b3) (#261)
Co-authored-by: Katrin Leinweber <katrinleinweber@noreply.github.com>
2026-01-25 12:32:11 +01:00
orangecoding
e859250545 next release version 2026-01-22 15:10:31 +00:00
Christian Kellner
4dd0370ec1 Calculating the distance (#255)
* migra for distance

* adding distance calculator

* adding ability to store home address

* improve distance calculation

* calculating distance

* show distance in grid view

* upgrading dependencies

* moving to react 19

* ability to clone a job

* fixing tests

* polishing
2026-01-22 16:09:36 +01:00
55 changed files with 1520 additions and 404 deletions

View File

@@ -5,11 +5,15 @@
import { NoNewListingsWarning } from './errors.js';
import { storeListings, getKnownListingHashesForJobAndProvider } from './services/storage/listingsStorage.js';
import { getJob } from './services/storage/jobStorage.js';
import * as notify from './notification/notify.js';
import Extractor from './services/extractor/extractor.js';
import urlModifier from './services/queryStringMutator.js';
import logger from './services/logger.js';
import { geocodeAddress } from './services/geocoding/geoCodingService.js';
import { distanceMeters } from './services/listings/distanceCalculator.js';
import { getUserSettings } from './services/storage/settingsStorage.js';
import { updateListingDistance } from './services/storage/listingsStorage.js';
/**
* @typedef {Object} Listing
@@ -82,6 +86,7 @@ class FredyPipelineExecutioner {
.then(this._findNew.bind(this))
.then(this._geocode.bind(this))
.then(this._save.bind(this))
.then(this._calculateDistance.bind(this))
.then(this._filterBySimilarListings.bind(this))
.then(this._notify.bind(this))
.catch(this._handleError.bind(this));
@@ -201,6 +206,42 @@ class FredyPipelineExecutioner {
return newListings;
}
/**
* Calculate distance for new listings.
*
* @param {Listing[]} listings
* @returns {Listing[]}
* @private
*/
_calculateDistance(listings) {
if (listings.length === 0) return [];
const job = getJob(this._jobKey);
const userId = job?.userId;
if (userId == null || typeof userId !== 'string') {
logger.debug('Skipping distance calculation: userId is missing or invalid');
return listings;
}
const userSettings = getUserSettings(userId);
const homeAddress = userSettings?.home_address;
if (!homeAddress || !homeAddress.coords) {
return listings;
}
const { lat, lng } = homeAddress.coords;
for (const listing of listings) {
if (listing.latitude != null && listing.longitude != null) {
const dist = distanceMeters(lat, lng, listing.latitude, listing.longitude);
updateListingDistance(listing.id, dist);
listing.distance_to_destination = dist;
}
}
return listings;
}
/**
* Remove listings that are similar to already known entries according to the similarity cache.
* Adds the remaining listings to the cache.

View File

@@ -10,6 +10,7 @@ import { providerRouter } from './routes/providerRouter.js';
import { versionRouter } from './routes/versionRouter.js';
import { loginRouter } from './routes/loginRoute.js';
import { userRouter } from './routes/userRoute.js';
import { userSettingsRouter } from './routes/userSettingsRoute.js';
import { jobRouter } from './routes/jobRouter.js';
import bodyParser from 'body-parser';
import restana from 'restana';
@@ -20,7 +21,6 @@ import { demoRouter } from './routes/demoRouter.js';
import logger from '../services/logger.js';
import { listingsRouter } from './routes/listingsRouter.js';
import { getSettings } from '../services/storage/settingsStorage.js';
import { featureRouter } from './routes/featureRouter.js';
import { dashboardRouter } from './routes/dashboardRouter.js';
import { backupRouter } from './routes/backupRouter.js';
const service = restana();
@@ -35,7 +35,7 @@ service.use('/api/jobs', authInterceptor());
service.use('/api/version', authInterceptor());
service.use('/api/listings', authInterceptor());
service.use('/api/dashboard', authInterceptor());
service.use('/api/features', authInterceptor());
service.use('/api/user/settings', authInterceptor());
// /admin can only be accessed when user is having admin permissions
service.use('/api/admin', adminInterceptor());
@@ -44,11 +44,11 @@ service.use('/api/admin/generalSettings', generalSettingsRouter);
service.use('/api/admin/backup', backupRouter);
service.use('/api/jobs/provider', providerRouter);
service.use('/api/admin/users', userRouter);
service.use('/api/user/settings', userSettingsRouter);
service.use('/api/version', versionRouter);
service.use('/api/jobs', jobRouter);
service.use('/api/login', loginRouter);
service.use('/api/listings', listingsRouter);
service.use('/api/features', featureRouter);
service.use('/api/dashboard', dashboardRouter);
//this route is unsecured intentionally as it is being queried from the login page
service.use('/api/demo', demoRouter);

View File

@@ -1,17 +0,0 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import restana from 'restana';
import getFeatures from '../../features.js';
const service = restana();
const featureRouter = service.newRouter();
featureRouter.get('/', async (req, res) => {
const features = getFeatures();
res.body = Object.assign({}, { features });
res.send();
});
export { featureRouter };

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 restana from 'restana';
import SqliteConnection from '../../services/storage/SqliteConnection.js';
import { upsertSettings } from '../../services/storage/settingsStorage.js';
import { geocodeAddress } from '../../services/geocoding/geoCodingService.js';
import { autocompleteAddress } from '../../services/geocoding/autocompleteService.js';
import { calculateDistanceForUser } from '../../services/geocoding/distanceService.js';
import { fromJson } from '../../utils.js';
import { trackFeature } from '../../services/tracking/Tracker.js';
import { FEATURES } from '../../features.js';
import logger from '../../services/logger.js';
const service = restana();
const userSettingsRouter = service.newRouter();
userSettingsRouter.get('/', async (req, res) => {
const userId = req.session.currentUser;
const rows = SqliteConnection.query('SELECT name, value FROM settings WHERE user_id = @userId', { userId });
const settings = {};
for (const r of rows) {
settings[r.name] = fromJson(r.value, null);
}
res.body = settings;
res.send();
});
userSettingsRouter.get('/autocomplete', async (req, res) => {
const { q } = req.query;
try {
const results = await autocompleteAddress(q);
res.body = results;
res.send();
} catch (error) {
res.statusCode = 500;
res.send({ error: error.message });
}
});
userSettingsRouter.post('/home-address', async (req, res) => {
const userId = req.session.currentUser;
const { home_address } = req.body;
try {
if (home_address) {
await trackFeature(FEATURES.DISTANCE_ADDRESS_ENTERED);
const coords = await geocodeAddress(home_address);
if (coords && coords.lat !== -1) {
upsertSettings({ home_address: { address: home_address, coords } }, userId);
calculateDistanceForUser(userId);
res.send({ success: true, coords });
} else {
res.statusCode = 400;
res.send({ error: 'Could not geocode address' });
}
} else {
upsertSettings({ home_address: null }, userId);
res.send({ success: true });
}
} catch (error) {
logger.error('Error updating home address settings', error);
res.statusCode = 500;
res.send({ error: error.message });
}
});
export { userSettingsRouter };

View File

@@ -3,12 +3,6 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
const FEATURES = {
WATCHLIST_MANAGEMENT: false,
export const FEATURES = {
DISTANCE_ADDRESS_ENTERED: 'DISTANCE_ADDRESS_ENTERED',
};
export default function getFeatures() {
return {
...FEATURES,
};
}

View File

@@ -6,22 +6,28 @@
import cron from 'node-cron';
import { getListingsToGeocode, updateListingGeocoordinates } from '../storage/listingsStorage.js';
import { geocodeAddress, isGeocodingPaused } from '../geocoding/geoCodingService.js';
import { getJobs } from '../storage/jobStorage.js';
import { calculateDistanceForJob } from '../geocoding/distanceService.js';
async function runTask() {
const listings = getListingsToGeocode();
if (listings.length === 0) {
return;
if (listings.length > 0) {
for (const listing of listings) {
if (isGeocodingPaused()) {
break;
}
const coords = await geocodeAddress(listing.address);
if (coords) {
updateListingGeocoordinates(listing.id, coords.lat, coords.lng);
}
}
}
for (const listing of listings) {
if (isGeocodingPaused()) {
break;
}
const coords = await geocodeAddress(listing.address);
if (coords) {
updateListingGeocoordinates(listing.id, coords.lat, coords.lng);
}
//additional run
const jobs = getJobs();
for (const job of jobs) {
calculateDistanceForJob(job.id, job.userId);
}
}

View File

@@ -0,0 +1,26 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { autocomplete as nominatimAutocomplete } from './client/nominatimClient.js';
import logger from '../logger.js';
/**
* Autocompletes an address using Nominatim.
*
* @param {string} query - The search query.
* @returns {Promise<string[]>} List of matching addresses.
*/
export async function autocompleteAddress(query) {
if (!query) {
return [];
}
try {
return await nominatimAutocomplete(query);
} catch (error) {
logger.error('Error during address autocomplete:', error);
return [];
}
}

View File

@@ -100,6 +100,53 @@ async function doGeocode(address) {
}
}
/**
* Autocompletes an address using Nominatim.
*
* @param {string} query - The search query.
* @returns {Promise<string[]>} List of matching addresses.
*/
async function doAutocomplete(query) {
if (Date.now() - last429 < PAUSE_DURATION) {
return [];
}
const url = `${API_URL}?q=${encodeURIComponent(query)}&format=json&addressdetails=1&limit=5&countrycodes=de`;
try {
const response = await fetch(url, {
agent,
headers: {
'User-Agent': userAgent,
},
});
if (response.status === 429) {
logger.warn('Nominatim rate limit hit. Pausing for 1 hour.');
last429 = Date.now();
return [];
}
if (!response.ok) {
logger.error(`Nominatim API error: ${response.status} ${response.statusText}`);
return [];
}
const data = await response.json();
if (Array.isArray(data)) {
return data.map((item) => item.display_name);
}
return [];
} catch (error) {
logger.error('Error during Nominatim autocomplete:', error);
return [];
}
}
export const geocode = throttle(doGeocode);
export const autocomplete = throttle(doAutocomplete);
export const isPaused = () => Date.now() - last429 < PAUSE_DURATION;

View File

@@ -0,0 +1,61 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { distanceMeters } from '../listings/distanceCalculator.js';
import {
getListingsToCalculateDistance,
getListingsForUserToCalculateDistance,
updateListingDistance,
} from '../storage/listingsStorage.js';
import { getUserSettings } from '../storage/settingsStorage.js';
/**
* Calculates and updates distances for listings of a specific job.
* Only processes listings where distance_to_destination is null.
*
* @param {string} jobId
* @param {string} userId
* @returns {void}
*/
export function calculateDistanceForJob(jobId, userId) {
const userSettings = getUserSettings(userId);
const homeAddress = userSettings.home_address;
if (!homeAddress || !homeAddress.coords) {
return;
}
const listings = getListingsToCalculateDistance(jobId);
const { lat, lng } = homeAddress.coords;
for (const listing of listings) {
const dist = distanceMeters(lat, lng, listing.latitude, listing.longitude);
updateListingDistance(listing.id, dist);
}
}
/**
* Calculates and updates distances for all active listings of a user.
* Usually called when the user updates their home address.
*
* @param {string} userId
* @returns {void}
*/
export function calculateDistanceForUser(userId) {
const userSettings = getUserSettings(userId);
const homeAddress = userSettings.home_address;
if (!homeAddress || !homeAddress.coords) {
return;
}
const listings = getListingsForUserToCalculateDistance(userId);
const { lat, lng } = homeAddress.coords;
for (const listing of listings) {
const dist = distanceMeters(lat, lng, listing.latitude, listing.longitude);
updateListingDistance(listing.id, dist);
}
}

View File

@@ -109,7 +109,9 @@ const REAL_ESTATE_TYPE = {
const WEB_PATH_TO_APARTMENT_EQUIPMENT_MAP = {
// Category "Balkon/Terrasse"
'wohnung-mit-balkon-mieten': { equipment: ['balcony'] },
'wohnung-kaufen-mit-balkon': { equipment: ['balcony'] },
'wohnung-mit-garten-mieten': { equipment: ['garden'] },
'eigentumswohnung-mit-garten': { equipment: ['garden'] },
// Category "Wohnungstyp"
'souterrainwohnung-mieten': { apartmenttypes: ['halfbasement'] },
'erdgeschosswohnung-mieten': { apartmenttypes: ['groundfloor'] },

View File

@@ -0,0 +1,35 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
const R = 6371000; // Earth radius in meters
/**
* Calculate the great-circle distance between two points on Earth using the Haversine formula.
* This is to calculate the distance between the listing address & the address provided by the user. I know, it is only
* a rough estimation as this calculates the distance as a straight line, but it's more convenient than using an external
* service and still gives a good approximation for sorting purposes.
* Returns distance in meters.
*
* @param {number} lat1
* @param {number} lon1
* @param {number} lat2
* @param {number} lon2
* @returns {number}
*/
export function distanceMeters(lat1, lon1, lat2, lon2) {
const toRad = (deg) => (deg * Math.PI) / 180;
const phi1 = toRad(lat1);
const phi2 = toRad(lat2);
const dPhi = toRad(lat2 - lat1);
const dLambda = toRad(lon2 - lon1);
const a =
Math.sin(dPhi / 2) * Math.sin(dPhi / 2) +
Math.cos(phi1) * Math.cos(phi2) * Math.sin(dLambda / 2) * Math.sin(dLambda / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return Math.round(R * c * 10) / 10;
}

View File

@@ -48,7 +48,8 @@ export const getListingsKpisForJobIds = (jobIds = []) => {
SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END) AS activeCount,
AVG(price) AS avgPrice
FROM listings
WHERE job_id IN (${placeholders})`,
WHERE job_id IN (${placeholders})
AND manually_deleted = 0`,
jobIds,
)[0] || {};
@@ -80,6 +81,7 @@ export const getProviderDistributionForJobIds = (jobIds = []) => {
`SELECT provider, COUNT(*) AS cnt
FROM listings
WHERE job_id IN (${placeholders})
AND manually_deleted = 0
GROUP BY provider
ORDER BY cnt DESC`,
jobIds,
@@ -118,8 +120,8 @@ export const getActiveOrUnknownListings = () => {
return SqliteConnection.query(
`SELECT *
FROM listings
WHERE is_active is null
OR is_active = 1
WHERE (is_active is null OR is_active = 1)
AND manually_deleted = 0
ORDER BY provider`,
);
};
@@ -306,6 +308,9 @@ export const queryListings = ({
whereParts.push('(wl.id IS NULL)');
}
// Build whereSql (filtering by manually_deleted = 0)
whereParts.push('(l.manually_deleted = 0)');
const whereSql = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : '';
const whereSqlWithAlias = whereSql
.replace(/\btitle\b/g, 'l.title')
@@ -370,8 +375,8 @@ export const queryListings = ({
export const deleteListingsByJobId = (jobId) => {
if (!jobId) return;
return SqliteConnection.execute(
`DELETE
FROM listings
`UPDATE listings
SET manually_deleted = 1
WHERE job_id = @jobId`,
{ jobId },
);
@@ -387,9 +392,9 @@ export const deleteListingsById = (ids) => {
if (!Array.isArray(ids) || ids.length === 0) return;
const placeholders = ids.map(() => '?').join(',');
return SqliteConnection.execute(
`DELETE
FROM listings
WHERE id IN (${placeholders})`,
`UPDATE listings
SET manually_deleted = 1
WHERE id IN (${placeholders})`,
ids,
);
};
@@ -404,6 +409,7 @@ export const getListingsToGeocode = () => {
`SELECT id, address
FROM listings
WHERE is_active = 1
AND manually_deleted = 0
AND address IS NOT NULL
AND (latitude IS NULL OR longitude IS NULL)`,
);
@@ -443,6 +449,7 @@ export const getListingsForMap = ({ jobId, userId = null, isAdmin = false } = {}
'l.latitude != -1',
'l.longitude != -1',
'l.is_active = 1',
'l.manually_deleted = 0',
];
const params = { userId: userId || '__NO_USER__' };
@@ -479,7 +486,7 @@ export const getListingsForMap = ({ jobId, userId = null, isAdmin = false } = {}
* @returns {{title: string|null, address: string|null, price: number|null}[]}
*/
export const getAllEntriesFromListings = () => {
return SqliteConnection.query(`SELECT title, address, price FROM listings`);
return SqliteConnection.query(`SELECT title, address, price FROM listings WHERE manually_deleted = 0`);
};
/**
@@ -493,6 +500,7 @@ export const getGeocoordinatesByAddress = (address) => {
`SELECT latitude, longitude
FROM listings
WHERE address = @address
AND manually_deleted = 0
AND latitude IS NOT NULL
AND longitude IS NOT NULL
AND latitude != -1
@@ -502,3 +510,59 @@ export const getGeocoordinatesByAddress = (address) => {
)[0];
return row ? { lat: row.latitude, lng: row.longitude } : null;
};
/**
* Return all active listings for a given job that have geocoordinates but no distance set.
*
* @param {string} jobId
* @returns {Object[]}
*/
export const getListingsToCalculateDistance = (jobId) => {
return SqliteConnection.query(
`SELECT id, latitude, longitude
FROM listings
WHERE job_id = @jobId
AND is_active = 1
AND manually_deleted = 0
AND latitude IS NOT NULL
AND longitude IS NOT NULL
AND distance_to_destination IS NULL`,
{ jobId },
);
};
/**
* Return all active listings for a given user (across all jobs) that have geocoordinates.
*
* @param {string} userId
* @returns {Object[]}
*/
export const getListingsForUserToCalculateDistance = (userId) => {
return SqliteConnection.query(
`SELECT l.id, l.latitude, l.longitude
FROM listings l
JOIN jobs j ON l.job_id = j.id
WHERE j.user_id = @userId
AND l.is_active = 1
AND l.manually_deleted = 0
AND l.latitude IS NOT NULL
AND l.longitude IS NOT NULL`,
{ userId },
);
};
/**
* Update the distance to destination for a listing.
*
* @param {string} id
* @param {number} distance
* @returns {void}
*/
export const updateListingDistance = (id, distance) => {
SqliteConnection.execute(
`UPDATE listings
SET distance_to_destination = @distance
WHERE id = @id`,
{ id, distance },
);
};

View File

@@ -0,0 +1,16 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
export function up(db) {
// 1. Add manually_deleted column
db.exec(`ALTER TABLE listings ADD COLUMN manually_deleted INTEGER NOT NULL DEFAULT 0;`);
// 2. Remove change_set column
try {
db.exec(`ALTER TABLE listings DROP COLUMN change_set;`);
} catch {
// if column does not exists for whatever reason
}
}

View File

@@ -0,0 +1,12 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
// Migration: Removing city field and adding distance field
export function up(db) {
db.exec(`
ALTER TABLE listings ADD COLUMN distance_to_destination INTEGER;
`);
}

View File

@@ -0,0 +1,20 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
export function up(db) {
// 1. Remove old unique index
db.exec(`DROP INDEX IF EXISTS idx_settings_name;`);
// 2. Add new unique index for name and user_id.
// Since user_id can be NULL, we need a special index or use coalesce for the index.
// In SQLite, multiple NULLs are allowed in a UNIQUE index, which is fine for our global settings (user_id IS NULL).
// But we want only one global setting for a given name.
// Actually, in SQLite, UNIQUE allows multiple NULL values.
// To have only one NULL user_id for a name, we can use a partial index or COALESCE.
db.exec(`
CREATE UNIQUE INDEX idx_settings_name_user_id ON settings (name, IFNULL(user_id, 'GLOBAL_SETTING'));
`);
}

View File

@@ -37,12 +37,25 @@ function compileSettings(rows, configValues) {
* @returns {Record<string, any>}
*/
export async function refreshSettingsCache() {
const rows = SqliteConnection.query(`SELECT name, value FROM settings`);
const rows = SqliteConnection.query(`SELECT name, value FROM settings WHERE user_id IS NULL`);
const configValues = await readConfigFromStorage();
cachedSettingsConfig = compileSettings(rows, configValues);
return cachedSettingsConfig;
}
/**
* Retrieves user-specific settings from the database.
* @param {string} userId
* @returns {Record<string, any>}
*/
export function getUserSettings(userId) {
if (!userId || typeof userId !== 'string') {
return {};
}
const userRows = SqliteConnection.query(`SELECT name, value FROM settings WHERE user_id = @userId`, { userId });
return compileSettings(userRows, {});
}
/**
* Get the compiled settings config. Loads it once and caches the result.
* @returns {Record<string, any>}
@@ -77,16 +90,28 @@ export function upsertSettings(settingsMapOrEntry, userId = null) {
: Object.entries(settingsMapOrEntry || {});
for (const [name, rawValue] of entries) {
const id = nanoid();
const create_date = Date.now();
const json = toJson(rawValue);
SqliteConnection.execute(
`INSERT INTO settings (id, create_date, name, value, user_id)
if (rawValue === null) {
SqliteConnection.execute(
`DELETE FROM settings WHERE name = @name AND (user_id = @userId OR (user_id IS NULL AND @userId IS NULL))`,
{
name,
userId,
},
);
} else {
const id = nanoid();
const create_date = Date.now();
const json = toJson(rawValue);
SqliteConnection.execute(
`INSERT INTO settings (id, create_date, name, value, user_id)
VALUES (@id, @create_date, @name, @value, @userId)
ON CONFLICT(name) DO UPDATE SET value = excluded.value`,
{ id, create_date, name, value: json, userId },
);
ON CONFLICT(name, IFNULL(user_id, 'GLOBAL_SETTING')) DO UPDATE SET value = excluded.value`,
{ id, create_date, name, value: json, userId },
);
}
}
// keep cache in sync (only for global settings)
if (userId == null) {
refreshSettingsCache();
}
// keep cache in sync
refreshSettingsCache();
}

View File

@@ -47,6 +47,25 @@ export const trackMainEvent = async () => {
}
};
export const trackFeature = async (feature) => {
try {
const settings = await getSettings();
if (settings.analyticsEnabled && !inDevMode()) {
const trackingObj = await enrichTrackingObject({
feature,
});
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);
}
};
/**
* Note, this will only be used when Fredy runs in demo mode
*/

View File

@@ -95,7 +95,7 @@ function isOneOf(word, arr) {
* @returns {boolean}
*/
function nullOrEmpty(val) {
return val == null || val.length === 0 || val === 'null' || val === 'undefined';
return val == null || val.length === 0;
}
/**

262
package-lock.json generated
View File

@@ -1,26 +1,27 @@
{
"name": "fredy",
"version": "18.0.0",
"version": "18.0.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "fredy",
"version": "18.0.0",
"version": "18.0.2",
"license": "Apache-2.0",
"dependencies": {
"@douyinfe/semi-icons": "^2.90.13",
"@douyinfe/semi-ui": "2.90.13",
"@douyinfe/semi-ui-19": "^2.90.13",
"@sendgrid/mail": "8.1.6",
"@vitejs/plugin-react": "5.1.2",
"adm-zip": "^0.5.16",
"better-sqlite3": "^12.6.0",
"better-sqlite3": "^12.6.2",
"body-parser": "2.2.2",
"chart.js": "^4.5.1",
"cheerio": "^1.1.2",
"cookie-session": "2.1.1",
"handlebars": "4.7.8",
"lodash": "4.17.21",
"lodash": "4.17.23",
"maplibre-gl": "^5.16.0",
"nanoid": "5.1.6",
"node-cron": "^4.2.1",
@@ -28,13 +29,14 @@
"node-mailjet": "6.0.11",
"p-throttle": "^8.1.0",
"package-up": "^5.0.0",
"puppeteer": "^24.35.0",
"puppeteer": "^24.36.0",
"puppeteer-extra": "^3.3.6",
"puppeteer-extra-plugin-stealth": "^2.11.2",
"query-string": "9.3.1",
"react": "18.3.1",
"react": "19.2.3",
"react-chartjs-2": "^5.3.1",
"react-dom": "18.3.1",
"react-dom": "19.2.3",
"react-range-slider-input": "^3.3.2",
"react-router": "7.12.0",
"react-router-dom": "7.12.0",
"restana": "5.1.0",
@@ -61,7 +63,7 @@
"lint-staged": "16.2.7",
"mocha": "11.7.5",
"nodemon": "^3.1.11",
"prettier": "3.8.0"
"prettier": "3.8.1"
},
"engines": {
"node": ">=22.0.0",
@@ -1987,6 +1989,53 @@
"react-dom": ">=16.0.0"
}
},
"node_modules/@douyinfe/semi-ui-19": {
"version": "2.90.13",
"resolved": "https://registry.npmjs.org/@douyinfe/semi-ui-19/-/semi-ui-19-2.90.13.tgz",
"integrity": "sha512-OdJOOKEiRBTpwSdGSdtkwzAsQxehrQtvsU37YgMR3jGXL38PGN+UhEjdVbx+p6HS3G9fMbL1n/2+LvnHg4vx8A==",
"license": "MIT",
"dependencies": {
"@dnd-kit/core": "^6.0.8",
"@dnd-kit/sortable": "^7.0.2",
"@dnd-kit/utilities": "^3.2.1",
"@douyinfe/semi-animation": "2.90.13",
"@douyinfe/semi-animation-react": "2.90.13",
"@douyinfe/semi-foundation": "2.90.13",
"@douyinfe/semi-icons": "2.90.13",
"@douyinfe/semi-illustrations": "2.90.13",
"@douyinfe/semi-theme-default": "2.90.13",
"@tiptap/core": "^3.10.7",
"@tiptap/extension-document": "^3.10.7",
"@tiptap/extension-hard-break": "^3.10.7",
"@tiptap/extension-mention": "^3.10.7",
"@tiptap/extension-paragraph": "^3.10.7",
"@tiptap/extension-text": "^3.10.7",
"@tiptap/extensions": "^3.10.7",
"@tiptap/pm": "^3.10.7",
"@tiptap/react": "^3.10.7",
"async-validator": "^3.5.0",
"classnames": "^2.2.6",
"copy-text-to-clipboard": "^2.1.1",
"date-fns": "^2.29.3",
"date-fns-tz": "^1.3.8",
"fast-copy": "^3.0.1 ",
"jsonc-parser": "^3.3.1",
"lodash": "^4.17.21",
"prop-types": "^15.7.2",
"prosemirror-state": "^1.4.3",
"react-resizable": "^3.0.5",
"react-window": "^1.8.2",
"scroll-into-view-if-needed": "^2.2.24",
"utility-types": "^3.10.0"
},
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.1.tgz",
@@ -2707,22 +2756,22 @@
}
},
"node_modules/@tiptap/core": {
"version": "3.15.3",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.15.3.tgz",
"integrity": "sha512-bmXydIHfm2rEtGju39FiQNfzkFx9CDvJe+xem1dgEZ2P6Dj7nQX9LnA1ZscW7TuzbBRkL5p3dwuBIi3f62A66A==",
"version": "3.16.0",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.16.0.tgz",
"integrity": "sha512-XegRaNuoQ/guzBQU2xHxOwFXXrtoXW9tiyXDhssSqylvZmBVSlRIPNHA6ArkHBKm6ehLf6+6Y9fF3uky1yCXYQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/pm": "^3.15.3"
"@tiptap/pm": "^3.16.0"
}
},
"node_modules/@tiptap/extension-bubble-menu": {
"version": "3.11.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.11.0.tgz",
"integrity": "sha512-P3j9lQ+EZ5Zg/isJzLpCPX7bp7WUBmz8GPs/HPlyMyN2su8LqXntITBZr8IP1JNBlB/wR83k/W0XqdC57mG7cA==",
"version": "3.16.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.16.0.tgz",
"integrity": "sha512-nFL7FMu1LjZ5ZGf4U3tw56JLj/SpLysZvHQ1EneGB+90TEI/WReOvTY9VwH1egGWwrl7/OvQuGKclbuLIsy+BA==",
"license": "MIT",
"optional": true,
"dependencies": {
@@ -2733,8 +2782,8 @@
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.11.0",
"@tiptap/pm": "^3.11.0"
"@tiptap/core": "^3.16.0",
"@tiptap/pm": "^3.16.0"
}
},
"node_modules/@tiptap/extension-document": {
@@ -2751,9 +2800,9 @@
}
},
"node_modules/@tiptap/extension-floating-menu": {
"version": "3.11.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.11.0.tgz",
"integrity": "sha512-nEHdWZHEJYX1II1oJQ4aeZ8O/Kss4BRbYFXQFGIvPelCfCYEATpUJh3aq3767ARSq40bOWyu+Dcd4SCW0We6Sw==",
"version": "3.16.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.16.0.tgz",
"integrity": "sha512-cokYXL8EkW+CFIlke70GLL7iKetUtYEp87muMG9oflczyj0BjmGAbO7Mskm+bcQBhxZ0dIYILTqKn2bNBvCDFw==",
"license": "MIT",
"optional": true,
"funding": {
@@ -2762,8 +2811,8 @@
},
"peerDependencies": {
"@floating-ui/dom": "^1.0.0",
"@tiptap/core": "^3.11.0",
"@tiptap/pm": "^3.11.0"
"@tiptap/core": "^3.16.0",
"@tiptap/pm": "^3.16.0"
}
},
"node_modules/@tiptap/extension-hard-break": {
@@ -2835,9 +2884,9 @@
}
},
"node_modules/@tiptap/pm": {
"version": "3.15.3",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.15.3.tgz",
"integrity": "sha512-Zm1BaU1TwFi3CQiisxjgnzzIus+q40bBKWLqXf6WEaus8Z6+vo1MT2pU52dBCMIRaW9XNDq3E5cmGtMc1AlveA==",
"version": "3.16.0",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.16.0.tgz",
"integrity": "sha512-FMxZ6Tc5ONKa/EByDV8lswct6YW2lF/wn11zqXmrfBZhdG7UQPTijpSwb6TCqaO5GOHmixaIaDPj+zimUREHQA==",
"license": "MIT",
"dependencies": {
"prosemirror-changeset": "^2.3.0",
@@ -2865,13 +2914,13 @@
}
},
"node_modules/@tiptap/react": {
"version": "3.11.0",
"resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.11.0.tgz",
"integrity": "sha512-SDGei/2DjwmhzsxIQNr6dkB6NxLgXZjQ6hF36NfDm4937r5NLrWrNk5tCsoDQiKZ0DHEzuJ6yZM5C7I7LZLB6w==",
"version": "3.16.0",
"resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.16.0.tgz",
"integrity": "sha512-r1R19Ma4zxGt8ImiNOqSArAnWO239KUI9tTVeelgTyekPj7643lO8GbtuXJfAeWGPduDIpcAgR/Dd4NKieetiA==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"fast-deep-equal": "^3.1.3",
"fast-equals": "^5.3.3",
"use-sync-external-store": "^1.4.0"
},
"funding": {
@@ -2879,12 +2928,12 @@
"url": "https://github.com/sponsors/ueberdosis"
},
"optionalDependencies": {
"@tiptap/extension-bubble-menu": "^3.11.0",
"@tiptap/extension-floating-menu": "^3.11.0"
"@tiptap/extension-bubble-menu": "^3.16.0",
"@tiptap/extension-floating-menu": "^3.16.0"
},
"peerDependencies": {
"@tiptap/core": "^3.11.0",
"@tiptap/pm": "^3.11.0",
"@tiptap/core": "^3.16.0",
"@tiptap/pm": "^3.16.0",
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
@@ -3674,9 +3723,9 @@
}
},
"node_modules/better-sqlite3": {
"version": "12.6.0",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.0.tgz",
"integrity": "sha512-FXI191x+D6UPWSze5IzZjhz+i9MK9nsuHsmTX9bXVl52k06AfZ2xql0lrgIUuzsMsJ7Vgl5kIptvDgBLIV3ZSQ==",
"version": "12.6.2",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.2.tgz",
"integrity": "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -4177,9 +4226,9 @@
"license": "ISC"
},
"node_modules/chromium-bidi": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-12.0.1.tgz",
"integrity": "sha512-fGg+6jr0xjQhzpy5N4ErZxQ4wF7KLEvhGZXD6EgvZKDhu7iOhZXnZhcDxPJDcwTcrD48NPzOCo84RP2lv3Z+Cg==",
"version": "13.0.1",
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-13.0.1.tgz",
"integrity": "sha512-c+RLxH0Vg2x2syS9wPw378oJgiJNXtYXUvnVAldUlt5uaHekn0CCU7gPksNgHjrH1qFhmjVXQj4esvuthuC7OQ==",
"license": "Apache-2.0",
"dependencies": {
"mitt": "^3.0.1",
@@ -4421,12 +4470,16 @@
"license": "MIT"
},
"node_modules/cookie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/cookie-session": {
@@ -4491,6 +4544,17 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/core-js": {
"version": "3.47.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.47.0.tgz",
"integrity": "sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==",
"hasInstallScript": true,
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/core-js-compat": {
"version": "3.45.1",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.45.1.tgz",
@@ -4867,9 +4931,9 @@
}
},
"node_modules/devtools-protocol": {
"version": "0.0.1534754",
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1534754.tgz",
"integrity": "sha512-26T91cV5dbOYnXdJi5qQHoTtUoNEqwkHcAyu/IKtjIAxiEqPMrDiRkDOPWVsGfNZGmlQVHQbZRSjD8sxagWVsQ==",
"version": "0.0.1551306",
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1551306.tgz",
"integrity": "sha512-CFx8QdSim8iIv+2ZcEOclBKTQY6BI1IEDa7Tm9YkwAXzEWFndTEzpTo5jAUhSnq24IC7xaDw0wvGcm96+Y3PEg==",
"license": "BSD-3-Clause"
},
"node_modules/diff": {
@@ -5811,8 +5875,18 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-equals": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
"integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/fast-fifo": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
@@ -7721,9 +7795,9 @@
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"license": "MIT"
},
"node_modules/lodash.debounce": {
@@ -10110,9 +10184,9 @@
}
},
"node_modules/prettier": {
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.0.tgz",
"integrity": "sha512-yEPsovQfpxYfgWNhCfECjG5AQaO+K3dp6XERmOepyPDVqcJm+bjyCVO3pmU+nAPe0N5dDvekfGezt/EIiRe1TA==",
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"dev": true,
"license": "MIT",
"bin": {
@@ -10444,17 +10518,17 @@
}
},
"node_modules/puppeteer": {
"version": "24.35.0",
"resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.35.0.tgz",
"integrity": "sha512-sbjB5JnJ+3nwgSdRM/bqkFXqLxRz/vsz0GRIeTlCk+j+fGpqaF2dId9Qp25rXz9zfhqnN9s0krek1M/C2GDKtA==",
"version": "24.36.0",
"resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.36.0.tgz",
"integrity": "sha512-BD/VCyV/Uezvd6o7Fd1DmEJSxTzofAKplzDy6T9d4WbLTQ5A+06zY7VwO91ZlNU22vYE8sidVEsTpTrKc+EEnQ==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@puppeteer/browsers": "2.11.1",
"chromium-bidi": "12.0.1",
"chromium-bidi": "13.0.1",
"cosmiconfig": "^9.0.0",
"devtools-protocol": "0.0.1534754",
"puppeteer-core": "24.35.0",
"devtools-protocol": "0.0.1551306",
"puppeteer-core": "24.36.0",
"typed-query-selector": "^2.12.0"
},
"bin": {
@@ -10465,17 +10539,17 @@
}
},
"node_modules/puppeteer-core": {
"version": "24.35.0",
"resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.35.0.tgz",
"integrity": "sha512-vt1zc2ME0kHBn7ZDOqLvgvrYD5bqNv5y2ZNXzYnCv8DEtZGw/zKhljlrGuImxptZ4rq+QI9dFGrUIYqG4/IQzA==",
"version": "24.36.0",
"resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.36.0.tgz",
"integrity": "sha512-P3Ou0MAFDCQ0dK1d9F9+8jTrg6JvXjUacgG0YkJQP4kbEnUOGokSDEMmMId5ZhXD5HwsHM202E9VwEpEjWfwxg==",
"license": "Apache-2.0",
"dependencies": {
"@puppeteer/browsers": "2.11.1",
"chromium-bidi": "12.0.1",
"chromium-bidi": "13.0.1",
"debug": "^4.4.3",
"devtools-protocol": "0.0.1534754",
"devtools-protocol": "0.0.1551306",
"typed-query-selector": "^2.12.0",
"webdriver-bidi-protocol": "0.3.10",
"webdriver-bidi-protocol": "0.4.0",
"ws": "^8.19.0"
},
"engines": {
@@ -10751,13 +10825,10 @@
}
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
},
"engines": {
"node": ">=0.10.0"
}
@@ -10773,16 +10844,15 @@
}
},
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^18.3.1"
"react": "^19.2.3"
}
},
"node_modules/react-draggable": {
@@ -10805,6 +10875,25 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/react-range-slider-input": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/react-range-slider-input/-/react-range-slider-input-3.3.2.tgz",
"integrity": "sha512-CGyD/6Vlc7qakSW+92WAKrp333Xo9W+udW62xvf6dSwqEj7LFSY75udcbNRtCQhuXW1O7o71yC4AC/CC0etqSg==",
"license": "MIT",
"dependencies": {
"clsx": "^1.1.1",
"core-js": "^3.22.4"
}
},
"node_modules/react-range-slider-input/node_modules/clsx": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
"integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/react-refresh": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
@@ -11432,13 +11521,10 @@
"optional": true
},
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
}
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
},
"node_modules/scroll-into-view-if-needed": {
"version": "2.2.31",
@@ -11513,9 +11599,9 @@
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz",
"integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==",
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/set-function-length": {
@@ -13038,9 +13124,9 @@
}
},
"node_modules/webdriver-bidi-protocol": {
"version": "0.3.10",
"resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.3.10.tgz",
"integrity": "sha512-5LAE43jAVLOhB/QqX4bwSiv0Hg1HBfMmOuwBSXHdvg4GMGu9Y0lIq7p4R/yySu6w74WmaR4GM4H9t2IwLW7hgw==",
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.0.tgz",
"integrity": "sha512-U9VIlNRrq94d1xxR9JrCEAx5Gv/2W7ERSv8oWRoNe/QYbfccS0V3h/H6qeNeCRJxXGMhhnkqvwNrvPAYeuP9VA==",
"license": "Apache-2.0"
},
"node_modules/whatwg-encoding": {

View File

@@ -1,6 +1,6 @@
{
"name": "fredy",
"version": "18.0.2",
"version": "19.2.0",
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
"scripts": {
"prepare": "husky",
@@ -61,16 +61,17 @@
"dependencies": {
"@douyinfe/semi-icons": "^2.90.13",
"@douyinfe/semi-ui": "2.90.13",
"@douyinfe/semi-ui-19": "^2.90.13",
"@sendgrid/mail": "8.1.6",
"@vitejs/plugin-react": "5.1.2",
"adm-zip": "^0.5.16",
"better-sqlite3": "^12.6.0",
"better-sqlite3": "^12.6.2",
"body-parser": "2.2.2",
"chart.js": "^4.5.1",
"cheerio": "^1.1.2",
"cheerio": "^1.2.0",
"cookie-session": "2.1.1",
"handlebars": "4.7.8",
"lodash": "4.17.21",
"lodash": "4.17.23",
"maplibre-gl": "^5.16.0",
"nanoid": "5.1.6",
"node-cron": "^4.2.1",
@@ -78,16 +79,16 @@
"node-mailjet": "6.0.11",
"p-throttle": "^8.1.0",
"package-up": "^5.0.0",
"puppeteer": "^24.35.0",
"puppeteer": "^24.36.0",
"puppeteer-extra": "^3.3.6",
"puppeteer-extra-plugin-stealth": "^2.11.2",
"query-string": "9.3.1",
"react": "18.3.1",
"react": "19.2.3",
"react-chartjs-2": "^5.3.1",
"react-dom": "18.3.1",
"react-dom": "19.2.3",
"react-range-slider-input": "^3.3.2",
"react-router": "7.12.0",
"react-router-dom": "7.12.0",
"react-router": "7.13.0",
"react-router-dom": "7.13.0",
"restana": "5.1.0",
"semver": "^7.7.3",
"serve-static": "2.2.1",
@@ -112,6 +113,6 @@
"lint-staged": "16.2.7",
"mocha": "11.7.5",
"nodemon": "^3.1.11",
"prettier": "3.8.0"
"prettier": "3.8.1"
}
}

View File

@@ -3,6 +3,7 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
/* eslint-disable no-unused-vars */
const db = {};
export const storeListings = (jobKey, providerId, listings) => {
if (!Array.isArray(listings)) throw Error('Not a valid array');
@@ -11,3 +12,16 @@ export const storeListings = (jobKey, providerId, listings) => {
export const getKnownListingHashesForJobAndProvider = (jobKey, providerId) => {
return db[providerId] || [];
};
export const getGeocoordinatesByAddress = (any) => {
return null;
};
export function getUserSettings(userId) {
return null;
}
export const updateListingDistance = (id, distance) => {
// noop
};
/* eslint-enable no-unused-vars */

View File

@@ -15,6 +15,15 @@ export const mockFredy = async () => {
'../lib/services/storage/listingsStorage.js': {
...mockStore,
},
'../lib/services/storage/settingsStorage.js': {
...mockStore,
},
'../lib/services/geocoding/geoCodingService.js': {
geocodeAddress: mockStore.getGeocoordinatesByAddress,
},
'../lib/services/storage/jobStorage.js': {
getJob: (jobKey) => ({ id: jobKey, userId: 'user1' }),
},
'../lib/notification/notify.js': {
send,
},

View File

@@ -8,6 +8,7 @@ import React, { useEffect } from 'react';
import InsufficientPermission from './components/permission/InsufficientPermission';
import PermissionAwareRoute from './components/permission/PermissionAwareRoute';
import GeneralSettings from './views/generalSettings/GeneralSettings';
import UserSettings from './views/userSettings/UserSettings';
import JobMutation from './views/jobs/mutation/JobMutation';
import UserMutator from './views/user/mutation/UserMutator';
import { useActions, useSelector } from './services/state/store';
@@ -18,12 +19,12 @@ import Jobs from './views/jobs/Jobs';
import './App.less';
import TrackingModal from './components/tracking/TrackingModal.jsx';
import { Banner, Divider } from '@douyinfe/semi-ui';
import { Banner, Divider } from '@douyinfe/semi-ui-19';
import VersionBanner from './components/version/VersionBanner.jsx';
import Listings from './views/listings/Listings.jsx';
import MapView from './views/listings/Map.jsx';
import Navigation from './components/navigation/Navigation.jsx';
import { Layout } from '@douyinfe/semi-ui';
import { Layout } from '@douyinfe/semi-ui-19';
import FredyFooter from './components/footer/FredyFooter.jsx';
import WatchlistManagement from './views/listings/management/WatchlistManagement.jsx';
import Dashboard from './views/dashboard/Dashboard.jsx';
@@ -39,12 +40,12 @@ export default function FredyApp() {
async function init() {
await actions.user.getCurrentUser();
if (!needsLogin()) {
await actions.features.getFeatures();
await actions.provider.getProvider();
await actions.jobsData.getJobs();
await actions.jobsData.getSharableUserList();
await actions.notificationAdapter.getAdapter();
await actions.generalSettings.getGeneralSettings();
await actions.userSettings.getUserSettings();
await actions.versionUpdate.getVersionUpdate();
}
setLoading(false);
@@ -123,6 +124,14 @@ export default function FredyApp() {
</PermissionAwareRoute>
}
/>
<Route
path="/userSettings"
element={
<PermissionAwareRoute currentUser={currentUser} adminOnly={false}>
<UserSettings />
</PermissionAwareRoute>
}
/>
<Route
path="/generalSettings"
element={

View File

@@ -40,6 +40,8 @@ a:active {
text-decoration: underline;
}
a {outline : none;}
.semi-icon:not(.semi-tabs-bar .semi-tabs-tab .semi-icon) {
vertical-align: middle;
}

View File

@@ -7,8 +7,8 @@ import React from 'react';
import { HashRouter } from 'react-router-dom';
import { createRoot } from 'react-dom/client';
import en_US from '@douyinfe/semi-ui/lib/es/locale/source/en_US';
import { LocaleProvider } from '@douyinfe/semi-ui';
import en_US from '@douyinfe/semi-ui-19/lib/es/locale/source/en_US';
import { LocaleProvider } from '@douyinfe/semi-ui-19';
import App from './App';
import './Index.less';

View File

@@ -6,7 +6,7 @@
import React from 'react';
import './FredyFooter.less';
import { useSelector } from '../../services/state/store.js';
import { Typography } from '@douyinfe/semi-ui';
import { Typography } from '@douyinfe/semi-ui-19';
export default function FredyFooter() {
const { Text } = Typography;

View File

@@ -20,12 +20,13 @@ import {
Pagination,
Toast,
Empty,
} from '@douyinfe/semi-ui';
} from '@douyinfe/semi-ui-19';
import {
IconAlertTriangle,
IconDelete,
IconDescend2,
IconEdit,
IconCopy,
IconPlayCircle,
IconBriefcase,
IconBell,
@@ -198,12 +199,14 @@ const JobGrid = () => {
<div className="jobGrid__searchbar">
<Input prefix={<IconSearch />} showClear placeholder="Search" onChange={handleFilterChange} />
<Popover content="Filter / Sort Results" style={{ color: 'white', padding: '.5rem' }}>
<Button
icon={<IconFilter />}
onClick={() => {
setShowFilterBar(!showFilterBar);
}}
/>
<div>
<Button
icon={<IconFilter />}
onClick={() => {
setShowFilterBar(!showFilterBar);
}}
/>
</div>
</Popover>
</div>
</div>
@@ -287,7 +290,9 @@ const JobGrid = () => {
'This job has been shared with you by another user, therefor it is read-only.',
)}
>
<IconAlertTriangle style={{ color: 'rgba(var(--semi-yellow-7), 1)', marginLeft: '8px' }} />
<div>
<IconAlertTriangle style={{ color: 'rgba(var(--semi-yellow-7), 1)', marginLeft: '8px' }} />
</div>
</Popover>
)}
</div>
@@ -343,40 +348,59 @@ const JobGrid = () => {
<div className="jobGrid__actions">
<Popover content={getPopoverContent('Run Job')}>
<Button
type="primary"
theme="solid"
icon={<IconPlayCircle />}
disabled={job.isOnlyShared || job.running}
onClick={() => onJobRun(job.id)}
/>
<div>
<Button
type="primary"
theme="solid"
icon={<IconPlayCircle />}
disabled={job.isOnlyShared || job.running}
onClick={() => onJobRun(job.id)}
/>
</div>
</Popover>
<Popover content={getPopoverContent('Edit a Job')}>
<Button
type="secondary"
theme="solid"
icon={<IconEdit />}
disabled={job.isOnlyShared}
onClick={() => navigate(`/jobs/edit/${job.id}`)}
/>
<div>
<Button
type="secondary"
theme="solid"
icon={<IconEdit />}
disabled={job.isOnlyShared}
onClick={() => navigate(`/jobs/edit/${job.id}`)}
/>
</div>
</Popover>
<Popover content={getPopoverContent('Clone Job')}>
<div>
<Button
type="tertiary"
theme="solid"
icon={<IconCopy />}
disabled={job.isOnlyShared}
onClick={() => navigate('/jobs/new', { state: { cloneFrom: job.id } })}
/>
</div>
</Popover>
<Popover content={getPopoverContent('Delete all found Listings of this Job')}>
<Button
type="danger"
theme="solid"
icon={<IconDescend2 />}
disabled={job.isOnlyShared}
onClick={() => onListingRemoval(job.id)}
/>
<div>
<Button
type="danger"
theme="solid"
icon={<IconDescend2 />}
disabled={job.isOnlyShared}
onClick={() => onListingRemoval(job.id)}
/>
</div>
</Popover>
<Popover content={getPopoverContent('Delete Job')}>
<Button
type="danger"
theme="solid"
icon={<IconDelete />}
disabled={job.isOnlyShared}
onClick={() => onJobRemoval(job.id)}
/>
<div>
<Button
type="danger"
theme="solid"
icon={<IconDelete />}
disabled={job.isOnlyShared}
onClick={() => onJobRemoval(job.id)}
/>
</div>
</Popover>
</div>
</div>

View File

@@ -19,7 +19,7 @@ import {
Select,
Popover,
Empty,
} from '@douyinfe/semi-ui';
} from '@douyinfe/semi-ui-19';
import {
IconBriefcase,
IconCart,
@@ -31,6 +31,7 @@ import {
IconStarStroked,
IconSearch,
IconFilter,
IconActivity,
} from '@douyinfe/semi-icons';
import no_image from '../../../assets/no_image.jpg';
import * as timeService from '../../../services/time/timeService.js';
@@ -102,17 +103,23 @@ const ListingsGrid = () => {
setPage(_page);
};
const cap = (val) => {
return String(val).charAt(0).toUpperCase() + String(val).slice(1);
};
return (
<div className="listingsGrid">
<div className="listingsGrid__searchbar">
<Input prefix={<IconSearch />} showClear placeholder="Search" onChange={handleFilterChange} />
<Popover content="Filter / Sort Results" style={{ color: 'white', padding: '.5rem' }}>
<Button
icon={<IconFilter />}
onClick={() => {
setShowFilterBar(!showFilterBar);
}}
/>
<div>
<Button
icon={<IconFilter />}
onClick={() => {
setShowFilterBar(!showFilterBar);
}}
/>
</div>
</Popover>
</div>
{showFilterBar && (
@@ -248,11 +255,9 @@ const ListingsGrid = () => {
bodyStyle={{ padding: '12px' }}
>
<div className="listingsGrid__content">
<a href={item.url} target="_blank" rel="noopener noreferrer" className="listingsGrid__titleLink">
<Text strong ellipsis={{ showTooltip: true }} className="listingsGrid__title">
{item.title}
</Text>
</a>
<Text strong ellipsis={{ showTooltip: true }} className="listingsGrid__title">
{cap(item.title)}
</Text>
<Space vertical align="start" spacing={2} style={{ width: '100%', marginTop: 8 }}>
<Text type="secondary" icon={<IconCart />} size="small">
{item.price}
@@ -272,18 +277,23 @@ const ListingsGrid = () => {
<Text type="tertiary" size="small" icon={<IconBriefcase />}>
{item.provider.charAt(0).toUpperCase() + item.provider.slice(1)}
</Text>
{item.distance_to_destination ? (
<Text type="tertiary" size="small" icon={<IconActivity />}>
{item.distance_to_destination} m to chosen address
</Text>
) : (
<Text type="tertiary" size="small" icon={<IconActivity />}>
Distance cannot be calculated, provide an address
</Text>
)}
</Space>
<Divider margin=".6rem" />
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Button
title="Link to listing"
type="primary"
size="small"
onClick={async () => {
window.open(item.link);
}}
icon={<IconLink />}
/>
<div className="listingsGrid__linkButton">
<a href={item.link} target="_blank" rel="noopener noreferrer">
<IconLink />
</a>
</div>
<Button
title="Remove"

View File

@@ -103,4 +103,17 @@
&__setupButton {
margin-bottom: 1rem;
}
&__linkButton {
background: var(--semi-color-fill-0);
font-size: 14px;
line-height: 20px;
font-weight: 600;
height: 24px;
width: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 3px;
}
}

View File

@@ -4,7 +4,7 @@
*/
import React from 'react';
import { Typography } from '@douyinfe/semi-ui';
import { Typography } from '@douyinfe/semi-ui-19';
export default function Headline({ text, size = 3 } = {}) {
const { Title } = Typography;

View File

@@ -4,7 +4,7 @@
*/
import React from 'react';
import { Button } from '@douyinfe/semi-ui';
import { Button } from '@douyinfe/semi-ui-19';
import { xhrPost } from '../../services/xhr';
import { IconUser } from '@douyinfe/semi-icons';

View File

@@ -4,7 +4,7 @@
*/
import React, { useEffect, useState } from 'react';
import { Button, Nav } from '@douyinfe/semi-ui';
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';
import heart from '../../assets/heart.png';
@@ -12,7 +12,6 @@ import Logout from '../logout/Logout.jsx';
import { useLocation, useNavigate } from 'react-router-dom';
import './Navigate.less';
import { useFeature } from '../../hooks/featureHook.js';
import { useScreenWidth } from '../../hooks/screenWidth.js';
export default function Navigation({ isAdmin }) {
@@ -21,7 +20,6 @@ export default function Navigation({ isAdmin }) {
const width = useScreenWidth();
const [collapsed, setCollapsed] = useState(width <= 850);
const watchlistFeature = useFeature('WATCHLIST_MANAGEMENT') || false;
useEffect(() => {
if (width <= 850) {
@@ -46,11 +44,9 @@ export default function Navigation({ isAdmin }) {
if (isAdmin) {
const settingsItems = [
{ itemKey: '/users', text: 'User Management' },
{ itemKey: '/userSettings', text: 'User Specific Settings' },
{ itemKey: '/generalSettings', text: 'General Settings' },
];
if (watchlistFeature) {
settingsItems.push({ itemKey: '/watchlistManagement', text: 'Watchlist Management' });
}
items.push({
itemKey: 'settings',
@@ -58,6 +54,13 @@ export default function Navigation({ isAdmin }) {
icon: <IconSetting />,
items: settingsItems,
});
} else {
items.push({
itemKey: 'settings',
text: 'Settings',
icon: <IconSetting />,
items: [{ itemKey: '/userSettings', text: 'User Specific Settings' }],
});
}
function parsePathName(name) {

View File

@@ -4,7 +4,7 @@
*/
import React from 'react';
import { Card } from '@douyinfe/semi-ui';
import { Card } from '@douyinfe/semi-ui-19';
import './SegmentParts.less';

View File

@@ -5,7 +5,7 @@
import React from 'react';
import { Empty, Table, Button } from '@douyinfe/semi-ui';
import { Empty, Table, Button } from '@douyinfe/semi-ui-19';
import { IconDelete, IconEdit } from '@douyinfe/semi-icons';
export default function NotificationAdapterTable({ notificationAdapter = [], onRemove, onEdit } = {}) {

View File

@@ -5,7 +5,7 @@
import React from 'react';
import { Empty, Table, Button } from '@douyinfe/semi-ui';
import { Empty, Table, Button } from '@douyinfe/semi-ui-19';
import { IconDelete, IconEdit } from '@douyinfe/semi-icons';
export default function ProviderTable({ providerData = [], onRemove, onEdit } = {}) {

View File

@@ -7,7 +7,7 @@ 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';
import { Table, Button, Empty } from '@douyinfe/semi-ui-19';
import { IconDelete, IconEdit } from '@douyinfe/semi-icons';
const empty = (

View File

@@ -4,7 +4,7 @@
*/
import React from 'react';
import { Modal } from '@douyinfe/semi-ui';
import { Modal } from '@douyinfe/semi-ui-19';
import Logo from '../logo/Logo.jsx';
import { xhrPost } from '../../services/xhr.js';

View File

@@ -4,9 +4,9 @@
*/
import React from 'react';
import { Collapse, Descriptions } from '@douyinfe/semi-ui';
import { Collapse, Descriptions } from '@douyinfe/semi-ui-19';
import { useSelector } from '../../services/state/store.js';
import { MarkdownRender } from '@douyinfe/semi-ui';
import { MarkdownRender } from '@douyinfe/semi-ui-19';
import './VersionBanner.less';

View File

@@ -63,16 +63,6 @@ export const useFredyState = create(
}
},
},
features: {
async getFeatures() {
try {
const response = await xhrGet('/api/features');
set((state) => ({ ...state.features, ...response.json }));
} catch (Exception) {
console.error('Error while trying to get resource for api/features. Error:', Exception);
}
},
},
provider: {
async getProvider() {
try {
@@ -228,6 +218,16 @@ export const useFredyState = create(
}
},
},
userSettings: {
async getUserSettings() {
try {
const response = await xhrGet('/api/user/settings');
set((state) => ({ userSettings: { ...state.userSettings, settings: response.json } }));
} catch (Exception) {
console.error('Error while trying to get resource for api/user/settings. Error:', Exception);
}
},
},
};
// Initial state
@@ -241,8 +241,8 @@ export const useFredyState = create(
mapListings: [],
maxPrice: 0,
},
features: {},
generalSettings: { settings: {} },
userSettings: { settings: {} },
demoMode: { demoMode: false },
versionUpdate: {},
provider: [],
@@ -265,9 +265,9 @@ export const useFredyState = create(
versionUpdate: { ...effects.versionUpdate },
listingsData: { ...effects.listingsData },
provider: { ...effects.provider },
features: { ...effects.features },
jobsData: { ...effects.jobsData },
user: { ...effects.user },
userSettings: { ...effects.userSettings },
};
return {

View File

@@ -4,7 +4,7 @@
*/
import React from 'react';
import { Button, Col, Row, Toast } from '@douyinfe/semi-ui';
import { Button, Col, Row, Toast } from '@douyinfe/semi-ui-19';
import {
IconTerminal,
IconStar,
@@ -136,7 +136,7 @@ export default function Dashboard() {
<KpiCard
title="Avg. Price"
color="purple"
value={`${!kpis.avgPriceOfListings ? '---' : kpis.avgPriceOfListings} EUR`}
value={`${!kpis.avgPriceOfListings ? '---' : kpis.avgPriceOfListings} `}
icon={<IconNoteMoney />}
description="Avg. Price of listings"
/>

View File

@@ -7,11 +7,11 @@ import React from 'react';
import { useActions, useSelector } from '../../services/state/store';
import { Divider, TimePicker, Button, Checkbox, Input, Modal } from '@douyinfe/semi-ui';
import { InputNumber } from '@douyinfe/semi-ui';
import { Divider, TimePicker, Button, Checkbox, Input, Modal } from '@douyinfe/semi-ui-19';
import { InputNumber } from '@douyinfe/semi-ui-19';
import { xhrPost } from '../../services/xhr';
import { SegmentPart } from '../../components/segment/SegmentPart';
import { Banner, Toast } from '@douyinfe/semi-ui';
import { Banner, Toast } from '@douyinfe/semi-ui-19';
import {
downloadBackup as downloadBackupZip,
precheckRestore as clientPrecheckRestore,

View File

@@ -12,8 +12,8 @@ import ProviderMutator from './components/provider/ProviderMutator';
import Headline from '../../../components/headline/Headline';
import { useActions, useSelector } from '../../../services/state/store';
import { xhrPost } from '../../../services/xhr';
import { useNavigate, useParams } from 'react-router-dom';
import { Divider, Input, Switch, Button, TagInput, Toast, Select } from '@douyinfe/semi-ui';
import { useNavigate, useParams, useLocation } from 'react-router-dom';
import { Divider, Input, Switch, Button, TagInput, Toast, Select } from '@douyinfe/semi-ui-19';
import './JobMutation.less';
import { SegmentPart } from '../../../components/segment/SegmentPart';
import {
@@ -30,14 +30,20 @@ export default function JobMutator() {
const jobs = useSelector((state) => state.jobsData.jobs);
const shareableUserList = useSelector((state) => state.jobsData.shareableUserList);
const params = useParams();
const location = useLocation();
const cloneFromId = location.state?.cloneFrom;
const jobToClone = cloneFromId ? jobs.find((job) => job.id === cloneFromId) : null;
const jobToBeEdit = params.jobId == null ? null : jobs.find((job) => job.id === params.jobId);
const defaultBlacklist = jobToBeEdit?.blacklist || [];
const defaultName = jobToBeEdit?.name || null;
const defaultProviderData = jobToBeEdit?.provider || [];
const defaultNotificationAdapter = jobToBeEdit?.notificationAdapter || [];
const defaultEnabled = jobToBeEdit?.enabled ?? true;
const sourceJob = jobToBeEdit || jobToClone;
const defaultBlacklist = sourceJob?.blacklist || [];
const defaultName = jobToClone ? `Copy of - ${sourceJob?.name}` : sourceJob?.name || null;
const defaultProviderData = sourceJob?.provider || [];
const defaultNotificationAdapter = sourceJob?.notificationAdapter || [];
const defaultEnabled = sourceJob?.enabled ?? true;
const defaultShareWithUsers = sourceJob?.shared_with_user ?? [];
const [providerToEdit, setProviderToEdit] = useState(null);
const [providerCreationVisible, setProviderCreationVisibility] = useState(false);
@@ -47,7 +53,7 @@ export default function JobMutator() {
const [name, setName] = useState(defaultName);
const [blacklist, setBlacklist] = useState(defaultBlacklist);
const [notificationAdapterData, setNotificationAdapterData] = useState(defaultNotificationAdapter);
const [shareWithUsers, setShareWithUsers] = useState(jobToBeEdit?.shared_with_user ?? []);
const [shareWithUsers, setShareWithUsers] = useState(defaultShareWithUsers);
const [enabled, setEnabled] = useState(defaultEnabled);
const navigate = useNavigate();
const actions = useActions();

View File

@@ -9,7 +9,7 @@ import { transform } from '../../../../../services/transformer/notificationAdapt
import { xhrPost } from '../../../../../services/xhr';
import Help from './NotificationHelpDisplay';
import { useSelector } from '../../../../../services/state/store';
import { Banner, Button, Form, Modal, Select, Switch } from '@douyinfe/semi-ui';
import { Banner, Button, Form, Modal, Select, Switch } from '@douyinfe/semi-ui-19';
import './NotificationAdapterMutator.less';
import { useScreenWidth } from '../../../../../hooks/screenWidth.js';

View File

@@ -4,7 +4,7 @@
*/
import React from 'react';
import { Banner, MarkdownRender } from '@douyinfe/semi-ui';
import { Banner, MarkdownRender } from '@douyinfe/semi-ui-19';
export default function Help({ readme }) {
return (

View File

@@ -5,7 +5,7 @@
import React, { useState, useEffect } from 'react';
import { Banner, Modal, Select, Input } from '@douyinfe/semi-ui';
import { Banner, Modal, Select, Input } from '@douyinfe/semi-ui-19';
import { transform } from '../../../../../services/transformer/providerTransformer';
import { useSelector } from '../../../../../services/state/store';
import { IconLikeHeart } from '@douyinfe/semi-icons';

View File

@@ -4,15 +4,21 @@
*/
import React, { useEffect, useRef, useState } from 'react';
import { renderToString } from 'react-dom/server';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import { useSelector, useActions } from '../../services/state/store.js';
import { Select, Space, Typography, Button, Popover, Divider, Switch, Banner } from '@douyinfe/semi-ui';
import { IconFilter } from '@douyinfe/semi-icons';
import { distanceMeters, generateCircleCoords, getBoundsFromCenter, getBoundsFromCoords } from './mapUtils.js';
import { Select, Space, Typography, Button, Popover, Divider, Switch, Banner, Toast } from '@douyinfe/semi-ui-19';
import { IconFilter, IconLink } from '@douyinfe/semi-icons';
import { IconDelete } from '@douyinfe/semi-icons';
import no_image from '../../assets/no_image.jpg';
import RangeSlider from 'react-range-slider-input';
import 'react-range-slider-input/dist/style.css';
import './Map.less';
import { xhrDelete } from '../../services/xhr.js';
import { Link } from 'react-router';
const { Text } = Typography;
@@ -65,8 +71,10 @@ export default function MapView() {
const mapContainer = useRef(null);
const map = useRef(null);
const markers = useRef([]);
const homeMarker = useRef(null);
const actions = useActions();
const listings = useSelector((state) => state.listingsData.mapListings);
const homeAddress = useSelector((state) => state.userSettings.settings.home_address);
const [style, setStyle] = useState('STANDARD');
const [show3dBuildings, setShow3dBuildings] = useState(false);
@@ -74,6 +82,7 @@ export default function MapView() {
const [jobId, setJobId] = useState(null);
const [priceRange, setPriceRange] = useState([0, 0]);
const [showFilterBar, setShowFilterBar] = useState(false);
const [distanceFilter, setDistanceFilter] = useState(0);
useEffect(() => {
setPriceRange([0, getMaxPrice()]);
@@ -93,6 +102,22 @@ export default function MapView() {
return listings.filter((listing) => listing.price && listing.price >= min && listing.price <= max);
};
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');
}
};
return () => {
delete window.deleteListing;
};
}, []);
useEffect(() => {
if (map.current) return;
@@ -150,6 +175,7 @@ export default function MapView() {
if (!map.current) return;
const add3dLayer = () => {
if (!map.current || !map.current.isStyleLoaded()) return;
if (show3dBuildings) {
if (!map.current.getSource('openfreemap')) {
map.current.addSource('openfreemap', {
@@ -201,11 +227,7 @@ export default function MapView() {
}
};
if (map.current.isStyleLoaded()) {
add3dLayer();
} else {
map.current.once('styledata', add3dLayer);
}
add3dLayer();
}, [show3dBuildings, style]);
const setMapStyle = (value) => {
@@ -225,12 +247,110 @@ export default function MapView() {
fetchListings();
}, [jobId]);
useEffect(() => {
if (!map.current) return;
if (homeAddress?.coords) {
// We only want to zoom/fly when distanceFilter OR homeAddress actually change,
// not on every render. useEffect dependency array handles this.
if (distanceFilter > 0) {
const bounds = getBoundsFromCenter([homeAddress.coords.lng, homeAddress.coords.lat], distanceFilter);
map.current.fitBounds(bounds, {
padding: 20,
maxZoom: 15,
duration: 1000,
});
} else {
map.current.flyTo({
center: [homeAddress.coords.lng, homeAddress.coords.lat],
zoom: 12,
duration: 1000,
});
}
} else {
const filtered = filterListings();
const coords = filtered
.filter((l) => l.latitude != null && l.longitude != null && l.latitude !== -1 && l.longitude !== -1)
.map((l) => [l.longitude, l.latitude]);
if (coords.length > 0) {
const bounds = getBoundsFromCoords(coords);
map.current.fitBounds(bounds, {
padding: 50,
maxZoom: 15,
duration: 1000,
});
}
}
}, [homeAddress?.address, distanceFilter, listings]);
useEffect(() => {
if (!map.current) return;
markers.current.forEach((marker) => marker.remove());
markers.current = [];
if (homeMarker.current) {
homeMarker.current.remove();
homeMarker.current = null;
}
if (homeAddress?.coords) {
homeMarker.current = new maplibregl.Marker({ color: 'red' })
.setLngLat([homeAddress.coords.lng, homeAddress.coords.lat])
.setPopup(
new maplibregl.Popup({ offset: 25 }).setHTML(
`<div class="map-popup-content"><h4>Home Address</h4><p>${homeAddress.address}</p></div>`,
),
)
.addTo(map.current);
}
const addCircleLayer = () => {
if (!map.current || !map.current.isStyleLoaded()) return;
if (map.current.getLayer('distance-circle')) map.current.removeLayer('distance-circle');
if (map.current.getLayer('distance-circle-outline')) map.current.removeLayer('distance-circle-outline');
if (map.current.getSource('distance-circle-source')) map.current.removeSource('distance-circle-source');
if (distanceFilter > 0 && homeAddress?.coords) {
const ret = generateCircleCoords([homeAddress.coords.lng, homeAddress.coords.lat], distanceFilter);
map.current.addSource('distance-circle-source', {
type: 'geojson',
data: {
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [ret],
},
},
});
map.current.addLayer({
id: 'distance-circle',
type: 'fill',
source: 'distance-circle-source',
paint: {
'fill-color': '#90EE90',
'fill-opacity': 0.3,
},
});
map.current.addLayer({
id: 'distance-circle-outline',
type: 'line',
source: 'distance-circle-source',
paint: {
'line-color': '#006400',
'line-width': 1,
},
});
}
};
addCircleLayer();
filterListings().forEach((listing) => {
if (
listing.latitude != null &&
@@ -242,8 +362,8 @@ export default function MapView() {
? listing.provider.charAt(0).toUpperCase() + listing.provider.slice(1)
: 'N/A';
const popup = new maplibregl.Popup({ offset: 25 }).setHTML(
`<div class="map-popup-content">
const popupContent = `
<div class="map-popup-content">
<img src="${listing.image_url || no_image}" alt="${listing.title}" />
<h4>${listing.title}</h4>
<div class="info">
@@ -251,12 +371,40 @@ export default function MapView() {
<span><strong>Address:</strong> ${listing.address || 'N/A'}</span>
<span><strong>Job:</strong> ${listing.job_name || 'N/A'}</span>
<span><strong>Provider:</strong> ${capitalizedProvider}</span>
<a href="${listing.link}" target="_blank" rel="noopener noreferrer">View Listing</a>
<span><strong>Size:</strong> ${listing.size != null ? `${listing.size}` : 'N/A'}</span>
<div style="display: flex; gap: 8px; margin-top: 8px; justify-content: space-between;">
<div class="map-popup-content__linkButton">
<a href="${listing.link}" target="_blank" rel="noopener noreferrer">
${renderToString(<IconLink />)}
</a>
</div>
<button
class="map-popup-content__deleteButton"
title="Remove"
onclick="deleteListing('${listing.id}')"
>
${renderToString(<IconDelete />)}
</button>
</div>
</div>
</div>`,
);
</div>`;
const marker = new maplibregl.Marker()
const popup = new maplibregl.Popup({ offset: 25 }).setHTML(popupContent);
let color = '#3FB1CE'; // Default blue-ish
if (distanceFilter > 0 && homeAddress?.coords) {
const dist = distanceMeters(
homeAddress.coords.lat,
homeAddress.coords.lng,
listing.latitude,
listing.longitude,
);
if (dist <= distanceFilter * 1000) {
color = 'orange';
}
}
const marker = new maplibregl.Marker({ color })
.setLngLat([listing.longitude, listing.latitude])
.setPopup(popup)
.addTo(map.current);
@@ -264,7 +412,7 @@ export default function MapView() {
markers.current.push(marker);
}
});
}, [listings, priceRange]);
}, [listings, priceRange, homeAddress, distanceFilter]);
return (
<div className="map-view-container">
@@ -281,12 +429,14 @@ export default function MapView() {
</div>
</div>
<Popover content="Filter Results" style={{ color: 'white', padding: '.5rem' }}>
<Button
icon={<IconFilter />}
onClick={() => {
setShowFilterBar(!showFilterBar);
}}
/>
<div>
<Button
icon={<IconFilter />}
onClick={() => {
setShowFilterBar(!showFilterBar);
}}
/>
</div>
</Popover>
</div>
@@ -316,6 +466,29 @@ export default function MapView() {
</div>
</div>
<Divider layout="vertical" />
<div className="listingsGrid__toolbar__card">
<div>
<Text strong>Distance:</Text>
</div>
<div style={{ display: 'flex', gap: '.3rem', alignItems: 'center' }}>
<Select
placeholder="Distance"
style={{ width: 100 }}
onChange={(val) => {
setDistanceFilter(val);
}}
value={distanceFilter}
>
<Select.Option value={0}>---</Select.Option>
<Select.Option value={5}>5 km</Select.Option>
<Select.Option value={10}>10 km</Select.Option>
<Select.Option value={15}>15 km</Select.Option>
<Select.Option value={20}>20 km</Select.Option>
<Select.Option value={25}>25 km</Select.Option>
</Select>
</div>
</div>
<Divider layout="vertical" />
<div className="listingsGrid__toolbar__card">
<div>
<Text strong>Price Range ():</Text>
@@ -341,6 +514,21 @@ export default function MapView() {
</div>
)}
{!homeAddress && (
<Banner
fullMode={true}
type="warning"
bordered
closeIcon={null}
description={
<span>
You have not set your home address yet. Please do so in the <Link to="/userSettings">user settings</Link>{' '}
to use the distance filter.
</span>
}
/>
)}
<Banner
fullMode={true}
type="info"

View File

@@ -43,10 +43,62 @@
}
.info {
font-size: 0.9rem;
font-size: 0.8rem;
display: flex;
flex-direction: column;
gap: 0.2rem;
gap: 0.1rem;
}
&__linkButton {
background: var(--semi-color-primary);
color: white;
font-size: 14px;
line-height: 20px;
font-weight: 600;
height: 32px;
width: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
a {
color: white;
display: flex;
align-items: center;
justify-content: center;
text-decoration: none;
width: 100%;
height: 100%;
}
&:hover {
background: var(--semi-color-primary-hover);
cursor: pointer;
}
}
&__deleteButton {
background: var(--semi-color-danger);
color: white;
border: none;
font-size: 14px;
height: 32px;
width: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
cursor: pointer;
padding: 0;
&:hover {
background: var(--semi-color-danger-hover);
}
svg {
fill: currentColor;
}
}
}

View File

@@ -6,7 +6,7 @@
import React, { 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';
import { Banner, Button, Checkbox, Space } from '@douyinfe/semi-ui-19';
import NotificationAdapterMutator from '../../jobs/mutation/components/notificationAdapter/NotificationAdapterMutator.jsx';
import Headline from '../../../components/headline/Headline.jsx';

View File

@@ -0,0 +1,130 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
/**
* Calculates the great-circle distance between two points on a sphere using the Haversine formula.
*
* I'm using the Haversine formula here because it accounts for the Earth's curvature.
* By calculating the central angle (c) between two points and multiplying it by the Earth's radius (R ≈ 6371km),
* we get a pretty accurate straight-line distance. It's basically some trigonometry involving
* sines and cosines of the latitudes and longitudes to find the chord length (a) first.
*
* @param {number} lat1 - Latitude of the first point
* @param {number} lon1 - Longitude of the first point
* @param {number} lat2 - Latitude of the second point
* @param {number} lon2 - Longitude of the second point
* @returns {number} Distance in meters, rounded to one decimal place
*/
export const distanceMeters = (lat1, lon1, lat2, lon2) => {
const R = 6371000;
const toRad = (deg) => (deg * Math.PI) / 180;
const phi1 = toRad(lat1);
const phi2 = toRad(lat2);
const dPhi = toRad(lat2 - lat1);
const dLambda = toRad(lon2 - lon1);
const a =
Math.sin(dPhi / 2) * Math.sin(dPhi / 2) +
Math.cos(phi1) * Math.cos(phi2) * Math.sin(dLambda / 2) * Math.sin(dLambda / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return Math.round(R * c * 10) / 10;
};
/**
* Generates an array of coordinates representing a circle on a map.
*
* To get this circle right, I'm approximating it with a polygon of 64 points.
* Since the Earth isn't flat, I have to adjust the longitude distance based on the latitude
* using the cosine of the latitude. The formula for the points is basically:
* x = center_lon + radius_lon * cos(theta)
* y = center_lat + radius_lat * sin(theta)
* where theta ranges from 0 to 2π. This handles the slight "squishing" of distances as you move away from the equator.
*
* @param {number[]} center - [longitude, latitude] of the center
* @param {number} radiusInKm - Radius of the circle in kilometers
* @param {number} [points=64] - Number of points to generate for the polygon
* @returns {number[][]} Array of [longitude, latitude] coordinates
*/
export const generateCircleCoords = (center, radiusInKm, points = 64) => {
const [longitude, latitude] = center;
const coords = [];
// 1 degree of latitude is roughly 110.574 km
// 1 degree of longitude is roughly 111.32 km * cos(latitude)
const distanceX = radiusInKm / (111.32 * Math.cos((latitude * Math.PI) / 180));
const distanceY = radiusInKm / 110.574;
for (let i = 0; i < points; i++) {
const theta = (i / points) * (2 * Math.PI);
const x = distanceX * Math.cos(theta);
const y = distanceY * Math.sin(theta);
coords.push([longitude + x, latitude + y]);
}
// Close the polygon
coords.push(coords[0]);
return coords;
};
/**
* Calculates the bounding box for a given center and radius.
*
* I'm calculating the bounds by offsetting the center coordinates by the radius.
* Again, using the 110.574 km per degree latitude and the cosine-adjusted longitude
* to make sure the bounds actually contain the circle, even at our latitudes.
* I've added a bit of padding (15% by default) to make sure everything fits nicely on the screen.
*
* @param {number[]} center - [longitude, latitude] of the center
* @param {number} radiusInKm - Radius in kilometers
* @param {number} [padding=0.15] - Percentage of padding to add
* @returns {number[][]} Bounding box coordinates [[minLon, minLat], [maxLon, maxLat]]
*/
export const getBoundsFromCenter = (center, radiusInKm, padding = 0.15) => {
const [lng, lat] = center;
const kmInDegLat = 1 / 110.574;
const kmInDegLng = 1 / (111.32 * Math.cos((lat * Math.PI) / 180));
const offsetLng = radiusInKm * kmInDegLng * (1 + padding);
const offsetLat = radiusInKm * kmInDegLat * (1 + padding);
return [
[lng - offsetLng, lat - offsetLat],
[lng + offsetLng, lat + offsetLat],
];
};
/**
* Calculates the bounding box for a set of coordinates.
*
* @param {number[][]} coords - Array of [longitude, latitude] coordinates
* @param {number} [padding=0.1] - Padding to add to the bounds
* @returns {number[][]} Bounding box coordinates [[minLon, minLat], [maxLon, maxLat]]
*/
export const getBoundsFromCoords = (coords, padding = 0.1) => {
if (!coords || coords.length === 0) return null;
let minLng = Infinity;
let minLat = Infinity;
let maxLng = -Infinity;
let maxLat = -Infinity;
coords.forEach(([lng, lat]) => {
if (lng < minLng) minLng = lng;
if (lng > maxLng) maxLng = lng;
if (lat < minLat) minLat = lat;
if (lat > maxLat) maxLat = lat;
});
const lngDiff = maxLng - minLng;
const latDiff = maxLat - minLat;
return [
[minLng - lngDiff * padding, minLat - latDiff * padding],
[maxLng + lngDiff * padding, maxLat + latDiff * padding],
];
};

View File

@@ -10,7 +10,7 @@ import Logo from '../../components/logo/Logo';
import { xhrPost } from '../../services/xhr';
import { useNavigate } from 'react-router-dom';
import { useActions, useSelector } from '../../services/state/store';
import { Input, Button, Banner, Toast } from '@douyinfe/semi-ui';
import { Input, Button, Banner, Toast } from '@douyinfe/semi-ui-19';
import './login.less';
import { IconUser, IconLock } from '@douyinfe/semi-icons';

View File

@@ -4,7 +4,7 @@
*/
import React from 'react';
import { Modal } from '@douyinfe/semi-ui';
import { Modal } from '@douyinfe/semi-ui-19';
const UserRemovalModal = function UserRemovalModal({ onOk, onCancel }) {
return (
<Modal title="Removing user" visible={true} closable={false} onOk={onOk} onCancel={onCancel}>

View File

@@ -5,11 +5,11 @@
import React from 'react';
import { Toast } from '@douyinfe/semi-ui';
import { Toast } from '@douyinfe/semi-ui-19';
import UserTable from '../../components/table/UserTable';
import { useActions, useSelector } from '../../services/state/store';
import { IconPlus } from '@douyinfe/semi-icons';
import { Button } from '@douyinfe/semi-ui';
import { Button } from '@douyinfe/semi-ui-19';
import UserRemovalModal from './UserRemovalModal';
import { xhrDelete } from '../../services/xhr';
import { useNavigate } from 'react-router-dom';

View File

@@ -8,7 +8,7 @@ import React from 'react';
import { xhrGet, xhrPost } from '../../../services/xhr';
import { useNavigate, useParams } from 'react-router-dom';
import { useActions } from '../../../services/state/store';
import { Divider, Input, Switch, Button, Toast } from '@douyinfe/semi-ui';
import { Divider, Input, Switch, Button, Toast } from '@douyinfe/semi-ui-19';
import './UserMutator.less';
import { SegmentPart } from '../../../components/segment/SegmentPart';
import { IconPlusCircle } from '@douyinfe/semi-icons';

View File

@@ -0,0 +1,105 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* 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 { 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);
const [address, setAddress] = useState(homeAddress?.address || '');
const [coords, setCoords] = useState(homeAddress?.coords || null);
const [saving, setSaving] = useState(false);
const [dataSource, setDataSource] = useState([]);
useEffect(() => {
setAddress(homeAddress?.address || '');
setCoords(homeAddress?.coords || null);
}, [homeAddress]);
const handleSave = async () => {
setSaving(true);
try {
const response = await xhrPost('/api/user/settings/home-address', { home_address: address });
if (response.status === 200) {
setCoords(response.json.coords);
await actions.userSettings.getUserSettings();
Toast.success('Settings saved successfully');
} else {
Toast.error(response.json.error || 'Failed to save settings');
}
} catch (error) {
Toast.error(error.json?.error || 'Error while saving settings');
} finally {
setSaving(false);
}
};
const debouncedSearch = useMemo(
() =>
debounce((value) => {
xhrGet(`/api/user/settings/autocomplete?q=${encodeURIComponent(value)}`)
.then((response) => {
if (response.status === 200) {
setDataSource(response.json);
}
})
.catch(() => {
// Silently fail for autocomplete
});
}, 300),
[],
);
const searchAddress = (value) => {
if (!value) {
setDataSource([]);
return;
}
debouncedSearch(value);
};
return (
<div className="user-settings">
<Title heading={2}>User Specific Settings</Title>
<Divider />
<SegmentPart
name="Distance claculation"
Icon={IconHome}
helpText="The address you enter is used to calculate the distance between your chosen location and each listing. The distance is computed using an approximate mathematical method and is intended to give you a rough indication of commute time. If you update your address, we will recalculate the distance for all active listings."
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', maxWidth: '600px' }}>
<AutoComplete
data={dataSource}
value={address}
showClear
onChange={(v) => setAddress(v)}
onSearch={searchAddress}
placeholder="Enter your home address"
style={{ width: '100%' }}
/>
{coords && coords.lat === -1 && (
<Banner type="danger" description="Address found but could not be geocoded accurately." closeIcon={null} />
)}
</div>
</SegmentPart>
<Divider />
<div style={{ marginTop: '20px' }}>
<Button icon={<IconSave />} theme="solid" type="primary" onClick={handleSave} loading={saving}>
Save Settings
</Button>
</div>
</div>
);
};
export default UserSettings;

267
yarn.lock
View File

@@ -969,6 +969,44 @@
resolved "https://registry.npmjs.org/@douyinfe/semi-theme-default/-/semi-theme-default-2.90.13.tgz"
integrity sha512-8f4j7NVw7w1IFFTEpTe/RkK3TW4DZWrUngV6CK3XXSCQKSVVyG/cXI5xgK9jkFiuES5S+d+ZlvL2sTA4+x8HWw==
"@douyinfe/semi-ui-19@^2.90.13":
version "2.90.13"
resolved "https://registry.npmjs.org/@douyinfe/semi-ui-19/-/semi-ui-19-2.90.13.tgz"
integrity sha512-OdJOOKEiRBTpwSdGSdtkwzAsQxehrQtvsU37YgMR3jGXL38PGN+UhEjdVbx+p6HS3G9fMbL1n/2+LvnHg4vx8A==
dependencies:
"@dnd-kit/core" "^6.0.8"
"@dnd-kit/sortable" "^7.0.2"
"@dnd-kit/utilities" "^3.2.1"
"@douyinfe/semi-animation" "2.90.13"
"@douyinfe/semi-animation-react" "2.90.13"
"@douyinfe/semi-foundation" "2.90.13"
"@douyinfe/semi-icons" "2.90.13"
"@douyinfe/semi-illustrations" "2.90.13"
"@douyinfe/semi-theme-default" "2.90.13"
"@tiptap/core" "^3.10.7"
"@tiptap/extension-document" "^3.10.7"
"@tiptap/extension-hard-break" "^3.10.7"
"@tiptap/extension-mention" "^3.10.7"
"@tiptap/extension-paragraph" "^3.10.7"
"@tiptap/extension-text" "^3.10.7"
"@tiptap/extensions" "^3.10.7"
"@tiptap/pm" "^3.10.7"
"@tiptap/react" "^3.10.7"
async-validator "^3.5.0"
classnames "^2.2.6"
copy-text-to-clipboard "^2.1.1"
date-fns "^2.29.3"
date-fns-tz "^1.3.8"
fast-copy "^3.0.1 "
jsonc-parser "^3.3.1"
lodash "^4.17.21"
prop-types "^15.7.2"
prosemirror-state "^1.4.3"
react-resizable "^3.0.5"
react-window "^1.8.2"
scroll-into-view-if-needed "^2.2.24"
utility-types "^3.10.0"
"@douyinfe/semi-ui@2.90.13":
version "2.90.13"
resolved "https://registry.npmjs.org/@douyinfe/semi-ui/-/semi-ui-2.90.13.tgz"
@@ -1569,14 +1607,14 @@
"@sendgrid/helpers" "^8.0.0"
"@tiptap/core@^3.10.7":
version "3.15.3"
resolved "https://registry.npmjs.org/@tiptap/core/-/core-3.15.3.tgz"
integrity sha512-bmXydIHfm2rEtGju39FiQNfzkFx9CDvJe+xem1dgEZ2P6Dj7nQX9LnA1ZscW7TuzbBRkL5p3dwuBIi3f62A66A==
version "3.16.0"
resolved "https://registry.npmjs.org/@tiptap/core/-/core-3.16.0.tgz"
integrity sha512-XegRaNuoQ/guzBQU2xHxOwFXXrtoXW9tiyXDhssSqylvZmBVSlRIPNHA6ArkHBKm6ehLf6+6Y9fF3uky1yCXYQ==
"@tiptap/extension-bubble-menu@^3.11.0":
version "3.11.0"
resolved "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.11.0.tgz"
integrity sha512-P3j9lQ+EZ5Zg/isJzLpCPX7bp7WUBmz8GPs/HPlyMyN2su8LqXntITBZr8IP1JNBlB/wR83k/W0XqdC57mG7cA==
"@tiptap/extension-bubble-menu@^3.16.0":
version "3.16.0"
resolved "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.16.0.tgz"
integrity sha512-nFL7FMu1LjZ5ZGf4U3tw56JLj/SpLysZvHQ1EneGB+90TEI/WReOvTY9VwH1egGWwrl7/OvQuGKclbuLIsy+BA==
dependencies:
"@floating-ui/dom" "^1.0.0"
@@ -1585,10 +1623,10 @@
resolved "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.11.0.tgz"
integrity sha512-N2G3cwL2Dtur/CgD/byJmFx9T5no6fTO/U462VP3rthQYrRA1AB3TCYqtlwJkmyoxRTNd4qIg4imaPl8ej6Heg==
"@tiptap/extension-floating-menu@^3.11.0":
version "3.11.0"
resolved "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.11.0.tgz"
integrity sha512-nEHdWZHEJYX1II1oJQ4aeZ8O/Kss4BRbYFXQFGIvPelCfCYEATpUJh3aq3767ARSq40bOWyu+Dcd4SCW0We6Sw==
"@tiptap/extension-floating-menu@^3.16.0":
version "3.16.0"
resolved "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.16.0.tgz"
integrity sha512-cokYXL8EkW+CFIlke70GLL7iKetUtYEp87muMG9oflczyj0BjmGAbO7Mskm+bcQBhxZ0dIYILTqKn2bNBvCDFw==
"@tiptap/extension-hard-break@^3.10.7":
version "3.11.0"
@@ -1616,9 +1654,9 @@
integrity sha512-g43beA73ZMLezez1st9LEwYrRHZ0FLzlsSlOZKk7sdmtHLmuqWHf4oyb0XAHol1HZIdGv104rYaGNgmQXr1ecQ==
"@tiptap/pm@^3.10.7":
version "3.15.3"
resolved "https://registry.npmjs.org/@tiptap/pm/-/pm-3.15.3.tgz"
integrity sha512-Zm1BaU1TwFi3CQiisxjgnzzIus+q40bBKWLqXf6WEaus8Z6+vo1MT2pU52dBCMIRaW9XNDq3E5cmGtMc1AlveA==
version "3.16.0"
resolved "https://registry.npmjs.org/@tiptap/pm/-/pm-3.16.0.tgz"
integrity sha512-FMxZ6Tc5ONKa/EByDV8lswct6YW2lF/wn11zqXmrfBZhdG7UQPTijpSwb6TCqaO5GOHmixaIaDPj+zimUREHQA==
dependencies:
prosemirror-changeset "^2.3.0"
prosemirror-collab "^1.3.1"
@@ -1640,16 +1678,16 @@
prosemirror-view "^1.38.1"
"@tiptap/react@^3.10.7":
version "3.11.0"
resolved "https://registry.npmjs.org/@tiptap/react/-/react-3.11.0.tgz"
integrity sha512-SDGei/2DjwmhzsxIQNr6dkB6NxLgXZjQ6hF36NfDm4937r5NLrWrNk5tCsoDQiKZ0DHEzuJ6yZM5C7I7LZLB6w==
version "3.16.0"
resolved "https://registry.npmjs.org/@tiptap/react/-/react-3.16.0.tgz"
integrity sha512-r1R19Ma4zxGt8ImiNOqSArAnWO239KUI9tTVeelgTyekPj7643lO8GbtuXJfAeWGPduDIpcAgR/Dd4NKieetiA==
dependencies:
"@types/use-sync-external-store" "^0.0.6"
fast-deep-equal "^3.1.3"
fast-equals "^5.3.3"
use-sync-external-store "^1.4.0"
optionalDependencies:
"@tiptap/extension-bubble-menu" "^3.11.0"
"@tiptap/extension-floating-menu" "^3.11.0"
"@tiptap/extension-bubble-menu" "^3.16.0"
"@tiptap/extension-floating-menu" "^3.16.0"
"@tootallnate/quickjs-emscripten@^0.23.0":
version "0.23.0"
@@ -2118,10 +2156,10 @@ basic-ftp@^5.0.2:
resolved "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.0.5.tgz"
integrity sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==
better-sqlite3@^12.6.0:
version "12.6.0"
resolved "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.0.tgz"
integrity sha512-FXI191x+D6UPWSze5IzZjhz+i9MK9nsuHsmTX9bXVl52k06AfZ2xql0lrgIUuzsMsJ7Vgl5kIptvDgBLIV3ZSQ==
better-sqlite3@^12.6.2:
version "12.6.2"
resolved "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.2.tgz"
integrity sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==
dependencies:
bindings "^1.5.0"
prebuild-install "^7.1.1"
@@ -2330,21 +2368,21 @@ cheerio-select@^2.1.0:
domhandler "^5.0.3"
domutils "^3.0.1"
cheerio@^1.1.2:
version "1.1.2"
resolved "https://registry.npmjs.org/cheerio/-/cheerio-1.1.2.tgz"
integrity sha512-IkxPpb5rS/d1IiLbHMgfPuS0FgiWTtFIm/Nj+2woXDLTZ7fOT2eqzgYbdMlLweqlHbsZjxEChoVK+7iph7jyQg==
cheerio@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-1.2.0.tgz#f23b777c49021ead7475dcf3390d3535a7f896d6"
integrity sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==
dependencies:
cheerio-select "^2.1.0"
dom-serializer "^2.0.0"
domhandler "^5.0.3"
domutils "^3.2.2"
encoding-sniffer "^0.2.1"
htmlparser2 "^10.0.0"
htmlparser2 "^10.1.0"
parse5 "^7.3.0"
parse5-htmlparser2-tree-adapter "^7.1.0"
parse5-parser-stream "^7.1.2"
undici "^7.12.0"
undici "^7.19.0"
whatwg-mimetype "^4.0.0"
chokidar@^3.5.2:
@@ -2374,10 +2412,10 @@ chownr@^1.1.1:
resolved "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz"
integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==
chromium-bidi@12.0.1:
version "12.0.1"
resolved "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-12.0.1.tgz"
integrity sha512-fGg+6jr0xjQhzpy5N4ErZxQ4wF7KLEvhGZXD6EgvZKDhu7iOhZXnZhcDxPJDcwTcrD48NPzOCo84RP2lv3Z+Cg==
chromium-bidi@13.0.1:
version "13.0.1"
resolved "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-13.0.1.tgz"
integrity sha512-c+RLxH0Vg2x2syS9wPw378oJgiJNXtYXUvnVAldUlt5uaHekn0CCU7gPksNgHjrH1qFhmjVXQj4esvuthuC7OQ==
dependencies:
mitt "^3.0.1"
zod "^3.24.1"
@@ -2424,7 +2462,7 @@ clone-deep@^0.2.4:
clsx@^1.1.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.2.1.tgz#0ddc4a20a549b59c93a4116bb26f5294ca17dc12"
resolved "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz"
integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==
clsx@^2.1.1:
@@ -2502,9 +2540,9 @@ cookie-session@2.1.1:
safe-buffer "5.2.1"
cookie@^1.0.1:
version "1.0.2"
resolved "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz"
integrity sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==
version "1.1.1"
resolved "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz"
integrity sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==
cookies@0.9.1:
version "0.9.1"
@@ -2535,7 +2573,7 @@ core-js-compat@^3.43.0:
core-js@^3.22.4:
version "3.47.0"
resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.47.0.tgz#436ef07650e191afeb84c24481b298bd60eb4a17"
resolved "https://registry.npmjs.org/core-js/-/core-js-3.47.0.tgz"
integrity sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==
cosmiconfig@^9.0.0:
@@ -2734,10 +2772,10 @@ devlop@^1.0.0, devlop@^1.1.0:
dependencies:
dequal "^2.0.0"
devtools-protocol@0.0.1534754:
version "0.0.1534754"
resolved "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1534754.tgz"
integrity sha512-26T91cV5dbOYnXdJi5qQHoTtUoNEqwkHcAyu/IKtjIAxiEqPMrDiRkDOPWVsGfNZGmlQVHQbZRSjD8sxagWVsQ==
devtools-protocol@0.0.1551306:
version "0.0.1551306"
resolved "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1551306.tgz"
integrity sha512-CFx8QdSim8iIv+2ZcEOclBKTQY6BI1IEDa7Tm9YkwAXzEWFndTEzpTo5jAUhSnq24IC7xaDw0wvGcm96+Y3PEg==
diff@^7.0.0:
version "7.0.0"
@@ -2772,7 +2810,7 @@ domhandler@^5.0.2, domhandler@^5.0.3:
dependencies:
domelementtype "^2.3.0"
domutils@^3.0.1, domutils@^3.2.1, domutils@^3.2.2:
domutils@^3.0.1, domutils@^3.2.2:
version "3.2.2"
resolved "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz"
integrity sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==
@@ -2860,6 +2898,11 @@ entities@^6.0.0:
resolved "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz"
integrity sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==
entities@^7.0.1:
version "7.0.1"
resolved "https://registry.yarnpkg.com/entities/-/entities-7.0.1.tgz#26e8a88889db63417dcb9a1e79a3f1bc92b5976b"
integrity sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==
env-paths@^2.2.1:
version "2.2.1"
resolved "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz"
@@ -3342,6 +3385,11 @@ fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz"
integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
fast-equals@^5.3.3:
version "5.4.0"
resolved "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz"
integrity sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==
fast-fifo@^1.2.0, fast-fifo@^1.3.2:
version "1.3.2"
resolved "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz"
@@ -3810,15 +3858,15 @@ history@5.3.0:
dependencies:
"@babel/runtime" "^7.7.6"
htmlparser2@^10.0.0:
version "10.0.0"
resolved "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.0.0.tgz"
integrity sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==
htmlparser2@^10.1.0:
version "10.1.0"
resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-10.1.0.tgz#fe3f2e12c73b6e462d4e10395db9c1119e4d6ae4"
integrity sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==
dependencies:
domelementtype "^2.3.0"
domhandler "^5.0.3"
domutils "^3.2.1"
entities "^6.0.0"
domutils "^3.2.2"
entities "^7.0.1"
http-errors@^2.0.0:
version "2.0.0"
@@ -4459,10 +4507,10 @@ lodash.merge@^4.6.2:
resolved "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz"
integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==
lodash@4.17.21, lodash@^4.17.21:
version "4.17.21"
resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
lodash@4.17.23, lodash@^4.17.21:
version "4.17.23"
resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz"
integrity sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==
log-symbols@^4.1.0:
version "4.1.0"
@@ -4488,7 +4536,7 @@ longest-streak@^3.0.0:
resolved "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz"
integrity sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==
loose-envify@^1.1.0, loose-envify@^1.4.0:
loose-envify@^1.4.0:
version "1.4.0"
resolved "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz"
integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
@@ -5736,10 +5784,10 @@ prelude-ls@^1.2.1:
resolved "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz"
integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==
prettier@3.8.0:
version "3.8.0"
resolved "https://registry.npmjs.org/prettier/-/prettier-3.8.0.tgz"
integrity sha512-yEPsovQfpxYfgWNhCfECjG5AQaO+K3dp6XERmOepyPDVqcJm+bjyCVO3pmU+nAPe0N5dDvekfGezt/EIiRe1TA==
prettier@3.8.1:
version "3.8.1"
resolved "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz"
integrity sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==
prismjs@^1.29.0:
version "1.30.0"
@@ -5971,17 +6019,17 @@ punycode@^2.1.0:
resolved "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz"
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
puppeteer-core@24.35.0:
version "24.35.0"
resolved "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.35.0.tgz"
integrity sha512-vt1zc2ME0kHBn7ZDOqLvgvrYD5bqNv5y2ZNXzYnCv8DEtZGw/zKhljlrGuImxptZ4rq+QI9dFGrUIYqG4/IQzA==
puppeteer-core@24.36.0:
version "24.36.0"
resolved "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.36.0.tgz"
integrity sha512-P3Ou0MAFDCQ0dK1d9F9+8jTrg6JvXjUacgG0YkJQP4kbEnUOGokSDEMmMId5ZhXD5HwsHM202E9VwEpEjWfwxg==
dependencies:
"@puppeteer/browsers" "2.11.1"
chromium-bidi "12.0.1"
chromium-bidi "13.0.1"
debug "^4.4.3"
devtools-protocol "0.0.1534754"
devtools-protocol "0.0.1551306"
typed-query-selector "^2.12.0"
webdriver-bidi-protocol "0.3.10"
webdriver-bidi-protocol "0.4.0"
ws "^8.19.0"
puppeteer-extra-plugin-stealth@^2.11.2:
@@ -6031,16 +6079,16 @@ puppeteer-extra@^3.3.6:
debug "^4.1.1"
deepmerge "^4.2.2"
puppeteer@^24.35.0:
version "24.35.0"
resolved "https://registry.npmjs.org/puppeteer/-/puppeteer-24.35.0.tgz"
integrity sha512-sbjB5JnJ+3nwgSdRM/bqkFXqLxRz/vsz0GRIeTlCk+j+fGpqaF2dId9Qp25rXz9zfhqnN9s0krek1M/C2GDKtA==
puppeteer@^24.36.0:
version "24.36.0"
resolved "https://registry.npmjs.org/puppeteer/-/puppeteer-24.36.0.tgz"
integrity sha512-BD/VCyV/Uezvd6o7Fd1DmEJSxTzofAKplzDy6T9d4WbLTQ5A+06zY7VwO91ZlNU22vYE8sidVEsTpTrKc+EEnQ==
dependencies:
"@puppeteer/browsers" "2.11.1"
chromium-bidi "12.0.1"
chromium-bidi "13.0.1"
cosmiconfig "^9.0.0"
devtools-protocol "0.0.1534754"
puppeteer-core "24.35.0"
devtools-protocol "0.0.1551306"
puppeteer-core "24.36.0"
typed-query-selector "^2.12.0"
qs@^6.14.1:
@@ -6101,13 +6149,12 @@ react-chartjs-2@^5.3.1:
resolved "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.1.tgz"
integrity sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A==
react-dom@18.3.1:
version "18.3.1"
resolved "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz"
integrity sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==
react-dom@19.2.3:
version "19.2.3"
resolved "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz"
integrity sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==
dependencies:
loose-envify "^1.1.0"
scheduler "^0.23.2"
scheduler "^0.27.0"
react-draggable@^4.0.3:
version "4.5.0"
@@ -6124,7 +6171,7 @@ react-is@^16.13.1:
react-range-slider-input@^3.3.2:
version "3.3.2"
resolved "https://registry.yarnpkg.com/react-range-slider-input/-/react-range-slider-input-3.3.2.tgz#1fe03d7489fe2c664c92b0cf970b19b45257ec82"
resolved "https://registry.npmjs.org/react-range-slider-input/-/react-range-slider-input-3.3.2.tgz"
integrity sha512-CGyD/6Vlc7qakSW+92WAKrp333Xo9W+udW62xvf6dSwqEj7LFSY75udcbNRtCQhuXW1O7o71yC4AC/CC0etqSg==
dependencies:
clsx "^1.1.1"
@@ -6143,17 +6190,17 @@ react-resizable@^3.0.5:
prop-types "15.x"
react-draggable "^4.0.3"
react-router-dom@7.12.0:
version "7.12.0"
resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.12.0.tgz"
integrity sha512-pfO9fiBcpEfX4Tx+iTYKDtPbrSLLCbwJ5EqP+SPYQu1VYCXdy79GSj0wttR0U4cikVdlImZuEZ/9ZNCgoaxwBA==
react-router-dom@7.13.0:
version "7.13.0"
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.13.0.tgz#8b5f7204fadca680f0e94f207c163f0dcd1cfdf5"
integrity sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g==
dependencies:
react-router "7.12.0"
react-router "7.13.0"
react-router@7.12.0:
version "7.12.0"
resolved "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz"
integrity sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==
react-router@7.13.0:
version "7.13.0"
resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.13.0.tgz#de9484aee764f4f65b93275836ff5944d7f5bd3b"
integrity sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==
dependencies:
cookie "^1.0.1"
set-cookie-parser "^2.6.0"
@@ -6166,12 +6213,10 @@ react-window@^1.8.2:
"@babel/runtime" "^7.0.0"
memoize-one ">=3.1.1 <6"
react@18.3.1:
version "18.3.1"
resolved "https://registry.npmjs.org/react/-/react-18.3.1.tgz"
integrity sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==
dependencies:
loose-envify "^1.1.0"
react@19.2.3:
version "19.2.3"
resolved "https://registry.npmjs.org/react/-/react-19.2.3.tgz"
integrity sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==
readable-stream@^3.1.1, readable-stream@^3.4.0:
version "3.6.2"
@@ -6511,12 +6556,10 @@ sax@^1.2.4:
resolved "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz"
integrity sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==
scheduler@^0.23.2:
version "0.23.2"
resolved "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz"
integrity sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==
dependencies:
loose-envify "^1.1.0"
scheduler@^0.27.0:
version "0.27.0"
resolved "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz"
integrity sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==
scroll-into-view-if-needed@^2.2.24:
version "2.2.31"
@@ -6580,9 +6623,9 @@ serve-static@2.2.1:
send "^1.2.0"
set-cookie-parser@^2.6.0:
version "2.7.1"
resolved "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz"
integrity sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==
version "2.7.2"
resolved "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz"
integrity sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==
set-function-length@^1.2.2:
version "1.2.2"
@@ -7220,10 +7263,10 @@ undici-types@~7.10.0:
resolved "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz"
integrity sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==
undici@^7.12.0:
version "7.15.0"
resolved "https://registry.npmjs.org/undici/-/undici-7.15.0.tgz"
integrity sha512-7oZJCPvvMvTd0OlqWsIxTuItTpJBpU1tcbVl24FMn3xt3+VSunwUasmfPJRE57oNO1KsZ4PgA1xTdAX4hq8NyQ==
undici@^7.19.0:
version "7.19.1"
resolved "https://registry.yarnpkg.com/undici/-/undici-7.19.1.tgz#b8a35742564a27601efa893a8800ce60d5502019"
integrity sha512-Gpq0iNm5M6cQWlyHQv9MV+uOj1jWk7LpkoE5vSp/7zjb4zMdAcUD+VL5y0nH4p9EbUklq00eVIIX/XcDHzu5xg==
unicode-canonical-property-names-ecmascript@^2.0.0:
version "2.0.1"
@@ -7391,10 +7434,10 @@ web-streams-polyfill@^3.0.3:
resolved "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz"
integrity sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==
webdriver-bidi-protocol@0.3.10:
version "0.3.10"
resolved "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.3.10.tgz"
integrity sha512-5LAE43jAVLOhB/QqX4bwSiv0Hg1HBfMmOuwBSXHdvg4GMGu9Y0lIq7p4R/yySu6w74WmaR4GM4H9t2IwLW7hgw==
webdriver-bidi-protocol@0.4.0:
version "0.4.0"
resolved "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.0.tgz"
integrity sha512-U9VIlNRrq94d1xxR9JrCEAx5Gv/2W7ERSv8oWRoNe/QYbfccS0V3h/H6qeNeCRJxXGMhhnkqvwNrvPAYeuP9VA==
whatwg-encoding@^3.1.1:
version "3.1.1"