mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
797421f0d5 | ||
|
|
0b2b42fc75 | ||
|
|
472169693f | ||
|
|
3117044139 | ||
|
|
7879d0e94a | ||
|
|
afd1048c9e | ||
|
|
acbaab05ed | ||
|
|
72fffc526b | ||
|
|
9e5989ece3 | ||
|
|
afc200c9e1 | ||
|
|
59226491f2 | ||
|
|
28f7760120 | ||
|
|
2465514b7a | ||
|
|
9dde377fe6 | ||
|
|
28a3a7f372 | ||
|
|
e859250545 | ||
|
|
4dd0370ec1 |
Binary file not shown.
|
Before Width: | Height: | Size: 4.7 MiB After Width: | Height: | Size: 4.8 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 402 KiB After Width: | Height: | Size: 531 KiB |
@@ -7,7 +7,7 @@
|
||||
content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1"
|
||||
/>
|
||||
<meta name="google" content="notranslate" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
|
||||
<title>Fredy || Real Estate Finder</title>
|
||||
|
||||
2
index.js
2
index.js
@@ -8,7 +8,6 @@ import { checkIfConfigIsAccessible, getProviders, refreshConfig } from './lib/ut
|
||||
import * as similarityCache from './lib/services/similarity-check/similarityCache.js';
|
||||
import { runMigrations } from './lib/services/storage/migrations/migrate.js';
|
||||
import { ensureDemoUserExists, ensureAdminUserExists } from './lib/services/storage/userStorage.js';
|
||||
import { cleanupDemoAtMidnight } from './lib/services/crons/demoCleanup-cron.js';
|
||||
import { initTrackerCron } from './lib/services/crons/tracker-cron.js';
|
||||
import logger from './lib/services/logger.js';
|
||||
import { initActiveCheckerCron } from './lib/services/crons/listing-alive-cron.js';
|
||||
@@ -54,7 +53,6 @@ await import('./lib/api/api.js');
|
||||
|
||||
if (settings.demoMode) {
|
||||
logger.info('Running in demo mode');
|
||||
cleanupDemoAtMidnight();
|
||||
}
|
||||
|
||||
ensureAdminUserExists();
|
||||
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
@@ -11,10 +11,13 @@ import logger from '../../services/logger.js';
|
||||
import { bus } from '../../services/events/event-bus.js';
|
||||
import { isRunning as isJobRunning } from '../../services/jobs/run-state.js';
|
||||
import { addClient as addSseClient, removeClient } from '../../services/sse/sse-broker.js';
|
||||
import { getSettings } from '../../services/storage/settingsStorage.js';
|
||||
|
||||
const service = restana();
|
||||
const jobRouter = service.newRouter();
|
||||
|
||||
const DEMO_JOB_NAME = 'Demo-Job';
|
||||
|
||||
function doesJobBelongsToUser(job, req) {
|
||||
const userId = req.session.currentUser;
|
||||
if (userId == null) {
|
||||
@@ -161,6 +164,7 @@ jobRouter.post('/:jobId/run', async (req, res) => {
|
||||
|
||||
jobRouter.post('/', async (req, res) => {
|
||||
const { provider, notificationAdapter, name, blacklist = [], jobId, enabled, shareWithUsers = [] } = req.body;
|
||||
const settings = await getSettings();
|
||||
try {
|
||||
let jobFromDb = jobStorage.getJob(jobId);
|
||||
|
||||
@@ -169,6 +173,11 @@ jobRouter.post('/', async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (settings.demoMode && jobFromDb.name === DEMO_JOB_NAME) {
|
||||
res.send(new Error('Sorry, but you cannot change the Status of our Demo Job ;)'));
|
||||
return;
|
||||
}
|
||||
|
||||
jobStorage.upsertJob({
|
||||
userId: req.session.currentUser,
|
||||
jobId,
|
||||
@@ -188,8 +197,14 @@ jobRouter.post('/', async (req, res) => {
|
||||
|
||||
jobRouter.delete('', async (req, res) => {
|
||||
const { jobId } = req.body;
|
||||
const settings = await getSettings();
|
||||
try {
|
||||
const job = jobStorage.getJob(jobId);
|
||||
if (settings.demoMode && job.name === DEMO_JOB_NAME) {
|
||||
res.send(new Error('Sorry, but you cannot remove the Demo Job ;)'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!doesJobBelongsToUser(job, req)) {
|
||||
res.send(new Error('You are trying to remove a job that is not associated to your user'));
|
||||
} else {
|
||||
@@ -204,8 +219,15 @@ jobRouter.delete('', async (req, res) => {
|
||||
jobRouter.put('/:jobId/status', async (req, res) => {
|
||||
const { status } = req.body;
|
||||
const { jobId } = req.params;
|
||||
const settings = await getSettings();
|
||||
try {
|
||||
const job = jobStorage.getJob(jobId);
|
||||
|
||||
if (settings.demoMode && job.name === DEMO_JOB_NAME) {
|
||||
res.send(new Error('Sorry, but you cannot change the Status of our Demo Job ;)'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!doesJobBelongsToUser(job, req)) {
|
||||
res.send(new Error('You are trying change a job that is not associated to your user'));
|
||||
} else {
|
||||
|
||||
@@ -74,6 +74,18 @@ listingsRouter.get('/map', async (req, res) => {
|
||||
res.send();
|
||||
});
|
||||
|
||||
listingsRouter.get('/:listingId', async (req, res) => {
|
||||
const { listingId } = req.params;
|
||||
const listing = listingStorage.getListingById(listingId, req.session.currentUser, isAdminFn(req));
|
||||
if (!listing) {
|
||||
res.statusCode = 404;
|
||||
res.body = { message: 'Listing not found' };
|
||||
return res.send();
|
||||
}
|
||||
res.body = listing;
|
||||
res.send();
|
||||
});
|
||||
|
||||
// Toggle watch state for the current user on a listing
|
||||
listingsRouter.post('/watch', async (req, res) => {
|
||||
try {
|
||||
|
||||
79
lib/api/routes/userSettingsRoute.js
Normal file
79
lib/api/routes/userSettingsRoute.js
Normal file
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* 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 { getSettings, upsertSettings } from '../../services/storage/settingsStorage.js';
|
||||
import { resetGeocoordinatesAndDistanceForUser } from '../../services/storage/listingsStorage.js';
|
||||
import { geocodeAddress } from '../../services/geocoding/geoCodingService.js';
|
||||
import { autocompleteAddress } from '../../services/geocoding/autocompleteService.js';
|
||||
import { fromJson } from '../../utils.js';
|
||||
import { trackFeature } from '../../services/tracking/Tracker.js';
|
||||
import { FEATURES } from '../../features.js';
|
||||
import logger from '../../services/logger.js';
|
||||
import { runGeoCordTask } from '../../services/crons/geocoding-cron.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;
|
||||
const settings = await getSettings();
|
||||
|
||||
if (settings.demoMode) {
|
||||
res.send(new Error('In demo mode, it is not allowed to change the home address.'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (home_address) {
|
||||
await trackFeature(FEATURES.DISTANCE_ADDRESS_ENTERED);
|
||||
const coords = await geocodeAddress(home_address);
|
||||
if (coords && coords.lat !== -1) {
|
||||
upsertSettings({ home_address: { address: home_address, coords } }, userId);
|
||||
resetGeocoordinatesAndDistanceForUser(userId);
|
||||
//we do NOT wait for this to finish, as we don't want to block the response
|
||||
runGeoCordTask();
|
||||
res.send({ success: true, coords });
|
||||
} else {
|
||||
res.statusCode = 400;
|
||||
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 };
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*
|
||||
* The mobile API provides the following endpoints:
|
||||
* - GET /search/total?{search parameters}: Returns the total number of listings for the given query
|
||||
* Example: `curl -H "User-Agent: ImmoScout_27.3_26.0_._" https://api.mobile.immobilienscout24.de/search/total?searchType=region&realestatetype=apartmentrent&pricetype=calculatedtotalrent&geocodes=%2Fde%2Fberlin%2Fberlin `
|
||||
* Example: `curl -H "User-Agent: ImmoScout_27.12_26.2_._" https://api.mobile.immobilienscout24.de/search/total?searchType=region&realestatetype=apartmentrent&pricetype=calculatedtotalrent&geocodes=%2Fde%2Fberlin%2Fberlin `
|
||||
*
|
||||
* - POST /search/list?{search parameters}: Actually retrieves the listings. Body is json encoded and contains
|
||||
* data specifying additional results (advertisements) to return. The format is as follows:
|
||||
@@ -20,12 +20,12 @@
|
||||
* ```
|
||||
* It is not necessary to provide data for the specified keys.
|
||||
*
|
||||
* Example: `curl -X POST 'https://api.mobile.immobilienscout24.de/search/list?pricetype=calculatedtotalrent&realestatetype=apartmentrent&searchType=region&geocodes=%2Fde%2Fberlin%2Fberlin&pagenumber=1' -H "Connection: keep-alive" -H "User-Agent: ImmoScout_27.3_26.0_._" -H "Accept: application/json" -H "Content-Type: application/json" -d '{"supportedResultListType": [], "userData": {}}'`
|
||||
* Example: `curl -X POST 'https://api.mobile.immobilienscout24.de/search/list?pricetype=calculatedtotalrent&realestatetype=apartmentrent&searchType=region&geocodes=%2Fde%2Fberlin%2Fberlin&pagenumber=1' -H "Connection: keep-alive" -H "User-Agent: ImmoScout_27.12_26.2_._" -H "Accept: application/json" -H "Content-Type: application/json" -d '{"supportedResultListType": [], "userData": {}}'`
|
||||
|
||||
* - GET /expose/{id} - Returns the details of a listing. The response contains additional details not included in the
|
||||
* listing response.
|
||||
*
|
||||
* Example: `curl -H "User-Agent: ImmoScout_27.3_26.0_._" "https://api.mobile.immobilienscout24.de/expose/158382494"`
|
||||
* Example: `curl -H "User-Agent: ImmoScout_27.12_26.2_._" "https://api.mobile.immobilienscout24.de/expose/158382494"`
|
||||
*
|
||||
*
|
||||
* It is necessary to set the correct User Agent (see `getListings`) in the request header.
|
||||
@@ -52,7 +52,7 @@ async function getListings(url) {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'User-Agent': 'ImmoScout_27.3_26.0_._',
|
||||
'User-Agent': 'ImmoScout_27.12_26.2_._',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
@@ -88,7 +88,7 @@ async function getListings(url) {
|
||||
async function isListingActive(link) {
|
||||
const result = await fetch(convertImmoscoutListingToMobileListing(link), {
|
||||
headers: {
|
||||
'User-Agent': 'ImmoScout_27.3_26.0_._',
|
||||
'User-Agent': 'ImmoScout_27.12_26.2_._',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ const config = {
|
||||
},
|
||||
normalize: normalize,
|
||||
filter: applyBlacklist,
|
||||
activeTester: checkIfListingIsActive,
|
||||
activeTester: (url) => checkIfListingIsActive(url, 'Angebot nicht gefunden'),
|
||||
};
|
||||
export const init = (sourceConfig, blacklist) => {
|
||||
config.enabled = sourceConfig.enabled;
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { removeJobsByUserId } from '../storage/jobStorage.js';
|
||||
import { getUsers } from '../storage/userStorage.js';
|
||||
import logger from '../logger.js';
|
||||
import cron from 'node-cron';
|
||||
import { getSettings } from '../storage/settingsStorage.js';
|
||||
|
||||
/**
|
||||
* if we are running in demo environment, we have to cleanup the db files (specifically the jobs table)
|
||||
*/
|
||||
export function cleanupDemoAtMidnight() {
|
||||
cron.schedule('0 0 * * *', cleanup);
|
||||
}
|
||||
|
||||
async function cleanup() {
|
||||
const settings = await getSettings();
|
||||
if (settings.demoMode) {
|
||||
const demoUser = getUsers(false).find((user) => user.username === 'demo');
|
||||
if (demoUser == null) {
|
||||
logger.error('Demo user not found, cannot remove Jobs');
|
||||
return Promise.resolve();
|
||||
}
|
||||
removeJobsByUserId(demoUser.id);
|
||||
}
|
||||
}
|
||||
@@ -6,28 +6,34 @@
|
||||
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() {
|
||||
export async function runGeoCordTask() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
export async function initGeocodingCron() {
|
||||
// run directly on start
|
||||
await runTask();
|
||||
await runGeoCordTask();
|
||||
// then every 6 hours
|
||||
cron.schedule('0 */6 * * *', runTask);
|
||||
cron.schedule('0 */6 * * *', runGeoCordTask);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -103,13 +103,17 @@ const REAL_ESTATE_TYPE = {
|
||||
'haus-mieten': 'houserent',
|
||||
'wohnung-mieten': 'apartmentrent',
|
||||
'wohnung-kaufen': 'apartmentbuy',
|
||||
'wohnung-kaufen-mit-balkon': 'apartmentbuy',
|
||||
'eigentumswohnung-mit-garten': 'apartmentbuy',
|
||||
'haus-kaufen': 'housebuy',
|
||||
};
|
||||
|
||||
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'] },
|
||||
@@ -144,7 +148,7 @@ export function convertWebToMobile(webUrl) {
|
||||
|
||||
const realTypeKey = segments.at(-1);
|
||||
let realType = REAL_ESTATE_TYPE[realTypeKey];
|
||||
let additionalParamsFromWebPath;
|
||||
let additionalParamsFromWebPath = WEB_PATH_TO_APARTMENT_EQUIPMENT_MAP[realTypeKey] || null;
|
||||
|
||||
if (!realType) {
|
||||
// Test for seo optimized apartment path (only used on the ImmoScout web app)
|
||||
@@ -165,7 +169,7 @@ export function convertWebToMobile(webUrl) {
|
||||
Object.entries(rawParams).filter(([key]) => key !== 'enteredFrom' && PARAM_NAME_MAP[key]),
|
||||
);
|
||||
|
||||
const geocodes = `/${segments.slice(2, 5).join('/')}`;
|
||||
const geocodes = `/${segments.slice(2, segments.length - 1).join('/')}`;
|
||||
const isRadius = segments.includes('radius');
|
||||
const mobileParams = {
|
||||
searchType: isRadius ? 'radius' : 'region',
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -8,38 +8,71 @@ import { randomBetween, sleep } from '../../utils.js';
|
||||
|
||||
const maxAttempts = 3;
|
||||
|
||||
const userAgents = [
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
||||
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Safari/605.1.15',
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1',
|
||||
];
|
||||
|
||||
/**
|
||||
* Check if a listing is still active with up to 3 attempts and exponential backoff.
|
||||
* Backoff waits are capped and the last wait is at most 2000 ms.
|
||||
* Check if a listing is still active with up to 5 attempts and exponential backoff.
|
||||
* Backoff waits are randomized and capped.
|
||||
*
|
||||
* Rules:
|
||||
* - HTTP 200 => return 1
|
||||
* - HTTP 200 => return 1 (if checkForText is provided and found, returns 0)
|
||||
* - HTTP 401/403 => return -1 (most certainly detected as a bot)
|
||||
* - HTTP 404 => return 0
|
||||
* - Other statuses or network errors => retry until attempts are exhausted
|
||||
*
|
||||
* @returns {Promise<Integer>} 1 if active, o if not active and -1 if detected as bot
|
||||
* @returns {Promise<Integer>} 1 if active, 0 if not active and -1 if detected as bot
|
||||
*/
|
||||
export default async function checkIfListingIsActive(link) {
|
||||
export default async function checkIfListingIsActive(link, checkForText = null) {
|
||||
await sleep(randomBetween(50, 100));
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
const userAgent = userAgents[Math.floor(Math.random() * userAgents.length)];
|
||||
const res = await fetch(link, {
|
||||
redirect: 'manual',
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36',
|
||||
'Accept-Language': 'de-DE,de;q=0.9,en;q=0.8',
|
||||
'User-Agent': userAgent,
|
||||
Accept:
|
||||
'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
|
||||
'Accept-Language': 'de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7',
|
||||
'Accept-Encoding': 'gzip, deflate, br',
|
||||
'Cache-Control': 'max-age=0',
|
||||
'Sec-Ch-Ua': '"Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"',
|
||||
'Sec-Ch-Ua-Mobile': '?0',
|
||||
'Sec-Ch-Ua-Platform': '"macOS"',
|
||||
'Sec-Fetch-Dest': 'document',
|
||||
'Sec-Fetch-Mode': 'navigate',
|
||||
'Sec-Fetch-Site': 'none',
|
||||
'Sec-Fetch-User': '?1',
|
||||
'Upgrade-Insecure-Requests': '1',
|
||||
Referer: 'https://www.google.com/',
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status === 200) {
|
||||
if (checkForText) {
|
||||
const htmText = await res.text();
|
||||
if (htmText.includes(checkForText)) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
if (res.status === 401) return -1;
|
||||
if (res.status === 403) return -1;
|
||||
if (res.status === 404) return 0;
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
if (attempt < maxAttempts) {
|
||||
await sleep(backoffDelay(attempt));
|
||||
continue;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
if (res.status === 404 || res.status === 410) return 0;
|
||||
|
||||
// For any other status, only retry if attempts remain
|
||||
if (attempt < maxAttempts) {
|
||||
@@ -62,13 +95,13 @@ export default async function checkIfListingIsActive(link) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Exponential backoff delay with cap.
|
||||
* attempt: 1 -> 500ms, 2 -> 1000ms, 3 -> 2000ms (cap)
|
||||
* Exponential backoff delay with cap and jitter.
|
||||
* @param {number} attempt 1-based attempt index
|
||||
* @returns {number} delay in ms
|
||||
*/
|
||||
function backoffDelay(attempt) {
|
||||
const base = 500;
|
||||
const cap = 2000;
|
||||
return Math.min(base * 2 ** (attempt - 1), cap);
|
||||
const delay = Math.min(base * 2 ** (attempt - 1), cap);
|
||||
return delay + randomBetween(0, 1000);
|
||||
}
|
||||
|
||||
@@ -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,105 @@ 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 },
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Return a single listing by id.
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {string} userId
|
||||
* @param {boolean} isAdmin
|
||||
* @returns {Object|null}
|
||||
*/
|
||||
export const getListingById = (id, userId = null, isAdmin = false) => {
|
||||
const params = { id, userId: userId || '__NO_USER__' };
|
||||
let whereScoping = '';
|
||||
if (!isAdmin) {
|
||||
whereScoping = `AND (j.user_id = @userId OR EXISTS (SELECT 1 FROM json_each(j.shared_with_user) AS sw WHERE sw.value = @userId))`;
|
||||
}
|
||||
return (
|
||||
SqliteConnection.query(
|
||||
`SELECT l.*, j.name AS job_name, CASE WHEN wl.id IS NOT NULL THEN 1 ELSE 0 END AS isWatched
|
||||
FROM listings l
|
||||
LEFT JOIN jobs j ON j.id = l.job_id
|
||||
LEFT JOIN watch_list wl ON wl.listing_id = l.id AND wl.user_id = @userId
|
||||
WHERE l.id = @id AND l.manually_deleted = 0 ${whereScoping}`,
|
||||
params,
|
||||
)[0] || null
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Resets geocoordinates and distance for all listings related to a user.
|
||||
*
|
||||
* @param {string} userId
|
||||
* @returns {void}
|
||||
*/
|
||||
export const resetGeocoordinatesAndDistanceForUser = (userId) => {
|
||||
SqliteConnection.execute(
|
||||
`UPDATE listings
|
||||
SET latitude = NULL,
|
||||
longitude = NULL,
|
||||
distance_to_destination = NULL
|
||||
WHERE job_id IN (
|
||||
SELECT id FROM jobs j
|
||||
WHERE j.user_id = @userId
|
||||
)`,
|
||||
{ userId },
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
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>}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import * as hasher from '../security/hash.js';
|
||||
import { nanoid } from 'nanoid';
|
||||
import SqliteConnection from './SqliteConnection.js';
|
||||
import { getSettings } from './settingsStorage.js';
|
||||
import { inDevMode } from '../../utils.js';
|
||||
|
||||
/**
|
||||
* Get all users.
|
||||
@@ -137,8 +138,12 @@ export const removeUser = (userId) => {
|
||||
export const ensureDemoUserExists = async () => {
|
||||
const settings = await getSettings();
|
||||
if (!settings.demoMode) {
|
||||
// Remove demo user (and cascade delete their jobs/listings)
|
||||
SqliteConnection.execute(`DELETE FROM users WHERE username = 'demo'`);
|
||||
if (!inDevMode()) {
|
||||
// Remove demo user (and cascade delete their jobs/listings)
|
||||
SqliteConnection.execute(`DELETE
|
||||
FROM users
|
||||
WHERE username = 'demo'`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Ensure demo user exists when demo mode is on
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
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": {
|
||||
|
||||
23
package.json
23
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "fredy",
|
||||
"version": "18.0.2",
|
||||
"version": "19.3.1",
|
||||
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
||||
"scripts": {
|
||||
"prepare": "husky",
|
||||
@@ -61,33 +61,34 @@
|
||||
"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",
|
||||
"maplibre-gl": "^5.16.0",
|
||||
"lodash": "4.17.23",
|
||||
"maplibre-gl": "^5.17.0",
|
||||
"nanoid": "5.1.6",
|
||||
"node-cron": "^4.2.1",
|
||||
"node-fetch": "3.3.2",
|
||||
"node-mailjet": "6.0.11",
|
||||
"p-throttle": "^8.1.0",
|
||||
"package-up": "^5.0.0",
|
||||
"puppeteer": "^24.35.0",
|
||||
"puppeteer": "^24.36.1",
|
||||
"puppeteer-extra": "^3.3.6",
|
||||
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
||||
"query-string": "9.3.1",
|
||||
"react": "18.3.1",
|
||||
"react": "19.2.4",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-dom": "19.2.4",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ Challenges:
|
||||
_Returns the total number of listings for the given query._
|
||||
|
||||
```
|
||||
curl -H "User-Agent: ImmoScout_27.3_26.0_._" \
|
||||
curl -H "User-Agent: ImmoScout_27.12_26.2_._" \
|
||||
-H "Accept: application/json" \
|
||||
"https://api.mobile.immobilienscout24.de/search/total?searchType=region&realestatetype=apartmentrent&pricetype=calculatedtotalrent&geocodes=%2Fde%2Fberlin%2Fberlin"
|
||||
```
|
||||
@@ -63,7 +63,7 @@ _The body is json encoded and contains data specifying additional results (adver
|
||||
```
|
||||
curl -X POST 'https://api.mobile.immobilienscout24.de/search/list?pricetype=calculatedtotalrent&realestatetype=apartmentrent&searchType=region&geocodes=%2Fde%2Fberlin%2Fberlin&pagenumber=1' \
|
||||
-H "Connection: keep-alive" \
|
||||
-H "User-Agent: ImmoScout_27.3_26.0_._" \
|
||||
-H "User-Agent: ImmoScout_27.12_26.2_._" \
|
||||
-H "Accept: application/json" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"supportedResultListType":[],"userData":{}}'
|
||||
@@ -78,7 +78,7 @@ curl -X POST 'https://api.mobile.immobilienscout24.de/search/list?pricetype=calc
|
||||
The response contains additional details not included in the listing response.
|
||||
|
||||
```
|
||||
curl -H "User-Agent: ImmoScout_27.3_26.0_._" \
|
||||
curl -H "User-Agent: ImmoScout_27.12_26.2_._" \
|
||||
-H "Accept: application/json" \
|
||||
"https://api.mobile.immobilienscout24.de/expose/158382494"
|
||||
```
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -58,7 +58,7 @@ describe('#immoscout-mobile URL conversion', () => {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'User-Agent': 'ImmoScout_27.3_26.0_._',
|
||||
'User-Agent': 'ImmoScout_27.12_26.2_._',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
@@ -75,7 +75,9 @@ describe('#immoscout-mobile URL conversion', () => {
|
||||
expect(responseBody.totalResults).to.be.greaterThan(0);
|
||||
expect(responseBody.totalResults).to.be.greaterThan(0);
|
||||
expect(responseBody.resultListItems.length).to.greaterThan(0);
|
||||
expect(responseBody.resultListItems[0].item.realEstateType).to.equal(type);
|
||||
expect(responseBody.resultListItems.filter((r) => r.type === 'EXPOSE_RESULT')[0].item.realEstateType).to.equal(
|
||||
type,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
117
ui/src/App.jsx
117
ui/src/App.jsx
@@ -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,15 +19,16 @@ 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 } 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';
|
||||
import ListingDetail from './views/listings/ListingDetail.jsx';
|
||||
|
||||
export default function FredyApp() {
|
||||
const actions = useActions();
|
||||
@@ -39,12 +41,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);
|
||||
@@ -58,7 +60,7 @@ export default function FredyApp() {
|
||||
};
|
||||
|
||||
const isAdmin = () => currentUser != null && currentUser.isAdmin;
|
||||
const { Footer, Sider, Content } = Layout;
|
||||
const { Sider, Content } = Layout;
|
||||
|
||||
return loading ? null : needsLogin() ? (
|
||||
<Routes>
|
||||
@@ -67,11 +69,11 @@ export default function FredyApp() {
|
||||
</Routes>
|
||||
) : (
|
||||
<Layout className="app">
|
||||
<Layout className="app">
|
||||
<Sider>
|
||||
<Navigation isAdmin={isAdmin()} />
|
||||
</Sider>
|
||||
<Content>
|
||||
<Sider>
|
||||
<Navigation isAdmin={isAdmin()} />
|
||||
</Sider>
|
||||
<Layout className="app__main">
|
||||
<Content className="app__content">
|
||||
{versionUpdate?.newVersion && <VersionBanner />}
|
||||
{settings.demoMode && (
|
||||
<>
|
||||
@@ -86,60 +88,57 @@ export default function FredyApp() {
|
||||
</>
|
||||
)}
|
||||
{settings.analyticsEnabled === null && !settings.demoMode && <TrackingModal />}
|
||||
<Divider />
|
||||
<div className="app__content">
|
||||
<Routes>
|
||||
<Route path="/403" element={<InsufficientPermission />} />
|
||||
<Route path="/jobs/new" element={<JobMutation />} />
|
||||
<Route path="/jobs/edit/:jobId" element={<JobMutation />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/jobs" element={<Jobs />} />
|
||||
<Route path="/listings" element={<Listings />} />
|
||||
<Route path="/map" element={<MapView />} />
|
||||
<Route path="/watchlistManagement" element={<WatchlistManagement />} />
|
||||
<Routes>
|
||||
<Route path="/403" element={<InsufficientPermission />} />
|
||||
<Route path="/jobs/new" element={<JobMutation />} />
|
||||
<Route path="/jobs/edit/:jobId" element={<JobMutation />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/jobs" element={<Jobs />} />
|
||||
<Route path="/listings" element={<Listings />} />
|
||||
<Route path="/listings/listing/:listingId" element={<ListingDetail />} />
|
||||
<Route path="/map" element={<MapView />} />
|
||||
<Route path="/watchlistManagement" element={<WatchlistManagement />} />
|
||||
|
||||
{/* Permission-aware routes */}
|
||||
<Route
|
||||
path="/users/new"
|
||||
element={
|
||||
<PermissionAwareRoute currentUser={currentUser}>
|
||||
<UserMutator />
|
||||
</PermissionAwareRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/users/edit/:userId"
|
||||
element={
|
||||
<PermissionAwareRoute currentUser={currentUser}>
|
||||
<UserMutator />
|
||||
</PermissionAwareRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/users"
|
||||
element={
|
||||
<PermissionAwareRoute currentUser={currentUser}>
|
||||
<Users />
|
||||
</PermissionAwareRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/generalSettings"
|
||||
element={
|
||||
<PermissionAwareRoute currentUser={currentUser}>
|
||||
<GeneralSettings />
|
||||
</PermissionAwareRoute>
|
||||
}
|
||||
/>
|
||||
{/* Permission-aware routes */}
|
||||
<Route
|
||||
path="/users/new"
|
||||
element={
|
||||
<PermissionAwareRoute currentUser={currentUser}>
|
||||
<UserMutator />
|
||||
</PermissionAwareRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/users/edit/:userId"
|
||||
element={
|
||||
<PermissionAwareRoute currentUser={currentUser}>
|
||||
<UserMutator />
|
||||
</PermissionAwareRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/users"
|
||||
element={
|
||||
<PermissionAwareRoute currentUser={currentUser}>
|
||||
<Users />
|
||||
</PermissionAwareRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/userSettings" element={<UserSettings />} />
|
||||
<Route
|
||||
path="/generalSettings"
|
||||
element={
|
||||
<PermissionAwareRoute currentUser={currentUser}>
|
||||
<GeneralSettings />
|
||||
</PermissionAwareRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
</div>
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
</Content>
|
||||
</Layout>
|
||||
<Footer>
|
||||
<FredyFooter />
|
||||
</Footer>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,45 +1,29 @@
|
||||
.app {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
|
||||
&__main {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__content {
|
||||
margin: 1rem;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
position: relative;
|
||||
padding: 24px;
|
||||
background-color: var(--semi-color-bg-0);
|
||||
box-sizing: border-box;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ui.inverted.segment {
|
||||
background: #31303078 !important;
|
||||
}
|
||||
|
||||
.ui.black.label,
|
||||
.ui.black.labels .label {
|
||||
background-color: #31303078 !important;
|
||||
}
|
||||
|
||||
a:link {
|
||||
color: #54a9ff;
|
||||
background-color: transparent;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:visited {
|
||||
color: #54a9ff;
|
||||
background-color: transparent;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #54a9ff;
|
||||
background-color: transparent;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
a:active {
|
||||
color: #54a9ff;
|
||||
background-color: transparent;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.semi-icon:not(.semi-tabs-bar .semi-tabs-tab .semi-icon) {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -1,92 +1,67 @@
|
||||
@import "DashboardCardColors.less";
|
||||
|
||||
.color-variant(@bg, @border, @text) {
|
||||
background-color: @bg;
|
||||
border: 1px solid @border;
|
||||
color: @text;
|
||||
}
|
||||
|
||||
.dashboard-card {
|
||||
box-sizing: border-box;
|
||||
padding: .8rem;
|
||||
border-radius: .5rem;
|
||||
border-width: 1px;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 6px 20px rgba(0,0,0,0.08);
|
||||
/* Make all KPI boxes the same size regardless of content/font */
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
height: 10rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 140px;
|
||||
margin-bottom: 16px;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
background-color: rgba(36, 36, 36, 0.9);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--semi-color-border);
|
||||
|
||||
&.blue {
|
||||
.color-variant(@color-blue-bg, @color-blue-border, @color-blue-text);
|
||||
}
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
background-color: rgba(36, 36, 36, 1);
|
||||
|
||||
&.orange {
|
||||
.color-variant(@color-orange-bg, @color-orange-border, @color-orange-text);
|
||||
}
|
||||
|
||||
&.green {
|
||||
.color-variant(@color-green-bg, @color-green-border, @color-green-text);
|
||||
}
|
||||
|
||||
&.purple {
|
||||
.color-variant(@color-purple-bg, @color-purple-border, @color-purple-text);
|
||||
}
|
||||
|
||||
&.gray {
|
||||
.color-variant(@color-gray-bg, @color-gray-border, @color-gray-text);
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .6rem;
|
||||
/* Keep header from growing content height */
|
||||
min-height: 2rem;
|
||||
overflow: hidden;
|
||||
&.blue {
|
||||
box-shadow: 0 8px 24px -5px var(--semi-color-primary);
|
||||
}
|
||||
&.orange {
|
||||
box-shadow: 0 8px 24px -5px var(--semi-color-warning);
|
||||
}
|
||||
&.green {
|
||||
box-shadow: 0 8px 24px -5px var(--semi-color-success);
|
||||
}
|
||||
&.purple {
|
||||
box-shadow: 0 8px 24px -5px var(--semi-color-info);
|
||||
}
|
||||
&.gray {
|
||||
box-shadow: 0 8px 24px -5px rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
&__icon {
|
||||
border-radius: .6rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&__content {
|
||||
margin-top: .4rem;
|
||||
font-size: .7rem;
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__value {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1.1;
|
||||
color: #fff;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
color: var(--semi-color-text-0);
|
||||
}
|
||||
|
||||
&__desc {
|
||||
opacity: .8;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
&.blue {
|
||||
box-shadow: 0 4px 20px -5px var(--semi-color-primary);
|
||||
}
|
||||
|
||||
&.orange {
|
||||
box-shadow: 0 4px 20px -5px var(--semi-color-warning);
|
||||
}
|
||||
|
||||
&.green {
|
||||
box-shadow: 0 4px 20px -5px var(--semi-color-success);
|
||||
}
|
||||
|
||||
&.purple {
|
||||
box-shadow: 0 4px 20px -5px var(--semi-color-info);
|
||||
}
|
||||
|
||||
&.gray {
|
||||
box-shadow: 0 4px 20px -5px rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,8 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
import React from 'react';
|
||||
|
||||
import { Card, Typography, Space } from '@douyinfe/semi-ui-19';
|
||||
import './DashboardCard.less';
|
||||
|
||||
export default function KpiCard({
|
||||
@@ -20,21 +16,28 @@ export default function KpiCard({
|
||||
color = 'gray',
|
||||
children,
|
||||
}) {
|
||||
const { Text } = Typography;
|
||||
return (
|
||||
<div className={`dashboard-card ${color}`}>
|
||||
<div className="dashboard-card__header">
|
||||
<div className="dashboard-card__icon">{icon}</div>
|
||||
<div className="dashboard-card__title">
|
||||
<span>{title}</span>
|
||||
<Card className={`dashboard-card ${color}`} bodyStyle={{ padding: '16px' }}>
|
||||
<Space vertical align="start" spacing="tight" style={{ width: '100%' }}>
|
||||
<Space>
|
||||
<div className="dashboard-card__icon">{icon}</div>
|
||||
<Text strong className="dashboard-card__title">
|
||||
{title}
|
||||
</Text>
|
||||
</Space>
|
||||
<div className="dashboard-card__content">
|
||||
<div className="dashboard-card__value" style={{ fontSize: valueFontSize }}>
|
||||
{value}
|
||||
{children}
|
||||
</div>
|
||||
{description && (
|
||||
<Text size="small" type="tertiary" className="dashboard-card__desc">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="dashboard-card__content">
|
||||
<p className="dashboard-card__value" style={{ fontSize: valueFontSize }}>
|
||||
{value}
|
||||
{children}
|
||||
</p>
|
||||
{description && <span className="dashboard-card__desc">{description}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,19 +6,23 @@
|
||||
import React from 'react';
|
||||
import './FredyFooter.less';
|
||||
import { useSelector } from '../../services/state/store.js';
|
||||
import { Typography } from '@douyinfe/semi-ui';
|
||||
import { Typography, Layout, Space, Divider } from '@douyinfe/semi-ui-19';
|
||||
|
||||
export default function FredyFooter() {
|
||||
const { Text } = Typography;
|
||||
const { Footer } = Layout;
|
||||
const version = useSelector((state) => state.versionUpdate.versionUpdate);
|
||||
|
||||
return (
|
||||
<div className="fredyFooter">
|
||||
<div className="fredyFooter__version">
|
||||
<Text type="tertiary">Fredy V{version?.localFredyVersion || 'N/A'}</Text>
|
||||
</div>
|
||||
<div className="fredyFooter__copyRight">
|
||||
<Text link={{ href: 'https://github.com/orangecoding', target: '_blank' }}>Made with ❤️</Text>
|
||||
</div>
|
||||
</div>
|
||||
<Footer className="fredyFooter">
|
||||
<Space split={<Divider layout="vertical" />}>
|
||||
<Text type="tertiary" size="small">
|
||||
Fredy V{version?.localFredyVersion || 'N/A'}
|
||||
</Text>
|
||||
<Text size="small" link={{ href: 'https://github.com/orangecoding', target: '_blank' }}>
|
||||
Made with ❤️
|
||||
</Text>
|
||||
</Space>
|
||||
</Footer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,12 @@
|
||||
.fredyFooter {
|
||||
background:rgb(53, 54, 60);
|
||||
color: white;
|
||||
background-color: var(--semi-color-bg-1);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
height: 1.7rem;
|
||||
border-radius: .3rem;
|
||||
border-top: 1px solid #45464b;
|
||||
|
||||
&__version {
|
||||
padding-left: .5rem;
|
||||
font-size: small;
|
||||
|
||||
}
|
||||
&__copyRight {
|
||||
padding-right: 1rem;
|
||||
|
||||
}
|
||||
padding: 0 1rem;
|
||||
height: 32px;
|
||||
border-top: 1px solid var(--semi-color-border);
|
||||
z-index: 1000;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -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,
|
||||
@@ -184,29 +185,21 @@ const JobGrid = () => {
|
||||
|
||||
return (
|
||||
<div className="jobGrid">
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Button
|
||||
style={{ width: '7rem', margin: 0 }}
|
||||
type="primary"
|
||||
icon={<IconPlusCircle />}
|
||||
className="jobs__newButton"
|
||||
onClick={() => navigate('/jobs/new')}
|
||||
>
|
||||
<Space vertical align="start" style={{ width: '100%', marginBottom: '16px' }} spacing="medium">
|
||||
<Button type="primary" icon={<IconPlusCircle />} onClick={() => navigate('/jobs/new')}>
|
||||
New Job
|
||||
</Button>
|
||||
|
||||
<div className="jobGrid__searchbar">
|
||||
<div className="jobGrid__searchbar" style={{ width: '100%' }}>
|
||||
<Input prefix={<IconSearch />} showClear placeholder="Search" onChange={handleFilterChange} />
|
||||
<Popover content="Filter / Sort Results" style={{ color: 'white', padding: '.5rem' }}>
|
||||
<Button
|
||||
icon={<IconFilter />}
|
||||
onClick={() => {
|
||||
setShowFilterBar(!showFilterBar);
|
||||
}}
|
||||
/>
|
||||
</Popover>
|
||||
<Button
|
||||
icon={<IconFilter />}
|
||||
style={{ marginLeft: '8px' }}
|
||||
onClick={() => {
|
||||
setShowFilterBar(!showFilterBar);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Space>
|
||||
|
||||
{showFilterBar && (
|
||||
<div className="jobGrid__toolbar">
|
||||
@@ -274,7 +267,6 @@ const JobGrid = () => {
|
||||
<Card
|
||||
className="jobGrid__card"
|
||||
bodyStyle={{ padding: '16px' }}
|
||||
headerLine={true}
|
||||
title={
|
||||
<div className="jobGrid__header">
|
||||
<Title heading={5} ellipsis={{ showTooltip: true }} className="jobGrid__title">
|
||||
@@ -287,7 +279,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 +337,61 @@ 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"
|
||||
style={{ background: '#21aa21b5' }}
|
||||
size="small"
|
||||
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"
|
||||
size="small"
|
||||
icon={<IconEdit />}
|
||||
disabled={job.isOnlyShared}
|
||||
onClick={() => navigate(`/jobs/edit/${job.id}`)}
|
||||
/>
|
||||
</div>
|
||||
</Popover>
|
||||
<Popover content={getPopoverContent('Clone Job')}>
|
||||
<div>
|
||||
<Button
|
||||
type="tertiary"
|
||||
size="small"
|
||||
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"
|
||||
size="small"
|
||||
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"
|
||||
size="small"
|
||||
icon={<IconDelete />}
|
||||
disabled={job.isOnlyShared}
|
||||
onClick={() => onJobRemoval(job.id)}
|
||||
/>
|
||||
</div>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
.jobGrid {
|
||||
&__card {
|
||||
height: 100%;
|
||||
transition: transform 0.2s;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
background-color: rgba(36, 36, 36, 0.9);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--semi-color-border);
|
||||
box-shadow: 0 0 15px -3px rgb(78 78 78 / 50%);
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--semi-shadow-elevated);
|
||||
box-shadow: 0 0 15px -3px rgb(78 78 78 / 70%);
|
||||
background-color: rgba(36, 36, 36, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,12 +24,14 @@
|
||||
|
||||
&__toolbar {
|
||||
&__card {
|
||||
border-radius: 5px;
|
||||
border-radius: var(--semi-border-radius-medium);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .3rem;
|
||||
background: #232429;
|
||||
background: rgba(36, 36, 36, 0.9);
|
||||
backdrop-filter: blur(8px);
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--semi-color-border);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
Select,
|
||||
Popover,
|
||||
Empty,
|
||||
} from '@douyinfe/semi-ui';
|
||||
} from '@douyinfe/semi-ui-19';
|
||||
import {
|
||||
IconBriefcase,
|
||||
IconCart,
|
||||
@@ -31,7 +31,10 @@ import {
|
||||
IconStarStroked,
|
||||
IconSearch,
|
||||
IconFilter,
|
||||
IconActivity,
|
||||
IconEyeOpened,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import no_image from '../../../assets/no_image.jpg';
|
||||
import * as timeService from '../../../services/time/timeService.js';
|
||||
import { xhrDelete, xhrPost } from '../../../services/xhr.js';
|
||||
@@ -48,6 +51,7 @@ const ListingsGrid = () => {
|
||||
const providers = useSelector((state) => state.provider);
|
||||
const jobs = useSelector((state) => state.jobsData.jobs);
|
||||
const actions = useActions();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 40;
|
||||
@@ -102,17 +106,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 && (
|
||||
@@ -216,6 +226,8 @@ const ListingsGrid = () => {
|
||||
<Col key={item.id} xs={24} sm={12} md={8} lg={6} xl={4} xxl={6}>
|
||||
<Card
|
||||
className={`listingsGrid__card ${!item.is_active ? 'listingsGrid__card--inactive' : ''}`}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate(`/listings/listing/${item.id}`)}
|
||||
cover={
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div className="listingsGrid__imageContainer">
|
||||
@@ -248,11 +260,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,24 +282,38 @@ 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' }}>
|
||||
<div className="listingsGrid__linkButton" onClick={(e) => e.stopPropagation()}>
|
||||
<a href={item.link} target="_blank" rel="noopener noreferrer">
|
||||
<IconLink />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
title="Link to listing"
|
||||
type="primary"
|
||||
type="secondary"
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
window.open(item.link);
|
||||
}}
|
||||
icon={<IconLink />}
|
||||
title="View Details"
|
||||
onClick={() => navigate(`/listings/listing/${item.id}`)}
|
||||
icon={<IconEyeOpened />}
|
||||
/>
|
||||
|
||||
<Button
|
||||
title="Remove"
|
||||
type="danger"
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await xhrDelete('/api/listings/', { ids: [item.id] });
|
||||
Toast.success('Listing(s) successfully removed');
|
||||
|
||||
@@ -33,11 +33,15 @@
|
||||
|
||||
&__card {
|
||||
height: 100%;
|
||||
transition: transform 0.2s;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
background-color: rgba(36, 36, 36, 0.9);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--semi-color-border);
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--semi-shadow-elevated);
|
||||
background-color: rgba(36, 36, 36, 1);
|
||||
}
|
||||
|
||||
&--inactive {
|
||||
@@ -90,17 +94,45 @@
|
||||
}
|
||||
|
||||
&__toolbar {
|
||||
|
||||
&__card {
|
||||
border-radius: 5px;
|
||||
border-radius: var(--semi-border-radius-medium);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .3rem;
|
||||
background: #232429;
|
||||
background: rgba(36, 36, 36, 0.9);
|
||||
backdrop-filter: blur(8px);
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--semi-color-border);
|
||||
}
|
||||
}
|
||||
|
||||
&__setupButton {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
&__linkButton {
|
||||
background: var(--semi-color-primary);
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
font-weight: 600;
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 3px;
|
||||
|
||||
a {
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--semi-color-primary-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -6,5 +6,6 @@
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
@@ -67,20 +70,18 @@ export default function Navigation({ isAdmin }) {
|
||||
|
||||
return (
|
||||
<Nav
|
||||
style={{ height: '100%' }}
|
||||
style={{ height: '100%', maxWidth: collapsed ? '60px' : '240px' }}
|
||||
items={items}
|
||||
isCollapsed={collapsed}
|
||||
selectedKeys={[parsePathName(location.pathname)]}
|
||||
onSelect={(key) => {
|
||||
navigate(key.itemKey);
|
||||
}}
|
||||
header={<img src={collapsed ? heart : logoWhite} width={collapsed ? '80' : '160'} alt="Fredy Logo" />}
|
||||
header={<img src={collapsed ? heart : logoWhite} width={collapsed ? '30' : '120'} alt="Fredy Logo" />}
|
||||
footer={
|
||||
<Nav.Footer className="navigate__footer">
|
||||
<Logout text={!collapsed} />
|
||||
<Button icon={<IconSidebar />} onClick={() => setCollapsed(!collapsed)}>
|
||||
{!collapsed && 'Collapse'}
|
||||
</Button>
|
||||
<Button icon={<IconSidebar />} onClick={() => setCollapsed(!collapsed)} />
|
||||
</Nav.Footer>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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,10 +5,12 @@
|
||||
|
||||
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';
|
||||
import { Typography } from '@douyinfe/semi-ui';
|
||||
|
||||
export default function ProviderTable({ providerData = [], onRemove, onEdit } = {}) {
|
||||
const { Text } = Typography;
|
||||
return (
|
||||
<Table
|
||||
pagination={false}
|
||||
@@ -22,11 +24,7 @@ export default function ProviderTable({ providerData = [], onRemove, onEdit } =
|
||||
title: 'URL',
|
||||
dataIndex: 'url',
|
||||
render: (_, data) => {
|
||||
return (
|
||||
<a href={data.url} target="_blank" rel="noopener noreferrer">
|
||||
Visit site
|
||||
</a>
|
||||
);
|
||||
return <Text link={{ href: data.url, target: '_blank' }}>Open Provider</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 {
|
||||
@@ -205,6 +195,18 @@ export const useFredyState = create(
|
||||
console.error('Error while trying to get resource for api/listings. Error:', Exception);
|
||||
}
|
||||
},
|
||||
async getListing(listingId) {
|
||||
try {
|
||||
const response = await xhrGet(`/api/listings/${listingId}`);
|
||||
set((state) => ({
|
||||
listingsData: { ...state.listingsData, currentListing: response.json },
|
||||
}));
|
||||
return response.json;
|
||||
} catch (Exception) {
|
||||
console.error(`Error while trying to get resource for api/listings/${listingId}. Error:`, Exception);
|
||||
throw Exception;
|
||||
}
|
||||
},
|
||||
async getListingsForMap({ jobId, minPrice, maxPrice } = {}) {
|
||||
try {
|
||||
const qryString = queryString.stringify(
|
||||
@@ -228,6 +230,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
|
||||
@@ -239,10 +251,11 @@ export const useFredyState = create(
|
||||
page: 1,
|
||||
result: [],
|
||||
mapListings: [],
|
||||
currentListing: null,
|
||||
maxPrice: 0,
|
||||
},
|
||||
features: {},
|
||||
generalSettings: { settings: {} },
|
||||
userSettings: { settings: {} },
|
||||
demoMode: { demoMode: false },
|
||||
versionUpdate: {},
|
||||
provider: [],
|
||||
@@ -265,9 +278,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 {
|
||||
|
||||
@@ -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,
|
||||
@@ -41,10 +41,10 @@ export default function Dashboard() {
|
||||
<div className="dashboard">
|
||||
<Headline text="Dashboard" size={3} />
|
||||
|
||||
<Row gutter={16} className="dashboard__row">
|
||||
<Row gutter={[16, 16]} className="dashboard__row">
|
||||
<Col span={12} xs={24} sm={24} md={24} lg={24} xl={12}>
|
||||
<SegmentPart name="General" Icon={IconTerminal}>
|
||||
<Row gutter={16} className="dashboard__row">
|
||||
<Row gutter={[16, 16]} className="dashboard__row">
|
||||
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
|
||||
<KpiCard
|
||||
title="Search Interval"
|
||||
@@ -104,7 +104,7 @@ export default function Dashboard() {
|
||||
</Col>
|
||||
<Col span={12} xs={24} sm={24} md={24} lg={24} xl={12}>
|
||||
<SegmentPart name="Overview" Icon={IconStar}>
|
||||
<Row gutter={16} className="dashboard__row">
|
||||
<Row gutter={[16, 16]} className="dashboard__row">
|
||||
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
|
||||
<KpiCard
|
||||
title="Jobs"
|
||||
@@ -136,7 +136,14 @@ export default function Dashboard() {
|
||||
<KpiCard
|
||||
title="Avg. Price"
|
||||
color="purple"
|
||||
value={`${!kpis.avgPriceOfListings ? '---' : kpis.avgPriceOfListings} EUR`}
|
||||
value={`${
|
||||
!kpis.avgPriceOfListings
|
||||
? '---'
|
||||
: new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
}).format(kpis.avgPriceOfListings)
|
||||
}`}
|
||||
icon={<IconNoteMoney />}
|
||||
description="Avg. Price of listings"
|
||||
/>
|
||||
@@ -147,7 +154,7 @@ export default function Dashboard() {
|
||||
</Row>
|
||||
|
||||
<SegmentPart name="Provider Insights" Icon={IconStar} helpText="Percentage of found listings over all providers">
|
||||
<PieChartCard title="Jobs per Provider" data={pieData} isLoading={false} />
|
||||
<PieChartCard data={pieData} />
|
||||
</SegmentPart>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
.dashboard {
|
||||
&__row {
|
||||
margin-bottom: 1rem;
|
||||
/* Ensure grid items wrap to next line on narrow screens */
|
||||
margin-bottom: 24px;
|
||||
flex-wrap: wrap;
|
||||
/* Vertical gap of 1rem between wrapped grid items (no px) */
|
||||
|
||||
.semi-col {
|
||||
margin-bottom: 1rem;
|
||||
margin-bottom: 0; // Handled by Row gutter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
383
ui/src/views/listings/ListingDetail.jsx
Normal file
383
ui/src/views/listings/ListingDetail.jsx
Normal file
@@ -0,0 +1,383 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useSelector, useActions } from '../../services/state/store.js';
|
||||
import {
|
||||
Typography,
|
||||
Button,
|
||||
Space,
|
||||
Card,
|
||||
Row,
|
||||
Col,
|
||||
Image,
|
||||
Tag,
|
||||
Divider,
|
||||
Descriptions,
|
||||
Banner,
|
||||
Spin,
|
||||
Toast,
|
||||
} from '@douyinfe/semi-ui-19';
|
||||
import {
|
||||
IconArrowLeft,
|
||||
IconMapPin,
|
||||
IconCart,
|
||||
IconClock,
|
||||
IconBriefcase,
|
||||
IconActivity,
|
||||
IconLink,
|
||||
IconStar,
|
||||
IconStarStroked,
|
||||
IconRealSize,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import no_image from '../../assets/no_image.jpg';
|
||||
import * as timeService from '../../services/time/timeService.js';
|
||||
import { distanceMeters, getBoundsFromCoords } from './mapUtils.js';
|
||||
import { xhrPost } from '../../services/xhr.js';
|
||||
|
||||
import './ListingDetail.less';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
const STYLES = {
|
||||
STANDARD: 'https://tiles.openfreemap.org/styles/bright',
|
||||
};
|
||||
|
||||
export default function ListingDetail() {
|
||||
const { listingId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const actions = useActions();
|
||||
const listing = useSelector((state) => state.listingsData.currentListing);
|
||||
const homeAddress = useSelector((state) => state.userSettings.settings.home_address);
|
||||
const mapContainer = useRef(null);
|
||||
const map = useRef(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchListing() {
|
||||
try {
|
||||
setLoading(true);
|
||||
await actions.listingsData.getListing(listingId);
|
||||
} catch (e) {
|
||||
console.error('Failed to load listing details:', e);
|
||||
Toast.error('Failed to load listing details');
|
||||
navigate('/listings');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
fetchListing();
|
||||
}, [listingId]);
|
||||
|
||||
const hasGeo =
|
||||
listing?.latitude != null && listing?.longitude != null && listing?.latitude !== -1 && listing?.longitude !== -1;
|
||||
|
||||
useEffect(() => {
|
||||
if (loading || !listing || !mapContainer.current || !hasGeo) return;
|
||||
|
||||
if (map.current) {
|
||||
map.current.remove();
|
||||
}
|
||||
|
||||
map.current = new maplibregl.Map({
|
||||
container: mapContainer.current,
|
||||
style: STYLES.STANDARD,
|
||||
center: [listing.longitude, listing.latitude],
|
||||
zoom: 14,
|
||||
cooperativeGestures: true,
|
||||
});
|
||||
|
||||
map.current.addControl(new maplibregl.NavigationControl(), 'top-right');
|
||||
|
||||
new maplibregl.Marker({ color: '#3FB1CE' })
|
||||
.setLngLat([listing.longitude, listing.latitude])
|
||||
.setPopup(new maplibregl.Popup({ offset: 25 }).setHTML(`<h4>Listing Location</h4><p>${listing.address}</p>`))
|
||||
.addTo(map.current);
|
||||
|
||||
if (homeAddress?.coords) {
|
||||
new maplibregl.Marker({ color: 'red' })
|
||||
.setLngLat([homeAddress.coords.lng, homeAddress.coords.lat])
|
||||
.setPopup(new maplibregl.Popup({ offset: 25 }).setHTML(`<h4>Home Address</h4><p>${homeAddress.address}</p>`))
|
||||
.addTo(map.current);
|
||||
|
||||
const bounds = getBoundsFromCoords([
|
||||
[listing.longitude, listing.latitude],
|
||||
[homeAddress.coords.lng, homeAddress.coords.lat],
|
||||
]);
|
||||
|
||||
map.current.fitBounds(bounds, {
|
||||
padding: 50,
|
||||
maxZoom: 15,
|
||||
});
|
||||
|
||||
const drawLine = () => {
|
||||
if (!map.current || !map.current.isStyleLoaded()) return;
|
||||
|
||||
const distance = distanceMeters(
|
||||
listing.latitude,
|
||||
listing.longitude,
|
||||
homeAddress.coords.lat,
|
||||
homeAddress.coords.lng,
|
||||
);
|
||||
|
||||
const midpoint = [
|
||||
(listing.longitude + homeAddress.coords.lng) / 2,
|
||||
(listing.latitude + homeAddress.coords.lat) / 2,
|
||||
];
|
||||
|
||||
if (map.current.getSource('route')) {
|
||||
map.current.getSource('route').setData({
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
{
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: [
|
||||
[listing.longitude, listing.latitude],
|
||||
[homeAddress.coords.lng, homeAddress.coords.lat],
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: midpoint,
|
||||
},
|
||||
properties: {
|
||||
distance: `${Math.round(distance)} m`,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
} else {
|
||||
map.current.addSource('route', {
|
||||
type: 'geojson',
|
||||
data: {
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
{
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: [
|
||||
[listing.longitude, listing.latitude],
|
||||
[homeAddress.coords.lng, homeAddress.coords.lat],
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: midpoint,
|
||||
},
|
||||
properties: {
|
||||
distance: `${Math.round(distance)} m`,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
map.current.addLayer({
|
||||
id: 'route',
|
||||
type: 'line',
|
||||
source: 'route',
|
||||
layout: {
|
||||
'line-join': 'round',
|
||||
'line-cap': 'round',
|
||||
},
|
||||
paint: {
|
||||
'line-color': '#3FB1CE',
|
||||
'line-width': 4,
|
||||
'line-dasharray': [2, 1],
|
||||
},
|
||||
filter: ['==', '$type', 'LineString'],
|
||||
});
|
||||
|
||||
map.current.addLayer({
|
||||
id: 'route-distance',
|
||||
type: 'symbol',
|
||||
source: 'route',
|
||||
layout: {
|
||||
'text-field': ['get', 'distance'],
|
||||
'text-size': 14,
|
||||
'text-offset': [0, -1],
|
||||
'text-allow-overlap': true,
|
||||
},
|
||||
paint: {
|
||||
'text-color': '#ffffff',
|
||||
'text-halo-color': '#3FB1CE',
|
||||
'text-halo-width': 2,
|
||||
},
|
||||
filter: ['==', '$type', 'Point'],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (map.current.isStyleLoaded()) {
|
||||
drawLine();
|
||||
} else {
|
||||
map.current.on('load', drawLine);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (map.current) {
|
||||
map.current.remove();
|
||||
map.current = null;
|
||||
}
|
||||
};
|
||||
}, [listing, loading, homeAddress]);
|
||||
|
||||
const handleWatch = async () => {
|
||||
try {
|
||||
await xhrPost('/api/listings/watch', { listingId: listing.id });
|
||||
Toast.success(listing.isWatched === 1 ? 'Removed from Watchlist' : 'Added to Watchlist');
|
||||
actions.listingsData.getListing(listingId);
|
||||
} catch (e) {
|
||||
console.error('Failed to operate Watchlist:', e);
|
||||
Toast.error('Failed to operate Watchlist');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!listing) return null;
|
||||
|
||||
const data = [
|
||||
{
|
||||
key: 'Job',
|
||||
value: listing.job_name,
|
||||
Icon: <IconBriefcase />,
|
||||
},
|
||||
{
|
||||
key: 'Provider',
|
||||
value: listing.provider.charAt(0).toUpperCase() + listing.provider.slice(1),
|
||||
Icon: <IconBriefcase />,
|
||||
},
|
||||
{ key: 'Price', value: `${listing.price} €`, Icon: <IconCart /> },
|
||||
{
|
||||
key: 'Size',
|
||||
value: listing.size ? `${listing.size} m²` : 'N/A',
|
||||
Icon: <IconRealSize />,
|
||||
},
|
||||
{
|
||||
key: 'Added',
|
||||
value: timeService.format(listing.created_at),
|
||||
Icon: <IconClock />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="listing-detail">
|
||||
<div className="listing-detail__back">
|
||||
<Button icon={<IconArrowLeft />} onClick={() => navigate(-1)} theme="borderless">
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="listing-detail__card">
|
||||
<div className="listing-detail__header">
|
||||
<Space vertical align="start" spacing="tight">
|
||||
<Title heading={2} className="listing-detail__title">
|
||||
{listing.title}
|
||||
</Title>
|
||||
<Space align="center">
|
||||
<IconMapPin style={{ fontSize: '18px', color: 'var(--semi-color-primary)' }} />
|
||||
<Text type="secondary">{listing.address || 'No address provided'}</Text>
|
||||
</Space>
|
||||
</Space>
|
||||
<Space wrap className="listing-detail__header-actions">
|
||||
<Button
|
||||
icon={
|
||||
listing.isWatched === 1 ? (
|
||||
<IconStar style={{ color: 'var(--semi-color-warning)' }} />
|
||||
) : (
|
||||
<IconStarStroked />
|
||||
)
|
||||
}
|
||||
onClick={handleWatch}
|
||||
theme="light"
|
||||
>
|
||||
{listing.isWatched === 1 ? 'Watched' : 'Watch'}
|
||||
</Button>
|
||||
<Text link={{ href: listing.link }} icon={<IconLink />} underline>
|
||||
Open listing
|
||||
</Text>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Row>
|
||||
<Col span={24} lg={12}>
|
||||
<div className="listing-detail__image-container">
|
||||
<Image src={listing.image_url || no_image} fallback={no_image} preview={true} />
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={24} lg={12}>
|
||||
<div className="listing-detail__info-section">
|
||||
<Title heading={4} style={{ marginBottom: '1rem' }}>
|
||||
Details
|
||||
</Title>
|
||||
<Descriptions column={1}>
|
||||
{data.map((item, index) => (
|
||||
<Descriptions.Item key={index}>
|
||||
<Space>
|
||||
{item.Icon}
|
||||
{item.value}
|
||||
</Space>
|
||||
</Descriptions.Item>
|
||||
))}
|
||||
</Descriptions>
|
||||
<Divider margin="1.5rem" />
|
||||
<Title heading={4} style={{ marginBottom: '1rem' }}>
|
||||
Description
|
||||
</Title>
|
||||
<Text type="secondary" style={{ whiteSpace: 'pre-wrap' }}>
|
||||
{listing.description || 'No description available.'}
|
||||
</Text>
|
||||
|
||||
{listing.distance_to_destination && (
|
||||
<>
|
||||
<Divider margin="1.5rem" />
|
||||
<Space align="center">
|
||||
<IconActivity style={{ fontSize: '18px', color: 'var(--semi-color-primary)' }} />
|
||||
<Text strong>Distance to home:</Text>
|
||||
<Tag color="blue">{listing.distance_to_destination} m</Tag>
|
||||
</Space>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<div className="listing-detail__map-wrapper">
|
||||
<Title heading={3}>Location</Title>
|
||||
{!hasGeo ? (
|
||||
<Banner
|
||||
type="warning"
|
||||
bordered
|
||||
description="This listing has no valid geocoordinates, so we cannot show it on the map."
|
||||
style={{ marginTop: '1rem' }}
|
||||
/>
|
||||
) : (
|
||||
<div ref={mapContainer} className="listing-detail__map-container" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
110
ui/src/views/listings/ListingDetail.less
Normal file
110
ui/src/views/listings/ListingDetail.less
Normal file
@@ -0,0 +1,110 @@
|
||||
.listing-detail {
|
||||
padding-bottom: 2rem;
|
||||
|
||||
&__back {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
&__card {
|
||||
background-color: rgba(36, 36, 36, 0.9);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--semi-color-border);
|
||||
margin-bottom: 2rem;
|
||||
overflow: hidden;
|
||||
|
||||
.semi-card-body {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding: 1.5rem;
|
||||
gap: 1rem;
|
||||
border-bottom: 1px solid var(--semi-color-border);
|
||||
|
||||
@media (max-width: 768px) {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
& > .semi-space {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
margin-top: 0.5rem;
|
||||
|
||||
.semi-button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0 !important;
|
||||
word-break: break-word;
|
||||
@media (max-width: 768px) {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__image-container {
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
background-color: var(--semi-color-fill-0);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
height: 250px;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
&__info-section {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
&__map-container {
|
||||
height: 400px;
|
||||
width: 100%;
|
||||
border-radius: var(--semi-border-radius-medium);
|
||||
margin-top: 1rem;
|
||||
border: 1px solid var(--semi-color-border);
|
||||
|
||||
@media (max-width: 768px) {
|
||||
height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
&__map-wrapper {
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.info-tag {
|
||||
font-size: 0.9rem;
|
||||
padding: 0.2rem 0.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
.listing-detail-popup {
|
||||
.map-popup-content {
|
||||
padding: 5px;
|
||||
h4 {
|
||||
margin: 5px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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, IconEyeOpened } from '@douyinfe/semi-icons';
|
||||
|
||||
import no_image from '../../assets/no_image.jpg';
|
||||
import RangeSlider from 'react-range-slider-input';
|
||||
import 'react-range-slider-input/dist/style.css';
|
||||
import './Map.less';
|
||||
import { xhrDelete } from '../../services/xhr.js';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -65,8 +71,11 @@ export default function MapView() {
|
||||
const mapContainer = useRef(null);
|
||||
const map = useRef(null);
|
||||
const markers = useRef([]);
|
||||
const homeMarker = useRef(null);
|
||||
const actions = useActions();
|
||||
const navigate = useNavigate();
|
||||
const listings = useSelector((state) => state.listingsData.mapListings);
|
||||
const homeAddress = useSelector((state) => state.userSettings.settings.home_address);
|
||||
const [style, setStyle] = useState('STANDARD');
|
||||
const [show3dBuildings, setShow3dBuildings] = useState(false);
|
||||
|
||||
@@ -74,6 +83,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 +103,27 @@ 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');
|
||||
}
|
||||
};
|
||||
|
||||
window.viewDetails = (id) => {
|
||||
navigate(`/listings/listing/${id}`);
|
||||
};
|
||||
|
||||
return () => {
|
||||
delete window.deleteListing;
|
||||
delete window.viewDetails;
|
||||
};
|
||||
}, [navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (map.current) return;
|
||||
|
||||
@@ -150,6 +181,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 +233,7 @@ export default function MapView() {
|
||||
}
|
||||
};
|
||||
|
||||
if (map.current.isStyleLoaded()) {
|
||||
add3dLayer();
|
||||
} else {
|
||||
map.current.once('styledata', add3dLayer);
|
||||
}
|
||||
add3dLayer();
|
||||
}, [show3dBuildings, style]);
|
||||
|
||||
const setMapStyle = (value) => {
|
||||
@@ -225,12 +253,118 @@ 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const updateLayers = () => {
|
||||
addCircleLayer();
|
||||
};
|
||||
|
||||
if (map.current.isStyleLoaded()) {
|
||||
updateLayers();
|
||||
} else {
|
||||
map.current.on('load', updateLayers);
|
||||
}
|
||||
|
||||
filterListings().forEach((listing) => {
|
||||
if (
|
||||
listing.latitude != null &&
|
||||
@@ -242,8 +376,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 +385,47 @@ 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} m²` : '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__detailsButton"
|
||||
title="View Details"
|
||||
onclick="viewDetails('${listing.id}')"
|
||||
>
|
||||
${renderToString(<IconEyeOpened />)}
|
||||
</button>
|
||||
<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 +433,7 @@ export default function MapView() {
|
||||
markers.current.push(marker);
|
||||
}
|
||||
});
|
||||
}, [listings, priceRange]);
|
||||
}, [listings, priceRange, homeAddress, distanceFilter]);
|
||||
|
||||
return (
|
||||
<div className="map-view-container">
|
||||
@@ -281,12 +450,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 +487,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 +535,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"
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
.map-view-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 120px); /* Adjust based on header/footer height */
|
||||
padding: 1rem;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.map-filter-bar {
|
||||
@@ -43,10 +44,85 @@
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
&__detailsButton {
|
||||
background: var(--semi-color-tertiary);
|
||||
color: white;
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
|
||||
&:hover {
|
||||
background: var(--semi-color-tertiary-hover);
|
||||
}
|
||||
|
||||
svg {
|
||||
fill: currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
&__deleteButton {
|
||||
background: var(--semi-color-danger);
|
||||
color: white;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
130
ui/src/views/listings/mapUtils.js
Normal file
130
ui/src/views/listings/mapUtils.js
Normal 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],
|
||||
];
|
||||
};
|
||||
@@ -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';
|
||||
@@ -58,40 +58,46 @@ export default function Login() {
|
||||
return (
|
||||
<div className="login">
|
||||
<div className="login__bgImage" style={{ background: `url("${cityBackground}")` }} />
|
||||
<Logo />
|
||||
<form>
|
||||
<div className="login__loginWrapper">
|
||||
{error && <Banner type="danger" closeIcon={null} description={error} />}
|
||||
<Input
|
||||
size="large"
|
||||
prefix={<IconUser />}
|
||||
placeholder="Username"
|
||||
value={username}
|
||||
showClear
|
||||
autoFocus
|
||||
onChange={(value) => setUserName(value)}
|
||||
onKeyPress={async (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
await tryLogin();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="login__loginWrapper">
|
||||
<div className="login__logoWrapper">
|
||||
<Logo width={250} white />
|
||||
</div>
|
||||
<form onSubmit={(e) => e.preventDefault()}>
|
||||
{error && <Banner type="danger" closeIcon={null} description={error} style={{ marginBottom: '1rem' }} />}
|
||||
<div className="login__inputGroup">
|
||||
<Input
|
||||
size="large"
|
||||
prefix={<IconUser />}
|
||||
placeholder="Username"
|
||||
value={username}
|
||||
showClear
|
||||
autoFocus
|
||||
onChange={(value) => setUserName(value)}
|
||||
onKeyPress={async (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
await tryLogin();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
size="large"
|
||||
mode="password"
|
||||
prefix={<IconLock />}
|
||||
value={password}
|
||||
placeholder="Password"
|
||||
onChange={(value) => setPassword(value)}
|
||||
onKeyPress={async (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
await tryLogin();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="login__inputGroup">
|
||||
<Input
|
||||
size="large"
|
||||
mode="password"
|
||||
prefix={<IconLock />}
|
||||
value={password}
|
||||
placeholder="Password"
|
||||
onChange={(value) => setPassword(value)}
|
||||
onKeyPress={async (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
await tryLogin();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="primary" onClick={tryLogin} theme="solid" style={{ marginTop: '1rem' }}>
|
||||
<Button block type="primary" onClick={tryLogin} theme="solid" style={{ marginTop: '1rem' }}>
|
||||
Login
|
||||
</Button>
|
||||
|
||||
@@ -102,10 +108,11 @@ export default function Login() {
|
||||
bordered
|
||||
closeIcon={null}
|
||||
description="This is the demo version of Fredy. Use 'demo' as both the username and password to log in."
|
||||
style={{ marginTop: '1.5rem' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,32 +4,80 @@
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
&__bgImage {
|
||||
background-size: cover;
|
||||
filter: blur(8px);
|
||||
-webkit-filter: blur(8px);
|
||||
background-position: center;
|
||||
filter: blur(10px) brightness(0.4);
|
||||
-webkit-filter: blur(10px) brightness(0.4);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
top: -20px;
|
||||
left: -20px;
|
||||
right: -20px;
|
||||
bottom: -20px;
|
||||
z-index: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
&__loginWrapper {
|
||||
border: 1px solid #555050;
|
||||
border-radius: 30px;
|
||||
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
background-color: #151313ab;
|
||||
background: rgba(20, 20, 25, 0.7);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 24px;
|
||||
padding: 3rem;
|
||||
width: 90%;
|
||||
max-width: 420px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 2rem;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.7);
|
||||
|
||||
}
|
||||
|
||||
&__logoWrapper {
|
||||
margin-bottom: 2.5rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
|
||||
.logo {
|
||||
position: static !important;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
form {
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.2rem;
|
||||
}
|
||||
|
||||
&__inputGroup {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// Mobile responsiveness
|
||||
@media (max-width: 480px) {
|
||||
&__loginWrapper {
|
||||
padding: 2rem 1.5rem;
|
||||
width: 95%;
|
||||
border-radius: 20px;
|
||||
background: rgba(20, 20, 25, 0.85);
|
||||
|
||||
&::after {
|
||||
opacity: 0.2;
|
||||
filter: blur(10px);
|
||||
}
|
||||
}
|
||||
|
||||
&__logoWrapper {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
107
ui/src/views/userSettings/UserSettings.jsx
Normal file
107
ui/src/views/userSettings/UserSettings.jsx
Normal file
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* 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. We will now start calculating distances for you. This may take a while and runs in the background.',
|
||||
);
|
||||
} 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;
|
||||
321
yarn.lock
321
yarn.lock
@@ -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"
|
||||
@@ -1346,6 +1384,11 @@
|
||||
resolved "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz"
|
||||
integrity sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==
|
||||
|
||||
"@maplibre/geojson-vt@^5.0.4":
|
||||
version "5.0.4"
|
||||
resolved "https://registry.yarnpkg.com/@maplibre/geojson-vt/-/geojson-vt-5.0.4.tgz#c5f301a5d227cecf0bf4d1ab9239b8b0b13e78fe"
|
||||
integrity sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==
|
||||
|
||||
"@maplibre/maplibre-gl-style-spec@^24.4.1":
|
||||
version "24.4.1"
|
||||
resolved "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.4.1.tgz"
|
||||
@@ -1366,16 +1409,16 @@
|
||||
dependencies:
|
||||
"@mapbox/point-geometry" "^1.1.0"
|
||||
|
||||
"@maplibre/vt-pbf@^4.2.0":
|
||||
version "4.2.0"
|
||||
resolved "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.2.0.tgz"
|
||||
integrity sha512-bxrk/kQUwWXZgmqYgwOCnZCMONCRi3MJMqJdza4T3E4AeR5i+VyMnaJ8iDWtWxdfEAJRtrzIOeJtxZSy5mFrFA==
|
||||
"@maplibre/vt-pbf@^4.2.1":
|
||||
version "4.2.1"
|
||||
resolved "https://registry.yarnpkg.com/@maplibre/vt-pbf/-/vt-pbf-4.2.1.tgz#395d97bd5de68b5efabf0d56c535163bb88f75c7"
|
||||
integrity sha512-IxZBGq/+9cqf2qdWlFuQ+ZfoMhWpxDUGQZ/poPHOJBvwMUT1GuxLo6HgYTou+xxtsOsjfbcjI8PZaPCtmt97rA==
|
||||
dependencies:
|
||||
"@mapbox/point-geometry" "^1.1.0"
|
||||
"@mapbox/vector-tile" "^2.0.4"
|
||||
"@types/geojson-vt" "3.2.5"
|
||||
"@maplibre/geojson-vt" "^5.0.4"
|
||||
"@types/geojson" "^7946.0.16"
|
||||
"@types/supercluster" "^7.1.3"
|
||||
geojson-vt "^4.0.2"
|
||||
pbf "^4.0.1"
|
||||
supercluster "^8.0.1"
|
||||
|
||||
@@ -1422,10 +1465,10 @@
|
||||
resolved "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz"
|
||||
integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==
|
||||
|
||||
"@puppeteer/browsers@2.11.1":
|
||||
version "2.11.1"
|
||||
resolved "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.11.1.tgz"
|
||||
integrity sha512-YmhAxs7XPuxN0j7LJloHpfD1ylhDuFmmwMvfy/+6nBSrETT2ycL53LrhgPtR+f+GcPSybQVuQ5inWWu5MrWCpA==
|
||||
"@puppeteer/browsers@2.11.2":
|
||||
version "2.11.2"
|
||||
resolved "https://registry.yarnpkg.com/@puppeteer/browsers/-/browsers-2.11.2.tgz#54ac339579c23014535011e6dc04bf3157705d73"
|
||||
integrity sha512-GBY0+2lI9fDrjgb5dFL9+enKXqyOPok9PXg/69NVkjW3bikbK9RQrNrI3qccQXmDNN7ln4j/yL89Qgvj/tfqrw==
|
||||
dependencies:
|
||||
debug "^4.4.3"
|
||||
extract-zip "^2.0.1"
|
||||
@@ -1569,14 +1612,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 +1628,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 +1659,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 +1683,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"
|
||||
@@ -1708,13 +1751,6 @@
|
||||
resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz"
|
||||
integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==
|
||||
|
||||
"@types/geojson-vt@3.2.5":
|
||||
version "3.2.5"
|
||||
resolved "https://registry.npmjs.org/@types/geojson-vt/-/geojson-vt-3.2.5.tgz"
|
||||
integrity sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g==
|
||||
dependencies:
|
||||
"@types/geojson" "*"
|
||||
|
||||
"@types/geojson@*", "@types/geojson@^7946.0.16":
|
||||
version "7946.0.16"
|
||||
resolved "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz"
|
||||
@@ -2118,10 +2154,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 +2366,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 +2410,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 +2460,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 +2538,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 +2571,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 +2770,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 +2808,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 +2896,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 +3383,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"
|
||||
@@ -3543,11 +3589,6 @@ gensync@^1.0.0-beta.2:
|
||||
resolved "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz"
|
||||
integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==
|
||||
|
||||
geojson-vt@^4.0.2:
|
||||
version "4.0.2"
|
||||
resolved "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.2.tgz"
|
||||
integrity sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A==
|
||||
|
||||
get-caller-file@^2.0.5:
|
||||
version "2.0.5"
|
||||
resolved "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz"
|
||||
@@ -3810,15 +3851,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 +4500,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 +4529,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==
|
||||
@@ -4530,10 +4571,10 @@ make-dir@^2.1.0:
|
||||
pify "^4.0.1"
|
||||
semver "^5.6.0"
|
||||
|
||||
maplibre-gl@^5.16.0:
|
||||
version "5.16.0"
|
||||
resolved "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.16.0.tgz"
|
||||
integrity sha512-/VDY89nr4jgLJyzmhy325cG6VUI02WkZ/UfVuDbG/piXzo6ODnM+omDFIwWY8tsEsBG26DNDmNMn3Y2ikHsBiA==
|
||||
maplibre-gl@^5.17.0:
|
||||
version "5.17.0"
|
||||
resolved "https://registry.yarnpkg.com/maplibre-gl/-/maplibre-gl-5.17.0.tgz#b7de18caf2c70d0ba98715803eea7f1e39581c36"
|
||||
integrity sha512-gwS6NpXBfWD406dtT5YfEpl2hmpMm+wcPqf04UAez/TxY1OBjiMdK2ZoMGcNIlGHelKc4+Uet6zhDdDEnlJVHA==
|
||||
dependencies:
|
||||
"@mapbox/geojson-rewind" "^0.5.2"
|
||||
"@mapbox/jsonlint-lines-primitives" "^2.0.2"
|
||||
@@ -4542,14 +4583,13 @@ maplibre-gl@^5.16.0:
|
||||
"@mapbox/unitbezier" "^0.0.1"
|
||||
"@mapbox/vector-tile" "^2.0.4"
|
||||
"@mapbox/whoots-js" "^3.1.0"
|
||||
"@maplibre/geojson-vt" "^5.0.4"
|
||||
"@maplibre/maplibre-gl-style-spec" "^24.4.1"
|
||||
"@maplibre/mlt" "^1.1.2"
|
||||
"@maplibre/vt-pbf" "^4.2.0"
|
||||
"@maplibre/vt-pbf" "^4.2.1"
|
||||
"@types/geojson" "^7946.0.16"
|
||||
"@types/geojson-vt" "3.2.5"
|
||||
"@types/supercluster" "^7.1.3"
|
||||
earcut "^3.0.2"
|
||||
geojson-vt "^4.0.2"
|
||||
gl-matrix "^3.4.4"
|
||||
kdbush "^4.0.2"
|
||||
murmurhash-js "^1.0.0"
|
||||
@@ -5736,10 +5776,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 +6011,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.1:
|
||||
version "24.36.1"
|
||||
resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-24.36.1.tgz#8d60c80a27b86b3d1d948155ecab2deb404cc00f"
|
||||
integrity sha512-L7ykMWc3lQf3HS7ME3PSjp7wMIjJeW6+bKfH/RSTz5l6VUDGubnrC2BKj3UvM28Y5PMDFW0xniJOZHBZPpW1dQ==
|
||||
dependencies:
|
||||
"@puppeteer/browsers" "2.11.1"
|
||||
chromium-bidi "12.0.1"
|
||||
"@puppeteer/browsers" "2.11.2"
|
||||
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 +6071,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.1:
|
||||
version "24.36.1"
|
||||
resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-24.36.1.tgz#55b328215090b9617eb71ca541232c14a60c14cb"
|
||||
integrity sha512-uPiDUyf7gd7Il1KnqfNUtHqntL0w1LapEw5Zsuh8oCK8GsqdxySX1PzdIHKB2Dw273gWY4MW0zC5gy3Re9XlqQ==
|
||||
dependencies:
|
||||
"@puppeteer/browsers" "2.11.1"
|
||||
chromium-bidi "12.0.1"
|
||||
"@puppeteer/browsers" "2.11.2"
|
||||
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.1"
|
||||
typed-query-selector "^2.12.0"
|
||||
|
||||
qs@^6.14.1:
|
||||
@@ -6101,13 +6141,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.4:
|
||||
version "19.2.4"
|
||||
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.2.4.tgz#6fac6bd96f7db477d966c7ec17c1a2b1ad8e6591"
|
||||
integrity sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==
|
||||
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 +6163,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 +6182,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 +6205,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.4:
|
||||
version "19.2.4"
|
||||
resolved "https://registry.yarnpkg.com/react/-/react-19.2.4.tgz#438e57baa19b77cb23aab516cf635cd0579ee09a"
|
||||
integrity sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==
|
||||
|
||||
readable-stream@^3.1.1, readable-stream@^3.4.0:
|
||||
version "3.6.2"
|
||||
@@ -6511,12 +6548,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 +6615,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 +7255,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 +7426,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"
|
||||
|
||||
Reference in New Issue
Block a user