mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
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
This commit is contained in:
committed by
GitHub
parent
51b4e51f3f
commit
4dd0370ec1
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
@@ -36,6 +36,7 @@ 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 +45,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);
|
||||
|
||||
@@ -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 };
|
||||
66
lib/api/routes/userSettingsRoute.js
Normal file
66
lib/api/routes/userSettingsRoute.js
Normal file
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* 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';
|
||||
|
||||
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.status(500).send({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
userSettingsRouter.post('/', 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.status(400).send({ error: 'Could not geocode address' });
|
||||
}
|
||||
} else {
|
||||
// If address is empty, maybe clear it?
|
||||
upsertSettings({ home_address: null }, userId);
|
||||
res.send({ success: true });
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).send({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
export { userSettingsRouter };
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
26
lib/services/geocoding/autocompleteService.js
Normal file
26
lib/services/geocoding/autocompleteService.js
Normal 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 [];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
61
lib/services/geocoding/distanceService.js
Normal file
61
lib/services/geocoding/distanceService.js
Normal 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);
|
||||
}
|
||||
}
|
||||
35
lib/services/listings/distanceCalculator.js
Normal file
35
lib/services/listings/distanceCalculator.js
Normal 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;
|
||||
}
|
||||
@@ -502,3 +502,57 @@ 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 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.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 },
|
||||
);
|
||||
};
|
||||
|
||||
12
lib/services/storage/migrations/sql/8.distances.js
Normal file
12
lib/services/storage/migrations/sql/8.distances.js
Normal 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;
|
||||
`);
|
||||
}
|
||||
@@ -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'));
|
||||
`);
|
||||
}
|
||||
@@ -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>}
|
||||
@@ -83,10 +96,12 @@ export function upsertSettings(settingsMapOrEntry, userId = null) {
|
||||
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`,
|
||||
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
|
||||
refreshSettingsCache();
|
||||
// keep cache in sync (only for global settings)
|
||||
if (userId == null) {
|
||||
refreshSettingsCache();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
262
package-lock.json
generated
262
package-lock.json
generated
@@ -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": {
|
||||
|
||||
13
package.json
13
package.json
@@ -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",
|
||||
"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,13 +79,13 @@
|
||||
"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",
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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';
|
||||
@@ -123,6 +124,14 @@ export default function FredyApp() {
|
||||
</PermissionAwareRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/userSettings"
|
||||
element={
|
||||
<PermissionAwareRoute currentUser={currentUser} adminOnly={false}>
|
||||
<UserSettings />
|
||||
</PermissionAwareRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/generalSettings"
|
||||
element={
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
@@ -107,12 +108,14 @@ const 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 && (
|
||||
@@ -272,6 +275,15 @@ 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' }}>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Card } from '@douyinfe/semi-ui';
|
||||
import { Card } from '@douyinfe/semi-ui-19';
|
||||
|
||||
import './SegmentParts.less';
|
||||
|
||||
|
||||
@@ -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 } = {}) {
|
||||
|
||||
@@ -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 } = {}) {
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -7,7 +7,7 @@ import React, { useEffect, useRef, useState } from 'react';
|
||||
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 { Select, Space, Typography, Button, Popover, Divider, Switch, Banner } from '@douyinfe/semi-ui-19';
|
||||
import { IconFilter } from '@douyinfe/semi-icons';
|
||||
import no_image from '../../assets/no_image.jpg';
|
||||
import RangeSlider from 'react-range-slider-input';
|
||||
@@ -281,12 +281,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>
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
119
ui/src/views/userSettings/UserSettings.jsx
Normal file
119
ui/src/views/userSettings/UserSettings.jsx
Normal file
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* 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 { xhrGet, xhrPost } from '../../services/xhr';
|
||||
import { SegmentPart } from '../../components/segment/SegmentPart';
|
||||
import debounce from 'lodash/debounce';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
const UserSettings = () => {
|
||||
const [address, setAddress] = useState('');
|
||||
const [coords, setCoords] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [dataSource, setDataSource] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUserSettings();
|
||||
}, []);
|
||||
|
||||
const fetchUserSettings = async () => {
|
||||
try {
|
||||
const response = await xhrGet('/api/user/settings');
|
||||
if (response.status === 200) {
|
||||
const homeAddress = response.json.home_address;
|
||||
setAddress(homeAddress?.address || '');
|
||||
setCoords(homeAddress?.coords || null);
|
||||
}
|
||||
} catch {
|
||||
Toast.error('Failed to fetch user settings');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const response = await xhrPost('/api/user/settings', { home_address: address });
|
||||
if (response.status === 200) {
|
||||
setCoords(response.json.coords);
|
||||
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);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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}
|
||||
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;
|
||||
Reference in New Issue
Block a user