mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d09770dae2 | ||
|
|
248e4d2562 | ||
|
|
7b8e961b49 | ||
|
|
f66ceccbb4 | ||
|
|
a3db725af6 | ||
|
|
0663bd945f | ||
|
|
bc355fb5fe | ||
|
|
797421f0d5 | ||
|
|
0b2b42fc75 | ||
|
|
472169693f |
94
CHANGELOG.md
94
CHANGELOG.md
@@ -1,94 +0,0 @@
|
||||
Newer release changelog see https://github.com/orangecoding/fredy/releases
|
||||
|
||||
---
|
||||
|
||||
###### [V5.5.0]
|
||||
|
||||
- Upgrading dependencies
|
||||
- fixing provider
|
||||
- allow multiple instances of 1 provider
|
||||
- **BREAKING**: Minimum node version is now 16
|
||||
|
||||
###### [V5.4.6]
|
||||
|
||||
- Adding Instana node.js monitoring
|
||||
-
|
||||
|
||||
###### [V5.4.5]
|
||||
|
||||
- Adding Instana node.js monitoring
|
||||
|
||||
###### [V5.4.4]
|
||||
|
||||
- Add support for Immo Südwest Presse (immo.swp.de)
|
||||
- Telegram: Use job name instead of ID and link in title
|
||||
- Fix race condition if user ID is in session but not in user store
|
||||
- Allow visiting the original provider URL
|
||||
|
||||
###### [V5.4.3]
|
||||
|
||||
- re-writing readme
|
||||
- improving docker build
|
||||
- using github's actions to build docker and test automatically
|
||||
|
||||
###### [V5.4.2]
|
||||
|
||||
- Fixing prod build
|
||||
|
||||
###### [V5.4.1]
|
||||
|
||||
- Upgrading dependencies
|
||||
- Provider urls are now automagically been changed to include the correct sort order for search results
|
||||
|
||||
```
|
||||
Note: It has been an point of confusion since the very beginning of Fredy, that people simply copied the url, but
|
||||
did not take care of sorting the search results by date. If this is not done, Fredy will most likely not see the latest
|
||||
results, thus cannot report them. This release fixes it by adding the necessary params (or replaces them).
|
||||
```
|
||||
|
||||
###### [V5.3.0]
|
||||
|
||||
- Upgrading dependencies
|
||||
- It's now possible to send mails to multiple receiver using comma separation for MailJet & Sendgrid
|
||||
- Fixing Immowelt scraping
|
||||
|
||||
###### [V5.2.0]
|
||||
|
||||
- Upgrading dependencies
|
||||
- Adding new similarity check layer (Duplicates are being removed now)
|
||||
- Adding paging for search results
|
||||
|
||||
###### [V5.1.0]
|
||||
|
||||
- Upgrading dependencies
|
||||
- NodeJS 12.13 is now the minimum supported version
|
||||
- Adding general settings as new configuration page to ui
|
||||
- Adding new feature working hours
|
||||
|
||||
###### [V5.0.0]
|
||||
|
||||
- Upgrading dependencies
|
||||
- NodeJS 12 is now the minimum supported version
|
||||
|
||||
###### [V4.0.0]
|
||||
|
||||
Bringing back Immoscout :tada:
|
||||
|
||||
###### [V3.0.0]
|
||||
|
||||
This is basically a re-write, your old config file will not be compatible anymore. Please re-created your search jobs
|
||||
on the new ui and use the values from your previous config file if needed.
|
||||
|
||||
```
|
||||
- We're getting rid of manual config changes, Fredy, now ships with a UI so that it's easy for you to create and edit jobs
|
||||
```
|
||||
|
||||
###### [V2.0.0]
|
||||
|
||||
```
|
||||
- Fredy can now run multiple search job on one instance
|
||||
- Changed lot's of the structure of Fredy to make this happen
|
||||
[BREAKING CHANGES]
|
||||
- The config has been changed, the config of V1.x will not work any longer
|
||||
- Sources have been renamed to provider
|
||||
```
|
||||
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();
|
||||
|
||||
@@ -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 && 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 {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { isAdmin as isAdminFn } from '../security.js';
|
||||
import logger from '../../services/logger.js';
|
||||
import { nullOrEmpty } from '../../utils.js';
|
||||
import { getJobs } from '../../services/storage/jobStorage.js';
|
||||
import { getSettings } from '../../services/storage/settingsStorage.js';
|
||||
|
||||
const service = restana();
|
||||
|
||||
@@ -74,6 +75,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 {
|
||||
@@ -94,9 +107,15 @@ listingsRouter.post('/watch', async (req, res) => {
|
||||
});
|
||||
|
||||
listingsRouter.delete('/job', async (req, res) => {
|
||||
const { jobId } = req.body;
|
||||
const { jobId, hardDelete = false } = req.body;
|
||||
const settings = await getSettings();
|
||||
try {
|
||||
listingStorage.deleteListingsByJobId(jobId);
|
||||
if (settings.demoMode) {
|
||||
res.send(new Error('Sorry, but you cannot remove listings in demo mode ;)'));
|
||||
return;
|
||||
}
|
||||
|
||||
listingStorage.deleteListingsByJobId(jobId, hardDelete);
|
||||
} catch (error) {
|
||||
res.send(new Error(error));
|
||||
logger.error(error);
|
||||
@@ -105,10 +124,10 @@ listingsRouter.delete('/job', async (req, res) => {
|
||||
});
|
||||
|
||||
listingsRouter.delete('/', async (req, res) => {
|
||||
const { ids } = req.body;
|
||||
const { ids, hardDelete = false } = req.body;
|
||||
try {
|
||||
if (Array.isArray(ids) && ids.length > 0) {
|
||||
listingStorage.deleteListingsById(ids);
|
||||
listingStorage.deleteListingsById(ids, hardDelete);
|
||||
}
|
||||
} catch (error) {
|
||||
res.send(new Error(error));
|
||||
|
||||
@@ -5,14 +5,15 @@
|
||||
|
||||
import restana from 'restana';
|
||||
import SqliteConnection from '../../services/storage/SqliteConnection.js';
|
||||
import { upsertSettings } from '../../services/storage/settingsStorage.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 { calculateDistanceForUser } from '../../services/geocoding/distanceService.js';
|
||||
import { fromJson } from '../../utils.js';
|
||||
import { trackFeature } from '../../services/tracking/Tracker.js';
|
||||
import { FEATURES } from '../../features.js';
|
||||
import logger from '../../services/logger.js';
|
||||
import { runGeoCordTask } from '../../services/crons/geocoding-cron.js';
|
||||
|
||||
const service = restana();
|
||||
const userSettingsRouter = service.newRouter();
|
||||
@@ -43,6 +44,12 @@ userSettingsRouter.get('/autocomplete', async (req, res) => {
|
||||
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) {
|
||||
@@ -50,7 +57,9 @@ userSettingsRouter.post('/home-address', async (req, res) => {
|
||||
const coords = await geocodeAddress(home_address);
|
||||
if (coords && coords.lat !== -1) {
|
||||
upsertSettings({ home_address: { address: home_address, coords } }, userId);
|
||||
calculateDistanceForUser(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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,10 @@ import { getListingsToGeocode, updateListingGeocoordinates } from '../storage/li
|
||||
import { geocodeAddress, isGeocodingPaused } from '../geocoding/geoCodingService.js';
|
||||
import { getJobs } from '../storage/jobStorage.js';
|
||||
import { calculateDistanceForJob } from '../geocoding/distanceService.js';
|
||||
import { getSettings } from '../storage/settingsStorage.js';
|
||||
import logger from '../logger.js';
|
||||
|
||||
async function runTask() {
|
||||
export async function runGeoCordTask() {
|
||||
const listings = getListingsToGeocode();
|
||||
if (listings.length > 0) {
|
||||
for (const listing of listings) {
|
||||
@@ -32,8 +34,13 @@ async function runTask() {
|
||||
}
|
||||
|
||||
export async function initGeocodingCron() {
|
||||
const settings = await getSettings();
|
||||
if (settings.demoMode) {
|
||||
logger.info('Do not start geo service as we are in demo mode');
|
||||
return;
|
||||
}
|
||||
// run directly on start
|
||||
await runTask();
|
||||
await runGeoCordTask();
|
||||
// then every 6 hours
|
||||
cron.schedule('0 */6 * * *', runTask);
|
||||
cron.schedule('0 */6 * * *', runGeoCordTask);
|
||||
}
|
||||
|
||||
@@ -5,12 +5,19 @@
|
||||
|
||||
import cron from 'node-cron';
|
||||
import runActiveChecker from '../listings/listingActiveService.js';
|
||||
import logger from '../logger.js';
|
||||
import { getSettings } from '../storage/settingsStorage.js';
|
||||
|
||||
async function runTask() {
|
||||
await runActiveChecker();
|
||||
}
|
||||
|
||||
export async function initActiveCheckerCron() {
|
||||
const settings = await getSettings();
|
||||
if (settings.demoMode) {
|
||||
logger.info('Do not start listing active checker as we are in demo mode');
|
||||
return;
|
||||
}
|
||||
//run directly on start
|
||||
await runTask();
|
||||
// then every day at 1 am
|
||||
|
||||
@@ -37,12 +37,12 @@ export const upsertJob = ({
|
||||
if (existing) {
|
||||
SqliteConnection.execute(
|
||||
`UPDATE jobs
|
||||
SET enabled = @enabled,
|
||||
name = @name,
|
||||
blacklist = @blacklist,
|
||||
provider = @provider,
|
||||
notification_adapter = @notification_adapter,
|
||||
shared_with_user = @shareWithUsers
|
||||
SET enabled = @enabled,
|
||||
name = @name,
|
||||
blacklist = @blacklist,
|
||||
provider = @provider,
|
||||
notification_adapter = @notification_adapter,
|
||||
shared_with_user = @shareWithUsers
|
||||
WHERE id = @id`,
|
||||
{
|
||||
id,
|
||||
@@ -87,10 +87,10 @@ export const getJob = (jobId) => {
|
||||
j.provider,
|
||||
j.shared_with_user,
|
||||
j.notification_adapter AS notificationAdapter,
|
||||
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id) AS numberOfFoundListings
|
||||
FROM jobs j
|
||||
WHERE j.id = @id
|
||||
LIMIT 1`,
|
||||
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id AND l.is_active = 1 AND l.manually_deleted = 0) AS numberOfFoundListings
|
||||
FROM jobs j
|
||||
WHERE j.id = @id
|
||||
LIMIT 1`,
|
||||
{ id: jobId },
|
||||
)[0];
|
||||
if (!row) return null;
|
||||
@@ -150,9 +150,10 @@ export const getJobs = () => {
|
||||
j.provider,
|
||||
j.shared_with_user,
|
||||
j.notification_adapter AS notificationAdapter,
|
||||
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id) AS numberOfFoundListings
|
||||
FROM jobs j
|
||||
ORDER BY j.name IS NULL, j.name`,
|
||||
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id AND l.is_active = 1 AND l.manually_deleted = 0) AS numberOfFoundListings
|
||||
FROM jobs j
|
||||
WHERE j.enabled = 1
|
||||
ORDER BY j.name IS NULL, j.name`,
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
@@ -250,11 +251,11 @@ export const queryJobs = ({
|
||||
j.provider,
|
||||
j.shared_with_user,
|
||||
j.notification_adapter AS notificationAdapter,
|
||||
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id) AS numberOfFoundListings
|
||||
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id AND l.is_active = 1 AND l.manually_deleted = 0) AS numberOfFoundListings
|
||||
FROM jobs j
|
||||
${whereSql}
|
||||
${orderSql}
|
||||
LIMIT @limit OFFSET @offset`,
|
||||
${orderSql}
|
||||
LIMIT @limit OFFSET @offset`,
|
||||
params,
|
||||
);
|
||||
|
||||
|
||||
@@ -370,10 +370,18 @@ export const queryListings = ({
|
||||
* Delete all listings for a given job id.
|
||||
*
|
||||
* @param {string} jobId - The job identifier whose listings should be removed.
|
||||
* @returns {any} The result from SqliteConnection.execute (may contain changes count).
|
||||
* @param {boolean} [hardDelete=false] - Whether to hard delete from DB or just mark as deleted.
|
||||
* @returns {any} The result from SqliteConnection.execute.
|
||||
*/
|
||||
export const deleteListingsByJobId = (jobId) => {
|
||||
export const deleteListingsByJobId = (jobId, hardDelete = false) => {
|
||||
if (!jobId) return;
|
||||
if (hardDelete) {
|
||||
return SqliteConnection.execute(
|
||||
`DELETE FROM listings
|
||||
WHERE job_id = @jobId`,
|
||||
{ jobId },
|
||||
);
|
||||
}
|
||||
return SqliteConnection.execute(
|
||||
`UPDATE listings
|
||||
SET manually_deleted = 1
|
||||
@@ -386,11 +394,19 @@ export const deleteListingsByJobId = (jobId) => {
|
||||
* Delete listings by a list of listing IDs.
|
||||
*
|
||||
* @param {string[]} ids - Array of listing IDs to delete.
|
||||
* @param {boolean} [hardDelete=false] - Whether to hard delete from DB or just mark as deleted.
|
||||
* @returns {any} The result from SqliteConnection.execute.
|
||||
*/
|
||||
export const deleteListingsById = (ids) => {
|
||||
export const deleteListingsById = (ids, hardDelete = false) => {
|
||||
if (!Array.isArray(ids) || ids.length === 0) return;
|
||||
const placeholders = ids.map(() => '?').join(',');
|
||||
if (hardDelete) {
|
||||
return SqliteConnection.execute(
|
||||
`DELETE FROM listings
|
||||
WHERE id IN (${placeholders})`,
|
||||
ids,
|
||||
);
|
||||
}
|
||||
return SqliteConnection.execute(
|
||||
`UPDATE listings
|
||||
SET manually_deleted = 1
|
||||
@@ -566,3 +582,49 @@ export const updateListingDistance = (id, distance) => {
|
||||
{ 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 },
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -15,55 +15,67 @@ const deviceId = getUniqueId() || 'N/A';
|
||||
const version = await getPackageVersion();
|
||||
const FREDY_TRACKING_URL = 'https://fredy.orange-coding.net/tracking';
|
||||
|
||||
export const trackMainEvent = async () => {
|
||||
const staticTrackingData = {
|
||||
operatingSystem: os.platform(),
|
||||
osVersion: os.release(),
|
||||
arch: process.arch,
|
||||
language: process.env.LANG || 'en',
|
||||
nodeVersion: process.version || 'N/A',
|
||||
deviceId,
|
||||
version,
|
||||
};
|
||||
|
||||
const shouldTrack = async () => {
|
||||
const settings = await getSettings();
|
||||
return settings.analyticsEnabled && !inDevMode();
|
||||
};
|
||||
|
||||
const sendTrackingData = async (endpoint, payload) => {
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
if (settings.analyticsEnabled && !inDevMode()) {
|
||||
const activeProvider = new Set();
|
||||
const activeAdapter = new Set();
|
||||
|
||||
const jobs = getJobs();
|
||||
|
||||
if (jobs != null && jobs.length > 0) {
|
||||
jobs.forEach((job) => {
|
||||
job.provider.forEach((provider) => activeProvider.add(provider.id));
|
||||
job.notificationAdapter.forEach((adapter) => activeAdapter.add(adapter.id));
|
||||
});
|
||||
|
||||
const trackingObj = enrichTrackingObject({
|
||||
adapter: Array.from(activeAdapter),
|
||||
provider: Array.from(activeProvider),
|
||||
});
|
||||
|
||||
await fetch(`${FREDY_TRACKING_URL}/main`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(trackingObj),
|
||||
});
|
||||
}
|
||||
const response = await fetch(`${FREDY_TRACKING_URL}${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: payload ? JSON.stringify(payload) : undefined,
|
||||
});
|
||||
if (!response.ok) {
|
||||
logger.warn(`Error sending tracking data to ${endpoint}. Status: ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Error sending tracking data', error);
|
||||
logger.warn(`Error sending tracking data to ${endpoint}`, error);
|
||||
}
|
||||
};
|
||||
|
||||
export const trackMainEvent = async () => {
|
||||
if (!(await shouldTrack())) return;
|
||||
|
||||
const activeProvider = new Set();
|
||||
const activeAdapter = new Set();
|
||||
|
||||
const jobs = getJobs();
|
||||
|
||||
if (jobs != null && jobs.length > 0) {
|
||||
jobs.forEach((job) => {
|
||||
job.provider.forEach((provider) => activeProvider.add(provider.id));
|
||||
job.notificationAdapter.forEach((adapter) => activeAdapter.add(adapter.id));
|
||||
});
|
||||
|
||||
const trackingObj = await enrichTrackingObject({
|
||||
adapter: Array.from(activeAdapter),
|
||||
provider: Array.from(activeProvider),
|
||||
});
|
||||
|
||||
await sendTrackingData('/main', trackingObj);
|
||||
}
|
||||
};
|
||||
|
||||
export const trackFeature = async (feature) => {
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
if (settings.analyticsEnabled && !inDevMode()) {
|
||||
const trackingObj = await enrichTrackingObject({
|
||||
feature,
|
||||
});
|
||||
if (!(await shouldTrack())) return;
|
||||
|
||||
await fetch(`${FREDY_TRACKING_URL}/feature`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(trackingObj),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Error tracking feature', error);
|
||||
}
|
||||
const trackingObj = await enrichTrackingObject({
|
||||
feature,
|
||||
});
|
||||
|
||||
await sendTrackingData('/feature', trackingObj);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -72,34 +84,17 @@ export const trackFeature = async (feature) => {
|
||||
export async function trackDemoAccessed() {
|
||||
const settings = await getSettings();
|
||||
if (settings.analyticsEnabled && !inDevMode() && settings.demoMode) {
|
||||
try {
|
||||
await fetch(`${FREDY_TRACKING_URL}/demo/accessed`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn('Error sending tracking data', error);
|
||||
}
|
||||
const trackingObj = await enrichTrackingObject({});
|
||||
await sendTrackingData('/demo/accessed', trackingObj);
|
||||
}
|
||||
}
|
||||
|
||||
async function enrichTrackingObject(trackingObject) {
|
||||
const settings = await getSettings();
|
||||
const operatingSystem = os.platform();
|
||||
const osVersion = os.release();
|
||||
const arch = process.arch;
|
||||
const language = process.env.LANG || 'en';
|
||||
const nodeVersion = process.version || 'N/A';
|
||||
|
||||
return {
|
||||
...trackingObject,
|
||||
...staticTrackingData,
|
||||
isDemo: settings.demoMode,
|
||||
operatingSystem,
|
||||
osVersion,
|
||||
arch,
|
||||
nodeVersion,
|
||||
language,
|
||||
deviceId,
|
||||
version,
|
||||
};
|
||||
}
|
||||
|
||||
18
package.json
18
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "fredy",
|
||||
"version": "19.2.2",
|
||||
"version": "19.3.6",
|
||||
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
||||
"scripts": {
|
||||
"prepare": "husky",
|
||||
@@ -63,7 +63,7 @@
|
||||
"@douyinfe/semi-ui": "2.90.13",
|
||||
"@douyinfe/semi-ui-19": "^2.90.13",
|
||||
"@sendgrid/mail": "8.1.6",
|
||||
"@vitejs/plugin-react": "5.1.2",
|
||||
"@vitejs/plugin-react": "5.1.3",
|
||||
"adm-zip": "^0.5.16",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"body-parser": "2.2.2",
|
||||
@@ -72,20 +72,20 @@
|
||||
"cookie-session": "2.1.1",
|
||||
"handlebars": "4.7.8",
|
||||
"lodash": "4.17.23",
|
||||
"maplibre-gl": "^5.16.0",
|
||||
"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.36.0",
|
||||
"puppeteer": "^24.36.1",
|
||||
"puppeteer-extra": "^3.3.6",
|
||||
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
||||
"query-string": "9.3.1",
|
||||
"react": "19.2.3",
|
||||
"react": "19.2.4",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"react-dom": "19.2.3",
|
||||
"react-dom": "19.2.4",
|
||||
"react-range-slider-input": "^3.3.2",
|
||||
"react-router": "7.13.0",
|
||||
"react-router-dom": "7.13.0",
|
||||
@@ -95,12 +95,12 @@
|
||||
"slack": "11.0.2",
|
||||
"vite": "7.3.1",
|
||||
"x-var": "^3.0.1",
|
||||
"zustand": "^5.0.10"
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.28.6",
|
||||
"@babel/core": "7.29.0",
|
||||
"@babel/eslint-parser": "7.28.6",
|
||||
"@babel/preset-env": "7.28.6",
|
||||
"@babel/preset-env": "7.29.0",
|
||||
"@babel/preset-react": "7.28.5",
|
||||
"chai": "6.2.2",
|
||||
"eslint": "9.39.2",
|
||||
|
||||
120
ui/src/App.jsx
120
ui/src/App.jsx
@@ -19,7 +19,7 @@ import Jobs from './views/jobs/Jobs';
|
||||
|
||||
import './App.less';
|
||||
import TrackingModal from './components/tracking/TrackingModal.jsx';
|
||||
import { Banner, Divider } from '@douyinfe/semi-ui-19';
|
||||
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';
|
||||
@@ -28,6 +28,7 @@ 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();
|
||||
@@ -59,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>
|
||||
@@ -68,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 && (
|
||||
<>
|
||||
@@ -87,68 +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="/userSettings"
|
||||
element={
|
||||
<PermissionAwareRoute currentUser={currentUser} adminOnly={false}>
|
||||
<UserSettings />
|
||||
</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,47 +1,31 @@
|
||||
.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;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@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;
|
||||
}
|
||||
|
||||
a {outline : none;}
|
||||
|
||||
.semi-icon:not(.semi-tabs-bar .semi-tabs-tab .semi-icon) {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
70
ui/src/components/ListingDeletionModal.jsx
Normal file
70
ui/src/components/ListingDeletionModal.jsx
Normal file
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Modal, Radio, RadioGroup, Typography } from '@douyinfe/semi-ui-19';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const ListingDeletionModal = ({
|
||||
visible,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
title = 'Delete Listings',
|
||||
showOptions = true,
|
||||
message = 'How would you like to delete the selected listing(s)?',
|
||||
}) => {
|
||||
const [deleteType, setDeleteType] = useState('soft');
|
||||
|
||||
const handleOk = () => {
|
||||
onConfirm(!showOptions || deleteType === 'hard');
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={title}
|
||||
visible={visible}
|
||||
onOk={handleOk}
|
||||
onCancel={onCancel}
|
||||
okText="Confirm"
|
||||
cancelText="Cancel"
|
||||
style={{ maxWidth: '500px' }}
|
||||
>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Text>{message}</Text>
|
||||
</div>
|
||||
{showOptions && (
|
||||
<RadioGroup value={deleteType} onChange={(e) => setDeleteType(e.target.value)} style={{ width: '100%' }}>
|
||||
<Radio value="soft" style={{ alignItems: 'flex-start', width: '100%' }}>
|
||||
<div style={{ marginLeft: 8 }}>
|
||||
<Text strong>Mark as deleted (Soft Delete)</Text>
|
||||
<br />
|
||||
<Text type="secondary">
|
||||
Listings are kept in the database but marked as hidden. They will <b>not</b> re-appear during the next
|
||||
scraping session.
|
||||
</Text>
|
||||
</div>
|
||||
</Radio>
|
||||
<Radio value="hard" style={{ marginTop: 16, alignItems: 'flex-start', width: '100%' }}>
|
||||
<div style={{ marginLeft: 8 }}>
|
||||
<Text strong>Remove from database (Hard Delete)</Text>
|
||||
<br />
|
||||
<Text type="secondary">
|
||||
Listings are completely removed from the database.
|
||||
<br />
|
||||
<Text type="warning">
|
||||
Consequence: They might re-appear when scraping the next time because Fredy won't know they were
|
||||
previously found.
|
||||
</Text>
|
||||
</Text>
|
||||
</div>
|
||||
</Radio>
|
||||
</RadioGroup>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListingDeletionModal;
|
||||
@@ -1,92 +1,80 @@
|
||||
@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;
|
||||
background-color: rgba(36, 36, 36, 0.9);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--semi-color-border);
|
||||
--pulse-color: rgba(255, 255, 255, 0.1);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
overflow: visible;
|
||||
|
||||
&.blue {
|
||||
.color-variant(@color-blue-bg, @color-blue-border, @color-blue-text);
|
||||
}
|
||||
|
||||
&.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;
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
border-radius: inherit;
|
||||
box-shadow: 0 4px 25px -2px var(--pulse-color);
|
||||
opacity: 0;
|
||||
animation: pulse 5s infinite ease-in-out;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
will-change: opacity;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
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 {
|
||||
--pulse-color: var(--semi-color-primary);
|
||||
box-shadow: 0 4px 20px -5px var(--pulse-color);
|
||||
}
|
||||
|
||||
&.orange {
|
||||
--pulse-color: var(--semi-color-warning);
|
||||
box-shadow: 0 4px 20px -5px var(--pulse-color);
|
||||
}
|
||||
|
||||
&.green {
|
||||
--pulse-color: var(--semi-color-success);
|
||||
box-shadow: 0 4px 20px -5px var(--pulse-color);
|
||||
}
|
||||
|
||||
&.purple {
|
||||
--pulse-color: var(--semi-color-info);
|
||||
box-shadow: 0 4px 20px -5px var(--pulse-color);
|
||||
}
|
||||
|
||||
&.gray {
|
||||
--pulse-color: rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 4px 20px -5px var(--pulse-color);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 0.1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
@@ -3,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-19';
|
||||
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;
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
IconPlusCircle,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import ListingDeletionModal from '../../ListingDeletionModal.jsx';
|
||||
import { useActions, useSelector } from '../../../services/state/store.js';
|
||||
import { xhrDelete, xhrPut, xhrPost } from '../../../services/xhr.js';
|
||||
import debounce from 'lodash/debounce';
|
||||
@@ -60,6 +61,9 @@ const JobGrid = () => {
|
||||
const [activityFilter, setActivityFilter] = useState(null);
|
||||
const [showFilterBar, setShowFilterBar] = useState(false);
|
||||
|
||||
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
||||
const [pendingDeletion, setPendingDeletion] = useState(null); // { type: 'job'|'listings', jobId }
|
||||
|
||||
const pendingJobIdRef = useRef(null);
|
||||
const evtSourceRef = useRef(null);
|
||||
|
||||
@@ -125,24 +129,35 @@ const JobGrid = () => {
|
||||
};
|
||||
}, [handleFilterChange]);
|
||||
|
||||
const onJobRemoval = async (jobId) => {
|
||||
try {
|
||||
await xhrDelete('/api/jobs', { jobId });
|
||||
Toast.success('Job successfully removed');
|
||||
loadData();
|
||||
actions.jobsData.getJobs(); // refresh select list too
|
||||
} catch (error) {
|
||||
Toast.error(error);
|
||||
}
|
||||
const onJobRemoval = (jobId) => {
|
||||
setPendingDeletion({ type: 'job', jobId });
|
||||
setDeleteModalVisible(true);
|
||||
};
|
||||
|
||||
const onListingRemoval = async (jobId) => {
|
||||
const onListingRemoval = (jobId) => {
|
||||
setPendingDeletion({ type: 'listings', jobId });
|
||||
setDeleteModalVisible(true);
|
||||
};
|
||||
|
||||
const confirmDeletion = async (hardDelete) => {
|
||||
const { type, jobId } = pendingDeletion;
|
||||
try {
|
||||
await xhrDelete('/api/listings/job', { jobId });
|
||||
Toast.success('Listings successfully removed');
|
||||
if (type === 'job') {
|
||||
await xhrDelete('/api/jobs', { jobId });
|
||||
Toast.success('Job and listings successfully removed');
|
||||
} else if (type === 'listings') {
|
||||
await xhrDelete('/api/listings/job', { jobId, hardDelete });
|
||||
Toast.success('Listings successfully removed');
|
||||
}
|
||||
loadData();
|
||||
if (type === 'job') {
|
||||
actions.jobsData.getJobs(); // refresh select list too
|
||||
}
|
||||
} catch (error) {
|
||||
Toast.error(error);
|
||||
Toast.error(error.message || 'Error performing deletion');
|
||||
} finally {
|
||||
setDeleteModalVisible(false);
|
||||
setPendingDeletion(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -185,31 +200,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' }}>
|
||||
<div>
|
||||
<Button
|
||||
icon={<IconFilter />}
|
||||
onClick={() => {
|
||||
setShowFilterBar(!showFilterBar);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Popover>
|
||||
<Button
|
||||
icon={<IconFilter />}
|
||||
style={{ marginLeft: '8px' }}
|
||||
onClick={() => {
|
||||
setShowFilterBar(!showFilterBar);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Space>
|
||||
|
||||
{showFilterBar && (
|
||||
<div className="jobGrid__toolbar">
|
||||
@@ -277,7 +282,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">
|
||||
@@ -351,6 +355,8 @@ const JobGrid = () => {
|
||||
<div>
|
||||
<Button
|
||||
type="primary"
|
||||
style={{ background: '#21aa21b5' }}
|
||||
size="small"
|
||||
theme="solid"
|
||||
icon={<IconPlayCircle />}
|
||||
disabled={job.isOnlyShared || job.running}
|
||||
@@ -362,7 +368,7 @@ const JobGrid = () => {
|
||||
<div>
|
||||
<Button
|
||||
type="secondary"
|
||||
theme="solid"
|
||||
size="small"
|
||||
icon={<IconEdit />}
|
||||
disabled={job.isOnlyShared}
|
||||
onClick={() => navigate(`/jobs/edit/${job.id}`)}
|
||||
@@ -373,7 +379,7 @@ const JobGrid = () => {
|
||||
<div>
|
||||
<Button
|
||||
type="tertiary"
|
||||
theme="solid"
|
||||
size="small"
|
||||
icon={<IconCopy />}
|
||||
disabled={job.isOnlyShared}
|
||||
onClick={() => navigate('/jobs/new', { state: { cloneFrom: job.id } })}
|
||||
@@ -384,7 +390,7 @@ const JobGrid = () => {
|
||||
<div>
|
||||
<Button
|
||||
type="danger"
|
||||
theme="solid"
|
||||
size="small"
|
||||
icon={<IconDescend2 />}
|
||||
disabled={job.isOnlyShared}
|
||||
onClick={() => onListingRemoval(job.id)}
|
||||
@@ -395,7 +401,7 @@ const JobGrid = () => {
|
||||
<div>
|
||||
<Button
|
||||
type="danger"
|
||||
theme="solid"
|
||||
size="small"
|
||||
icon={<IconDelete />}
|
||||
disabled={job.isOnlyShared}
|
||||
onClick={() => onJobRemoval(job.id)}
|
||||
@@ -419,6 +425,21 @@ const JobGrid = () => {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ListingDeletionModal
|
||||
visible={deleteModalVisible}
|
||||
title={pendingDeletion?.type === 'job' ? 'Delete Job' : 'Delete Listings'}
|
||||
showOptions={pendingDeletion?.type !== 'job'}
|
||||
message={
|
||||
pendingDeletion?.type === 'job'
|
||||
? 'Are you sure you want to delete this job? All associated listings will be removed from the database.'
|
||||
: 'How would you like to delete the selected listing(s)?'
|
||||
}
|
||||
onConfirm={confirmDeletion}
|
||||
onCancel={() => {
|
||||
setDeleteModalVisible(false);
|
||||
setPendingDeletion(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,10 @@ import {
|
||||
IconSearch,
|
||||
IconFilter,
|
||||
IconActivity,
|
||||
IconEyeOpened,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import ListingDeletionModal from '../../ListingDeletionModal.jsx';
|
||||
import no_image from '../../../assets/no_image.jpg';
|
||||
import * as timeService from '../../../services/time/timeService.js';
|
||||
import { xhrDelete, xhrPost } from '../../../services/xhr.js';
|
||||
@@ -49,6 +52,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;
|
||||
@@ -62,6 +66,9 @@ const ListingsGrid = () => {
|
||||
const [providerFilter, setProviderFilter] = useState(null);
|
||||
const [showFilterBar, setShowFilterBar] = useState(false);
|
||||
|
||||
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
||||
const [listingToDelete, setListingToDelete] = useState(null);
|
||||
|
||||
const loadData = () => {
|
||||
actions.listingsData.getListingsData({
|
||||
page,
|
||||
@@ -103,6 +110,19 @@ const ListingsGrid = () => {
|
||||
setPage(_page);
|
||||
};
|
||||
|
||||
const confirmDeletion = async (hardDelete) => {
|
||||
try {
|
||||
await xhrDelete('/api/listings/', { ids: [listingToDelete], hardDelete });
|
||||
Toast.success('Listing successfully removed');
|
||||
loadData();
|
||||
} catch (error) {
|
||||
Toast.error(error.message || 'Error deleting listing');
|
||||
} finally {
|
||||
setDeleteModalVisible(false);
|
||||
setListingToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const cap = (val) => {
|
||||
return String(val).charAt(0).toUpperCase() + String(val).slice(1);
|
||||
};
|
||||
@@ -223,6 +243,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">
|
||||
@@ -289,24 +311,28 @@ const ListingsGrid = () => {
|
||||
</Space>
|
||||
<Divider margin=".6rem" />
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<div className="listingsGrid__linkButton">
|
||||
<div className="listingsGrid__linkButton" onClick={(e) => e.stopPropagation()}>
|
||||
<a href={item.link} target="_blank" rel="noopener noreferrer">
|
||||
<IconLink />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="secondary"
|
||||
size="small"
|
||||
title="View Details"
|
||||
onClick={() => navigate(`/listings/listing/${item.id}`)}
|
||||
icon={<IconEyeOpened />}
|
||||
/>
|
||||
|
||||
<Button
|
||||
title="Remove"
|
||||
type="danger"
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await xhrDelete('/api/listings/', { ids: [item.id] });
|
||||
Toast.success('Listing(s) successfully removed');
|
||||
loadData();
|
||||
} catch (error) {
|
||||
Toast.error(error);
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setListingToDelete(item.id);
|
||||
setDeleteModalVisible(true);
|
||||
}}
|
||||
icon={<IconDelete />}
|
||||
/>
|
||||
@@ -327,6 +353,14 @@ const ListingsGrid = () => {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ListingDeletionModal
|
||||
visible={deleteModalVisible}
|
||||
onConfirm={confirmDeletion}
|
||||
onCancel={() => {
|
||||
setDeleteModalVisible(false);
|
||||
setListingToDelete(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,13 +94,15 @@
|
||||
}
|
||||
|
||||
&__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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,7 +111,7 @@
|
||||
}
|
||||
|
||||
&__linkButton {
|
||||
background: var(--semi-color-fill-0);
|
||||
background: var(--semi-color-primary);
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
font-weight: 600;
|
||||
@@ -115,5 +121,18 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,5 +6,6 @@
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
}
|
||||
@@ -70,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>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -8,12 +8,12 @@ import { Card } from '@douyinfe/semi-ui-19';
|
||||
|
||||
import './SegmentParts.less';
|
||||
|
||||
export const SegmentPart = ({ name, Icon = null, children, helpText = null }) => {
|
||||
export const SegmentPart = ({ name, Icon = null, children, helpText = null, className = '' }) => {
|
||||
const { Meta } = Card;
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="segmentParts"
|
||||
className={`segmentParts ${className}`}
|
||||
title={
|
||||
(helpText || name) && (
|
||||
<Meta title={name} description={helpText} avatar={Icon == null ? null : <Icon size="extra-extra-small" />} />
|
||||
|
||||
@@ -7,8 +7,10 @@ import React from 'react';
|
||||
|
||||
import { Empty, Table, Button } from '@douyinfe/semi-ui-19';
|
||||
import { IconDelete, IconEdit } from '@douyinfe/semi-icons';
|
||||
import { Typography } from '@douyinfe/semi-ui';
|
||||
|
||||
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>;
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -195,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(
|
||||
@@ -239,6 +251,7 @@ export const useFredyState = create(
|
||||
page: 1,
|
||||
result: [],
|
||||
mapListings: [],
|
||||
currentListing: null,
|
||||
maxPrice: 0,
|
||||
},
|
||||
generalSettings: { settings: {} },
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
import { useSelector, useActions } from '../../services/state/store';
|
||||
import KpiCard from '../../components/cards/KpiCard.jsx';
|
||||
import PieChartCard from '../../components/cards/PieChartCard.jsx';
|
||||
import Headline from '../../components/headline/Headline.jsx';
|
||||
|
||||
import './Dashboard.less';
|
||||
import { SegmentPart } from '../../components/segment/SegmentPart.jsx';
|
||||
@@ -39,12 +38,10 @@ export default function Dashboard() {
|
||||
|
||||
return (
|
||||
<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 +101,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 +133,14 @@ export default function Dashboard() {
|
||||
<KpiCard
|
||||
title="Avg. Price"
|
||||
color="purple"
|
||||
value={`${!kpis.avgPriceOfListings ? '---' : kpis.avgPriceOfListings} €`}
|
||||
value={`${
|
||||
!kpis.avgPriceOfListings
|
||||
? '---'
|
||||
: new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
}).format(kpis.avgPriceOfListings)
|
||||
}`}
|
||||
icon={<IconNoteMoney />}
|
||||
description="Avg. Price of listings"
|
||||
/>
|
||||
@@ -146,8 +150,13 @@ export default function Dashboard() {
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<SegmentPart name="Provider Insights" Icon={IconStar} helpText="Percentage of found listings over all providers">
|
||||
<PieChartCard title="Jobs per Provider" data={pieData} isLoading={false} />
|
||||
<SegmentPart
|
||||
name="Provider Insights"
|
||||
Icon={IconStar}
|
||||
helpText="Percentage of found listings over all providers"
|
||||
className="dashboard__provider-insights"
|
||||
>
|
||||
<PieChartCard data={pieData} />
|
||||
</SegmentPart>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,33 @@
|
||||
.dashboard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
|
||||
&__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
|
||||
}
|
||||
}
|
||||
|
||||
&__provider-insights {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0 !important;
|
||||
|
||||
.semi-card-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
max-height: 300px;
|
||||
|
||||
> * {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
388
ui/src/views/listings/ListingDetail.jsx
Normal file
388
ui/src/views/listings/ListingDetail.jsx
Normal file
@@ -0,0 +1,388 @@
|
||||
/*
|
||||
* 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, target: '_blank' }} 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}
|
||||
fallback={no_image}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,14 +11,15 @@ import { useSelector, useActions } from '../../services/state/store.js';
|
||||
import { distanceMeters, generateCircleCoords, getBoundsFromCenter, getBoundsFromCoords } from './mapUtils.js';
|
||||
import { Select, Space, Typography, Button, Popover, Divider, Switch, Banner, Toast } from '@douyinfe/semi-ui-19';
|
||||
import { IconFilter, IconLink } from '@douyinfe/semi-icons';
|
||||
import { IconDelete } from '@douyinfe/semi-icons';
|
||||
import { 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 } from 'react-router';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import ListingDeletionModal from '../../components/ListingDeletionModal.jsx';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -73,6 +74,7 @@ export default function MapView() {
|
||||
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');
|
||||
@@ -84,6 +86,22 @@ export default function MapView() {
|
||||
const [showFilterBar, setShowFilterBar] = useState(false);
|
||||
const [distanceFilter, setDistanceFilter] = useState(0);
|
||||
|
||||
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
||||
const [listingToDelete, setListingToDelete] = useState(null);
|
||||
|
||||
const confirmListingDeletion = async (hardDelete) => {
|
||||
try {
|
||||
await xhrDelete('/api/listings/', { ids: [listingToDelete], hardDelete });
|
||||
Toast.success('Listing successfully removed');
|
||||
fetchListings();
|
||||
} catch (error) {
|
||||
Toast.error(error.message || 'Error deleting listing');
|
||||
} finally {
|
||||
setDeleteModalVisible(false);
|
||||
setListingToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setPriceRange([0, getMaxPrice()]);
|
||||
}, [listings]);
|
||||
@@ -103,20 +121,20 @@ export default function MapView() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
window.deleteListing = async (id) => {
|
||||
try {
|
||||
await xhrDelete('/api/listings/', { ids: [id] });
|
||||
Toast.success('Listing successfully removed');
|
||||
fetchListings();
|
||||
} catch (error) {
|
||||
Toast.error(error.message || 'Error deleting listing');
|
||||
}
|
||||
window.deleteListing = (id) => {
|
||||
setListingToDelete(id);
|
||||
setDeleteModalVisible(true);
|
||||
};
|
||||
|
||||
window.viewDetails = (id) => {
|
||||
navigate(`/listings/listing/${id}`);
|
||||
};
|
||||
|
||||
return () => {
|
||||
delete window.deleteListing;
|
||||
delete window.viewDetails;
|
||||
};
|
||||
}, []);
|
||||
}, [navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (map.current) return;
|
||||
@@ -349,7 +367,15 @@ export default function MapView() {
|
||||
}
|
||||
};
|
||||
|
||||
addCircleLayer();
|
||||
const updateLayers = () => {
|
||||
addCircleLayer();
|
||||
};
|
||||
|
||||
if (map.current.isStyleLoaded()) {
|
||||
updateLayers();
|
||||
} else {
|
||||
map.current.on('load', updateLayers);
|
||||
}
|
||||
|
||||
filterListings().forEach((listing) => {
|
||||
if (
|
||||
@@ -364,7 +390,10 @@ export default function MapView() {
|
||||
|
||||
const popupContent = `
|
||||
<div class="map-popup-content">
|
||||
<img src="${listing.image_url || no_image}" alt="${listing.title}" />
|
||||
<img
|
||||
src="${listing.image_url}"
|
||||
onerror="this.onerror=null;this.src='${no_image}'"
|
||||
/>
|
||||
<h4>${listing.title}</h4>
|
||||
<div class="info">
|
||||
<span><strong>Price:</strong> ${listing.price ? listing.price + ' €' : 'N/A'}</span>
|
||||
@@ -378,6 +407,13 @@ export default function MapView() {
|
||||
${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"
|
||||
@@ -538,6 +574,14 @@ export default function MapView() {
|
||||
/>
|
||||
|
||||
<div ref={mapContainer} className="map-container" />
|
||||
<ListingDeletionModal
|
||||
visible={deleteModalVisible}
|
||||
onConfirm={confirmListingDeletion}
|
||||
onCancel={() => {
|
||||
setDeleteModalVisible(false);
|
||||
setListingToDelete(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -78,6 +79,29 @@
|
||||
}
|
||||
}
|
||||
|
||||
&__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;
|
||||
|
||||
@@ -58,54 +58,62 @@ 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>
|
||||
|
||||
<Input
|
||||
size="large"
|
||||
mode="password"
|
||||
prefix={<IconLock />}
|
||||
value={password}
|
||||
placeholder="Password"
|
||||
onChange={(value) => setPassword(value)}
|
||||
onKeyPress={async (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
await tryLogin();
|
||||
}
|
||||
}}
|
||||
{demoMode && (
|
||||
<Banner
|
||||
fullMode={true}
|
||||
type="info"
|
||||
bordered
|
||||
closeIcon={null}
|
||||
description="This is the demo version of Fredy. Use 'demo' as both the username and password to log in."
|
||||
style={{ marginBottom: '1.5rem' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button type="primary" onClick={tryLogin} theme="solid" style={{ marginTop: '1rem' }}>
|
||||
<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>
|
||||
|
||||
<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 block type="primary" onClick={tryLogin} theme="solid" style={{ marginTop: '1rem' }}>
|
||||
Login
|
||||
</Button>
|
||||
|
||||
{demoMode && (
|
||||
<Banner
|
||||
fullMode={true}
|
||||
type="info"
|
||||
bordered
|
||||
closeIcon={null}
|
||||
description="This is the demo version of Fredy. Use 'demo' as both the username and password to log in."
|
||||
/>
|
||||
)}
|
||||
</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,15 +4,13 @@
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { Divider, Button, AutoComplete, Toast, Typography, Banner } from '@douyinfe/semi-ui-19';
|
||||
import { Divider, Button, AutoComplete, Toast, Banner } from '@douyinfe/semi-ui-19';
|
||||
import { IconSave, IconHome } from '@douyinfe/semi-icons';
|
||||
import { useSelector, useActions } from '../../services/state/store';
|
||||
import { xhrGet, xhrPost } from '../../services/xhr';
|
||||
import { SegmentPart } from '../../components/segment/SegmentPart';
|
||||
import debounce from 'lodash/debounce';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
const UserSettings = () => {
|
||||
const actions = useActions();
|
||||
const homeAddress = useSelector((state) => state.userSettings.settings.home_address);
|
||||
@@ -33,7 +31,9 @@ const UserSettings = () => {
|
||||
if (response.status === 200) {
|
||||
setCoords(response.json.coords);
|
||||
await actions.userSettings.getUserSettings();
|
||||
Toast.success('Settings saved successfully');
|
||||
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');
|
||||
}
|
||||
@@ -70,8 +70,6 @@ const UserSettings = () => {
|
||||
|
||||
return (
|
||||
<div className="user-settings">
|
||||
<Title heading={2}>User Specific Settings</Title>
|
||||
<Divider />
|
||||
<SegmentPart
|
||||
name="Distance claculation"
|
||||
Icon={IconHome}
|
||||
|
||||
389
yarn.lock
389
yarn.lock
@@ -20,25 +20,39 @@
|
||||
js-tokens "^4.0.0"
|
||||
picocolors "^1.1.1"
|
||||
|
||||
"@babel/compat-data@^7.27.7", "@babel/compat-data@^7.28.6":
|
||||
"@babel/code-frame@^7.29.0":
|
||||
version "7.29.0"
|
||||
resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.29.0.tgz#7cd7a59f15b3cc0dcd803038f7792712a7d0b15c"
|
||||
integrity sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==
|
||||
dependencies:
|
||||
"@babel/helper-validator-identifier" "^7.28.5"
|
||||
js-tokens "^4.0.0"
|
||||
picocolors "^1.1.1"
|
||||
|
||||
"@babel/compat-data@^7.28.6":
|
||||
version "7.28.6"
|
||||
resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz"
|
||||
integrity sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==
|
||||
|
||||
"@babel/core@7.28.6", "@babel/core@^7.28.5":
|
||||
version "7.28.6"
|
||||
resolved "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz"
|
||||
integrity sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==
|
||||
"@babel/compat-data@^7.29.0":
|
||||
version "7.29.0"
|
||||
resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.29.0.tgz#00d03e8c0ac24dd9be942c5370990cbe1f17d88d"
|
||||
integrity sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==
|
||||
|
||||
"@babel/core@7.29.0", "@babel/core@^7.29.0":
|
||||
version "7.29.0"
|
||||
resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.29.0.tgz#5286ad785df7f79d656e88ce86e650d16ca5f322"
|
||||
integrity sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==
|
||||
dependencies:
|
||||
"@babel/code-frame" "^7.28.6"
|
||||
"@babel/generator" "^7.28.6"
|
||||
"@babel/code-frame" "^7.29.0"
|
||||
"@babel/generator" "^7.29.0"
|
||||
"@babel/helper-compilation-targets" "^7.28.6"
|
||||
"@babel/helper-module-transforms" "^7.28.6"
|
||||
"@babel/helpers" "^7.28.6"
|
||||
"@babel/parser" "^7.28.6"
|
||||
"@babel/parser" "^7.29.0"
|
||||
"@babel/template" "^7.28.6"
|
||||
"@babel/traverse" "^7.28.6"
|
||||
"@babel/types" "^7.28.6"
|
||||
"@babel/traverse" "^7.29.0"
|
||||
"@babel/types" "^7.29.0"
|
||||
"@jridgewell/remapping" "^2.3.5"
|
||||
convert-source-map "^2.0.0"
|
||||
debug "^4.1.0"
|
||||
@@ -66,6 +80,17 @@
|
||||
"@jridgewell/trace-mapping" "^0.3.28"
|
||||
jsesc "^3.0.2"
|
||||
|
||||
"@babel/generator@^7.29.0":
|
||||
version "7.29.0"
|
||||
resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.29.0.tgz#4cba5a76b3c71d8be31761b03329d5dc7768447f"
|
||||
integrity sha512-vSH118/wwM/pLR38g/Sgk05sNtro6TlTJKuiMXDaZqPUfjTFcudpCOt00IhOfj+1BFAX+UFAlzCU+6WXr3GLFQ==
|
||||
dependencies:
|
||||
"@babel/parser" "^7.29.0"
|
||||
"@babel/types" "^7.29.0"
|
||||
"@jridgewell/gen-mapping" "^0.3.12"
|
||||
"@jridgewell/trace-mapping" "^0.3.28"
|
||||
jsesc "^3.0.2"
|
||||
|
||||
"@babel/helper-annotate-as-pure@^7.27.1", "@babel/helper-annotate-as-pure@^7.27.3":
|
||||
version "7.27.3"
|
||||
resolved "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz"
|
||||
@@ -73,7 +98,7 @@
|
||||
dependencies:
|
||||
"@babel/types" "^7.27.3"
|
||||
|
||||
"@babel/helper-compilation-targets@^7.27.1", "@babel/helper-compilation-targets@^7.27.2", "@babel/helper-compilation-targets@^7.28.6":
|
||||
"@babel/helper-compilation-targets@^7.27.1", "@babel/helper-compilation-targets@^7.28.6":
|
||||
version "7.28.6"
|
||||
resolved "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz"
|
||||
integrity sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==
|
||||
@@ -106,16 +131,16 @@
|
||||
regexpu-core "^6.3.1"
|
||||
semver "^6.3.1"
|
||||
|
||||
"@babel/helper-define-polyfill-provider@^0.6.5":
|
||||
version "0.6.5"
|
||||
resolved "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.5.tgz"
|
||||
integrity sha512-uJnGFcPsWQK8fvjgGP5LZUZZsYGIoPeRjSF5PGwrelYgq7Q15/Ft9NGFp1zglwgIv//W0uG4BevRuSJRyylZPg==
|
||||
"@babel/helper-define-polyfill-provider@^0.6.6":
|
||||
version "0.6.6"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.6.tgz#714dfe33d8bd710f556df59953720f6eeb6c1a14"
|
||||
integrity sha512-mOAsxeeKkUKayvZR3HeTYD/fICpCPLJrU5ZjelT/PA6WHtNDBOE436YiaEUvHN454bRM3CebhDsIpieCc4texA==
|
||||
dependencies:
|
||||
"@babel/helper-compilation-targets" "^7.27.2"
|
||||
"@babel/helper-plugin-utils" "^7.27.1"
|
||||
debug "^4.4.1"
|
||||
"@babel/helper-compilation-targets" "^7.28.6"
|
||||
"@babel/helper-plugin-utils" "^7.28.6"
|
||||
debug "^4.4.3"
|
||||
lodash.debounce "^4.0.8"
|
||||
resolve "^1.22.10"
|
||||
resolve "^1.22.11"
|
||||
|
||||
"@babel/helper-globals@^7.28.0":
|
||||
version "7.28.0"
|
||||
@@ -138,7 +163,7 @@
|
||||
"@babel/traverse" "^7.28.6"
|
||||
"@babel/types" "^7.28.6"
|
||||
|
||||
"@babel/helper-module-transforms@^7.27.1", "@babel/helper-module-transforms@^7.28.3", "@babel/helper-module-transforms@^7.28.6":
|
||||
"@babel/helper-module-transforms@^7.27.1", "@babel/helper-module-transforms@^7.28.6":
|
||||
version "7.28.6"
|
||||
resolved "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz"
|
||||
integrity sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==
|
||||
@@ -224,6 +249,13 @@
|
||||
dependencies:
|
||||
"@babel/types" "^7.28.6"
|
||||
|
||||
"@babel/parser@^7.29.0":
|
||||
version "7.29.0"
|
||||
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.29.0.tgz#669ef345add7d057e92b7ed15f0bac07611831b6"
|
||||
integrity sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==
|
||||
dependencies:
|
||||
"@babel/types" "^7.29.0"
|
||||
|
||||
"@babel/plugin-bugfix-firefox-class-in-computed-class-key@^7.28.5":
|
||||
version "7.28.5"
|
||||
resolved "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.28.5.tgz"
|
||||
@@ -304,14 +336,14 @@
|
||||
dependencies:
|
||||
"@babel/helper-plugin-utils" "^7.27.1"
|
||||
|
||||
"@babel/plugin-transform-async-generator-functions@^7.28.6":
|
||||
version "7.28.6"
|
||||
resolved "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.28.6.tgz"
|
||||
integrity sha512-9knsChgsMzBV5Yh3kkhrZNxH3oCYAfMBkNNaVN4cP2RVlFPe8wYdwwcnOsAbkdDoV9UjFtOXWrWB52M8W4jNeA==
|
||||
"@babel/plugin-transform-async-generator-functions@^7.29.0":
|
||||
version "7.29.0"
|
||||
resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.0.tgz#63ed829820298f0bf143d5a4a68fb8c06ffd742f"
|
||||
integrity sha512-va0VdWro4zlBr2JsXC+ofCPB2iG12wPtVGTWFx2WLDOM3nYQZZIGP82qku2eW/JR83sD+k2k+CsNtyEbUqhU6w==
|
||||
dependencies:
|
||||
"@babel/helper-plugin-utils" "^7.28.6"
|
||||
"@babel/helper-remap-async-to-generator" "^7.27.1"
|
||||
"@babel/traverse" "^7.28.6"
|
||||
"@babel/traverse" "^7.29.0"
|
||||
|
||||
"@babel/plugin-transform-async-to-generator@^7.28.6":
|
||||
version "7.28.6"
|
||||
@@ -395,10 +427,10 @@
|
||||
dependencies:
|
||||
"@babel/helper-plugin-utils" "^7.27.1"
|
||||
|
||||
"@babel/plugin-transform-duplicate-named-capturing-groups-regex@^7.28.6":
|
||||
version "7.28.6"
|
||||
resolved "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.28.6.tgz"
|
||||
integrity sha512-5suVoXjC14lUN6ZL9OLKIHCNVWCrqGqlmEp/ixdXjvgnEl/kauLvvMO/Xw9NyMc95Joj1AeLVPVMvibBgSoFlA==
|
||||
"@babel/plugin-transform-duplicate-named-capturing-groups-regex@^7.29.0":
|
||||
version "7.29.0"
|
||||
resolved "https://registry.yarnpkg.com/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.29.0.tgz#8014b8a6cfd0e7b92762724443bf0d2400f26df1"
|
||||
integrity sha512-zBPcW2lFGxdiD8PUnPwJjag2J9otbcLQzvbiOzDxpYXyCuYX9agOwMPGn1prVH0a4qzhCKu24rlH4c1f7yA8rw==
|
||||
dependencies:
|
||||
"@babel/helper-create-regexp-features-plugin" "^7.28.5"
|
||||
"@babel/helper-plugin-utils" "^7.28.6"
|
||||
@@ -493,15 +525,15 @@
|
||||
"@babel/helper-module-transforms" "^7.28.6"
|
||||
"@babel/helper-plugin-utils" "^7.28.6"
|
||||
|
||||
"@babel/plugin-transform-modules-systemjs@^7.28.5":
|
||||
version "7.28.5"
|
||||
resolved "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.28.5.tgz"
|
||||
integrity sha512-vn5Jma98LCOeBy/KpeQhXcV2WZgaRUtjwQmjoBuLNlOmkg0fB5pdvYVeWRYI69wWKwK2cD1QbMiUQnoujWvrew==
|
||||
"@babel/plugin-transform-modules-systemjs@^7.29.0":
|
||||
version "7.29.0"
|
||||
resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.0.tgz#e458a95a17807c415924106a3ff188a3b8dee964"
|
||||
integrity sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ==
|
||||
dependencies:
|
||||
"@babel/helper-module-transforms" "^7.28.3"
|
||||
"@babel/helper-plugin-utils" "^7.27.1"
|
||||
"@babel/helper-module-transforms" "^7.28.6"
|
||||
"@babel/helper-plugin-utils" "^7.28.6"
|
||||
"@babel/helper-validator-identifier" "^7.28.5"
|
||||
"@babel/traverse" "^7.28.5"
|
||||
"@babel/traverse" "^7.29.0"
|
||||
|
||||
"@babel/plugin-transform-modules-umd@^7.27.1":
|
||||
version "7.27.1"
|
||||
@@ -511,13 +543,13 @@
|
||||
"@babel/helper-module-transforms" "^7.27.1"
|
||||
"@babel/helper-plugin-utils" "^7.27.1"
|
||||
|
||||
"@babel/plugin-transform-named-capturing-groups-regex@^7.27.1":
|
||||
version "7.27.1"
|
||||
resolved "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.27.1.tgz"
|
||||
integrity sha512-SstR5JYy8ddZvD6MhV0tM/j16Qds4mIpJTOd1Yu9J9pJjH93bxHECF7pgtc28XvkzTD6Pxcm/0Z73Hvk7kb3Ng==
|
||||
"@babel/plugin-transform-named-capturing-groups-regex@^7.29.0":
|
||||
version "7.29.0"
|
||||
resolved "https://registry.yarnpkg.com/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.0.tgz#a26cd51e09c4718588fc4cce1c5d1c0152102d6a"
|
||||
integrity sha512-1CZQA5KNAD6ZYQLPw7oi5ewtDNxH/2vuCh+6SmvgDfhumForvs8a1o9n0UrEoBD8HU4djO2yWngTQlXl1NDVEQ==
|
||||
dependencies:
|
||||
"@babel/helper-create-regexp-features-plugin" "^7.27.1"
|
||||
"@babel/helper-plugin-utils" "^7.27.1"
|
||||
"@babel/helper-create-regexp-features-plugin" "^7.28.5"
|
||||
"@babel/helper-plugin-utils" "^7.28.6"
|
||||
|
||||
"@babel/plugin-transform-new-target@^7.27.1":
|
||||
version "7.27.1"
|
||||
@@ -652,10 +684,10 @@
|
||||
"@babel/helper-annotate-as-pure" "^7.27.1"
|
||||
"@babel/helper-plugin-utils" "^7.27.1"
|
||||
|
||||
"@babel/plugin-transform-regenerator@^7.28.6":
|
||||
version "7.28.6"
|
||||
resolved "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.28.6.tgz"
|
||||
integrity sha512-eZhoEZHYQLL5uc1gS5e9/oTknS0sSSAtd5TkKMUp3J+S/CaUjagc0kOUPsEbDmMeva0nC3WWl4SxVY6+OBuxfw==
|
||||
"@babel/plugin-transform-regenerator@^7.29.0":
|
||||
version "7.29.0"
|
||||
resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.0.tgz#dec237cec1b93330876d6da9992c4abd42c9d18b"
|
||||
integrity sha512-FijqlqMA7DmRdg/aINBSs04y8XNTYw/lr1gJ2WsmBnnaNw1iS43EPkJW+zK7z65auG3AWRFXWj+NcTQwYptUog==
|
||||
dependencies:
|
||||
"@babel/helper-plugin-utils" "^7.28.6"
|
||||
|
||||
@@ -741,12 +773,12 @@
|
||||
"@babel/helper-create-regexp-features-plugin" "^7.28.5"
|
||||
"@babel/helper-plugin-utils" "^7.28.6"
|
||||
|
||||
"@babel/preset-env@7.28.6":
|
||||
version "7.28.6"
|
||||
resolved "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.28.6.tgz"
|
||||
integrity sha512-GaTI4nXDrs7l0qaJ6Rg06dtOXTBCG6TMDB44zbqofCIC4PqC7SEvmFFtpxzCDw9W5aJ7RKVshgXTLvLdBFV/qw==
|
||||
"@babel/preset-env@7.29.0":
|
||||
version "7.29.0"
|
||||
resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.29.0.tgz#c55db400c515a303662faaefd2d87e796efa08d0"
|
||||
integrity sha512-fNEdfc0yi16lt6IZo2Qxk3knHVdfMYX33czNb4v8yWhemoBhibCpQK/uYHtSKIiO+p/zd3+8fYVXhQdOVV608w==
|
||||
dependencies:
|
||||
"@babel/compat-data" "^7.28.6"
|
||||
"@babel/compat-data" "^7.29.0"
|
||||
"@babel/helper-compilation-targets" "^7.28.6"
|
||||
"@babel/helper-plugin-utils" "^7.28.6"
|
||||
"@babel/helper-validator-option" "^7.27.1"
|
||||
@@ -760,7 +792,7 @@
|
||||
"@babel/plugin-syntax-import-attributes" "^7.28.6"
|
||||
"@babel/plugin-syntax-unicode-sets-regex" "^7.18.6"
|
||||
"@babel/plugin-transform-arrow-functions" "^7.27.1"
|
||||
"@babel/plugin-transform-async-generator-functions" "^7.28.6"
|
||||
"@babel/plugin-transform-async-generator-functions" "^7.29.0"
|
||||
"@babel/plugin-transform-async-to-generator" "^7.28.6"
|
||||
"@babel/plugin-transform-block-scoped-functions" "^7.27.1"
|
||||
"@babel/plugin-transform-block-scoping" "^7.28.6"
|
||||
@@ -771,7 +803,7 @@
|
||||
"@babel/plugin-transform-destructuring" "^7.28.5"
|
||||
"@babel/plugin-transform-dotall-regex" "^7.28.6"
|
||||
"@babel/plugin-transform-duplicate-keys" "^7.27.1"
|
||||
"@babel/plugin-transform-duplicate-named-capturing-groups-regex" "^7.28.6"
|
||||
"@babel/plugin-transform-duplicate-named-capturing-groups-regex" "^7.29.0"
|
||||
"@babel/plugin-transform-dynamic-import" "^7.27.1"
|
||||
"@babel/plugin-transform-explicit-resource-management" "^7.28.6"
|
||||
"@babel/plugin-transform-exponentiation-operator" "^7.28.6"
|
||||
@@ -784,9 +816,9 @@
|
||||
"@babel/plugin-transform-member-expression-literals" "^7.27.1"
|
||||
"@babel/plugin-transform-modules-amd" "^7.27.1"
|
||||
"@babel/plugin-transform-modules-commonjs" "^7.28.6"
|
||||
"@babel/plugin-transform-modules-systemjs" "^7.28.5"
|
||||
"@babel/plugin-transform-modules-systemjs" "^7.29.0"
|
||||
"@babel/plugin-transform-modules-umd" "^7.27.1"
|
||||
"@babel/plugin-transform-named-capturing-groups-regex" "^7.27.1"
|
||||
"@babel/plugin-transform-named-capturing-groups-regex" "^7.29.0"
|
||||
"@babel/plugin-transform-new-target" "^7.27.1"
|
||||
"@babel/plugin-transform-nullish-coalescing-operator" "^7.28.6"
|
||||
"@babel/plugin-transform-numeric-separator" "^7.28.6"
|
||||
@@ -798,7 +830,7 @@
|
||||
"@babel/plugin-transform-private-methods" "^7.28.6"
|
||||
"@babel/plugin-transform-private-property-in-object" "^7.28.6"
|
||||
"@babel/plugin-transform-property-literals" "^7.27.1"
|
||||
"@babel/plugin-transform-regenerator" "^7.28.6"
|
||||
"@babel/plugin-transform-regenerator" "^7.29.0"
|
||||
"@babel/plugin-transform-regexp-modifiers" "^7.28.6"
|
||||
"@babel/plugin-transform-reserved-words" "^7.27.1"
|
||||
"@babel/plugin-transform-shorthand-properties" "^7.27.1"
|
||||
@@ -811,10 +843,10 @@
|
||||
"@babel/plugin-transform-unicode-regex" "^7.27.1"
|
||||
"@babel/plugin-transform-unicode-sets-regex" "^7.28.6"
|
||||
"@babel/preset-modules" "0.1.6-no-external-plugins"
|
||||
babel-plugin-polyfill-corejs2 "^0.4.14"
|
||||
babel-plugin-polyfill-corejs3 "^0.13.0"
|
||||
babel-plugin-polyfill-regenerator "^0.6.5"
|
||||
core-js-compat "^3.43.0"
|
||||
babel-plugin-polyfill-corejs2 "^0.4.15"
|
||||
babel-plugin-polyfill-corejs3 "^0.14.0"
|
||||
babel-plugin-polyfill-regenerator "^0.6.6"
|
||||
core-js-compat "^3.48.0"
|
||||
semver "^6.3.1"
|
||||
|
||||
"@babel/preset-modules@0.1.6-no-external-plugins":
|
||||
@@ -865,6 +897,19 @@
|
||||
"@babel/types" "^7.28.6"
|
||||
debug "^4.3.1"
|
||||
|
||||
"@babel/traverse@^7.29.0":
|
||||
version "7.29.0"
|
||||
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.29.0.tgz#f323d05001440253eead3c9c858adbe00b90310a"
|
||||
integrity sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==
|
||||
dependencies:
|
||||
"@babel/code-frame" "^7.29.0"
|
||||
"@babel/generator" "^7.29.0"
|
||||
"@babel/helper-globals" "^7.28.0"
|
||||
"@babel/parser" "^7.29.0"
|
||||
"@babel/template" "^7.28.6"
|
||||
"@babel/types" "^7.29.0"
|
||||
debug "^4.3.1"
|
||||
|
||||
"@babel/types@^7.0.0", "@babel/types@^7.20.7", "@babel/types@^7.27.1", "@babel/types@^7.27.3", "@babel/types@^7.28.2", "@babel/types@^7.28.5", "@babel/types@^7.28.6", "@babel/types@^7.4.4":
|
||||
version "7.28.6"
|
||||
resolved "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz"
|
||||
@@ -873,6 +918,14 @@
|
||||
"@babel/helper-string-parser" "^7.27.1"
|
||||
"@babel/helper-validator-identifier" "^7.28.5"
|
||||
|
||||
"@babel/types@^7.29.0":
|
||||
version "7.29.0"
|
||||
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.29.0.tgz#9f5b1e838c446e72cf3cd4b918152b8c605e37c7"
|
||||
integrity sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==
|
||||
dependencies:
|
||||
"@babel/helper-string-parser" "^7.27.1"
|
||||
"@babel/helper-validator-identifier" "^7.28.5"
|
||||
|
||||
"@dnd-kit/accessibility@^3.1.1":
|
||||
version "3.1.1"
|
||||
resolved "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz"
|
||||
@@ -1384,6 +1437,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"
|
||||
@@ -1404,16 +1462,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"
|
||||
|
||||
@@ -1460,10 +1518,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"
|
||||
@@ -1478,10 +1536,10 @@
|
||||
resolved "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz"
|
||||
integrity sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==
|
||||
|
||||
"@rolldown/pluginutils@1.0.0-beta.53":
|
||||
version "1.0.0-beta.53"
|
||||
resolved "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz"
|
||||
integrity sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==
|
||||
"@rolldown/pluginutils@1.0.0-rc.2":
|
||||
version "1.0.0-rc.2"
|
||||
resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz#10324e74cb3396cb7b616042ea7e9e6aa7d8d458"
|
||||
integrity sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==
|
||||
|
||||
"@rollup/rollup-android-arm-eabi@4.49.0":
|
||||
version "4.49.0"
|
||||
@@ -1746,13 +1804,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"
|
||||
@@ -1846,15 +1897,15 @@
|
||||
resolved "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz"
|
||||
integrity sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==
|
||||
|
||||
"@vitejs/plugin-react@5.1.2":
|
||||
version "5.1.2"
|
||||
resolved "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz"
|
||||
integrity sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==
|
||||
"@vitejs/plugin-react@5.1.3":
|
||||
version "5.1.3"
|
||||
resolved "https://registry.yarnpkg.com/@vitejs/plugin-react/-/plugin-react-5.1.3.tgz#05eba092cc89a6ff89668d3f62258e2c5a8a9122"
|
||||
integrity sha512-NVUnA6gQCl8jfoYqKqQU5Clv0aPw14KkZYCsX6T9Lfu9slI0LOU10OTwFHS/WmptsMMpshNd/1tuWsHQ2Uk+cg==
|
||||
dependencies:
|
||||
"@babel/core" "^7.28.5"
|
||||
"@babel/core" "^7.29.0"
|
||||
"@babel/plugin-transform-react-jsx-self" "^7.27.1"
|
||||
"@babel/plugin-transform-react-jsx-source" "^7.27.1"
|
||||
"@rolldown/pluginutils" "1.0.0-beta.53"
|
||||
"@rolldown/pluginutils" "1.0.0-rc.2"
|
||||
"@types/babel__core" "^7.20.5"
|
||||
react-refresh "^0.18.0"
|
||||
|
||||
@@ -2070,29 +2121,29 @@ b4a@^1.6.4:
|
||||
resolved "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz"
|
||||
integrity sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==
|
||||
|
||||
babel-plugin-polyfill-corejs2@^0.4.14:
|
||||
version "0.4.14"
|
||||
resolved "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.14.tgz"
|
||||
integrity sha512-Co2Y9wX854ts6U8gAAPXfn0GmAyctHuK8n0Yhfjd6t30g7yvKjspvvOo9yG+z52PZRgFErt7Ka2pYnXCjLKEpg==
|
||||
babel-plugin-polyfill-corejs2@^0.4.15:
|
||||
version "0.4.15"
|
||||
resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.15.tgz#808fa349686eea4741807cfaaa2aa3aa57ce120a"
|
||||
integrity sha512-hR3GwrRwHUfYwGfrisXPIDP3JcYfBrW7wKE7+Au6wDYl7fm/ka1NEII6kORzxNU556JjfidZeBsO10kYvtV1aw==
|
||||
dependencies:
|
||||
"@babel/compat-data" "^7.27.7"
|
||||
"@babel/helper-define-polyfill-provider" "^0.6.5"
|
||||
"@babel/compat-data" "^7.28.6"
|
||||
"@babel/helper-define-polyfill-provider" "^0.6.6"
|
||||
semver "^6.3.1"
|
||||
|
||||
babel-plugin-polyfill-corejs3@^0.13.0:
|
||||
version "0.13.0"
|
||||
resolved "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz"
|
||||
integrity sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==
|
||||
babel-plugin-polyfill-corejs3@^0.14.0:
|
||||
version "0.14.0"
|
||||
resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.0.tgz#65b06cda48d6e447e1e926681f5a247c6ae2b9cf"
|
||||
integrity sha512-AvDcMxJ34W4Wgy4KBIIePQTAOP1Ie2WFwkQp3dB7FQ/f0lI5+nM96zUnYEOE1P9sEg0es5VCP0HxiWu5fUHZAQ==
|
||||
dependencies:
|
||||
"@babel/helper-define-polyfill-provider" "^0.6.5"
|
||||
core-js-compat "^3.43.0"
|
||||
"@babel/helper-define-polyfill-provider" "^0.6.6"
|
||||
core-js-compat "^3.48.0"
|
||||
|
||||
babel-plugin-polyfill-regenerator@^0.6.5:
|
||||
version "0.6.5"
|
||||
resolved "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.5.tgz"
|
||||
integrity sha512-ISqQ2frbiNU9vIJkzg7dlPpznPZ4jOiUQ1uSmB0fEHeowtN3COYRsXr/xexn64NpU13P06jc/L5TgiJXOgrbEg==
|
||||
babel-plugin-polyfill-regenerator@^0.6.6:
|
||||
version "0.6.6"
|
||||
resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.6.tgz#69f5dd263cab933c42fe5ea05e83443b374bd4bf"
|
||||
integrity sha512-hYm+XLYRMvupxiQzrvXUj7YyvFFVfv5gI0R71AJzudg1g2AI2vyCPPIFEBjk162/wFzti3inBHo7isWFuEVS/A==
|
||||
dependencies:
|
||||
"@babel/helper-define-polyfill-provider" "^0.6.5"
|
||||
"@babel/helper-define-polyfill-provider" "^0.6.6"
|
||||
|
||||
bail@^2.0.0:
|
||||
version "2.0.2"
|
||||
@@ -2151,6 +2202,11 @@ base64-js@^1.3.1:
|
||||
resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz"
|
||||
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
|
||||
|
||||
baseline-browser-mapping@^2.9.0:
|
||||
version "2.9.19"
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz#3e508c43c46d961eb4d7d2e5b8d1dd0f9ee4f488"
|
||||
integrity sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==
|
||||
|
||||
basic-ftp@^5.0.2:
|
||||
version "5.0.5"
|
||||
resolved "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.0.5.tgz"
|
||||
@@ -2242,7 +2298,7 @@ browser-stdout@^1.3.1:
|
||||
resolved "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz"
|
||||
integrity sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==
|
||||
|
||||
browserslist@^4.24.0, browserslist@^4.25.3:
|
||||
browserslist@^4.24.0:
|
||||
version "4.25.3"
|
||||
resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.25.3.tgz"
|
||||
integrity sha512-cDGv1kkDI4/0e5yON9yM5G/0A5u8sf5TnmdX5C9qHzI9PPu++sQ9zjm1k9NiOrf3riY4OkK0zSGqfvJyJsgCBQ==
|
||||
@@ -2252,6 +2308,17 @@ browserslist@^4.24.0, browserslist@^4.25.3:
|
||||
node-releases "^2.0.19"
|
||||
update-browserslist-db "^1.1.3"
|
||||
|
||||
browserslist@^4.28.1:
|
||||
version "4.28.1"
|
||||
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.28.1.tgz#7f534594628c53c63101079e27e40de490456a95"
|
||||
integrity sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==
|
||||
dependencies:
|
||||
baseline-browser-mapping "^2.9.0"
|
||||
caniuse-lite "^1.0.30001759"
|
||||
electron-to-chromium "^1.5.263"
|
||||
node-releases "^2.0.27"
|
||||
update-browserslist-db "^1.2.0"
|
||||
|
||||
buffer-crc32@~0.2.3:
|
||||
version "0.2.13"
|
||||
resolved "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz"
|
||||
@@ -2311,6 +2378,11 @@ caniuse-lite@^1.0.30001735:
|
||||
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001737.tgz"
|
||||
integrity sha512-BiloLiXtQNrY5UyF0+1nSJLXUENuhka2pzy2Fx5pGxqavdrxSCW4U6Pn/PoG3Efspi2frRbHpBV2XsrPE6EDlw==
|
||||
|
||||
caniuse-lite@^1.0.30001759:
|
||||
version "1.0.30001767"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001767.tgz#0279c498e862efb067938bba0a0aabafe8d0b730"
|
||||
integrity sha512-34+zUAMhSH+r+9eKmYG+k2Rpt8XttfE4yXAjoZvkAPs15xcYQhyBYdalJ65BzivAvGRMViEjy6oKr/S91loekQ==
|
||||
|
||||
ccount@^2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz"
|
||||
@@ -2564,12 +2636,12 @@ copy-text-to-clipboard@^2.1.1:
|
||||
resolved "https://registry.npmjs.org/copy-text-to-clipboard/-/copy-text-to-clipboard-2.2.0.tgz"
|
||||
integrity sha512-WRvoIdnTs1rgPMkgA2pUOa/M4Enh2uzCwdKsOMYNAJiz/4ZvEJgmbF4OmninPmlFdAWisfeh0tH+Cpf7ni3RqQ==
|
||||
|
||||
core-js-compat@^3.43.0:
|
||||
version "3.45.1"
|
||||
resolved "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.45.1.tgz"
|
||||
integrity sha512-tqTt5T4PzsMIZ430XGviK4vzYSoeNJ6CXODi6c/voxOT6IZqBht5/EKaSNnYiEjjRYxjVz7DQIsOsY0XNi8PIA==
|
||||
core-js-compat@^3.48.0:
|
||||
version "3.48.0"
|
||||
resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.48.0.tgz#7efbe1fc1cbad44008190462217cc5558adaeaa6"
|
||||
integrity sha512-OM4cAF3D6VtH/WkLtWvyNC56EZVXsZdU3iqaMG2B4WvYrlqU831pc4UtG5yp0sE9z8Y02wVN7PjW5Zf9Gt0f1Q==
|
||||
dependencies:
|
||||
browserslist "^4.25.3"
|
||||
browserslist "^4.28.1"
|
||||
|
||||
core-js@^3.22.4:
|
||||
version "3.47.0"
|
||||
@@ -2672,7 +2744,7 @@ debug@3.2.7:
|
||||
dependencies:
|
||||
ms "^2.1.1"
|
||||
|
||||
debug@4, debug@^4, debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.5, debug@^4.4.1, debug@^4.4.3:
|
||||
debug@4, debug@^4, debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.5, debug@^4.4.3:
|
||||
version "4.4.3"
|
||||
resolved "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz"
|
||||
integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==
|
||||
@@ -2853,6 +2925,11 @@ electron-to-chromium@^1.5.204:
|
||||
resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.211.tgz"
|
||||
integrity sha512-IGBvimJkotaLzFnwIVgW9/UD/AOJ2tByUmeOrtqBfACSbAw5b1G0XpvdaieKyc7ULmbwXVx+4e4Be8pOPBrYkw==
|
||||
|
||||
electron-to-chromium@^1.5.263:
|
||||
version "1.5.283"
|
||||
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.283.tgz#51d492c37c2d845a0dccb113fe594880c8616de8"
|
||||
integrity sha512-3vifjt1HgrGW/h76UEeny+adYApveS9dH2h3p57JYzBSXJIKUJAvtmIytDKjcSCt9xHfrNCFJ7gts6vkhuq++w==
|
||||
|
||||
emoji-regex@^10.3.0:
|
||||
version "10.5.0"
|
||||
resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.5.0.tgz"
|
||||
@@ -3591,11 +3668,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"
|
||||
@@ -4070,7 +4142,7 @@ is-callable@^1.2.7:
|
||||
resolved "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz"
|
||||
integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==
|
||||
|
||||
is-core-module@^2.13.0, is-core-module@^2.16.0:
|
||||
is-core-module@^2.13.0, is-core-module@^2.16.0, is-core-module@^2.16.1:
|
||||
version "2.16.1"
|
||||
resolved "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz"
|
||||
integrity sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==
|
||||
@@ -4578,10 +4650,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"
|
||||
@@ -4590,14 +4662,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"
|
||||
@@ -5425,6 +5496,11 @@ node-releases@^2.0.19:
|
||||
resolved "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz"
|
||||
integrity sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==
|
||||
|
||||
node-releases@^2.0.27:
|
||||
version "2.0.27"
|
||||
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.27.tgz#eedca519205cf20f650f61d56b070db111231e4e"
|
||||
integrity sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==
|
||||
|
||||
nodemon@^3.1.11:
|
||||
version "3.1.11"
|
||||
resolved "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz"
|
||||
@@ -6019,12 +6095,12 @@ punycode@^2.1.0:
|
||||
resolved "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz"
|
||||
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
|
||||
|
||||
puppeteer-core@24.36.0:
|
||||
version "24.36.0"
|
||||
resolved "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.36.0.tgz"
|
||||
integrity sha512-P3Ou0MAFDCQ0dK1d9F9+8jTrg6JvXjUacgG0YkJQP4kbEnUOGokSDEMmMId5ZhXD5HwsHM202E9VwEpEjWfwxg==
|
||||
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"
|
||||
"@puppeteer/browsers" "2.11.2"
|
||||
chromium-bidi "13.0.1"
|
||||
debug "^4.4.3"
|
||||
devtools-protocol "0.0.1551306"
|
||||
@@ -6079,16 +6155,16 @@ puppeteer-extra@^3.3.6:
|
||||
debug "^4.1.1"
|
||||
deepmerge "^4.2.2"
|
||||
|
||||
puppeteer@^24.36.0:
|
||||
version "24.36.0"
|
||||
resolved "https://registry.npmjs.org/puppeteer/-/puppeteer-24.36.0.tgz"
|
||||
integrity sha512-BD/VCyV/Uezvd6o7Fd1DmEJSxTzofAKplzDy6T9d4WbLTQ5A+06zY7VwO91ZlNU22vYE8sidVEsTpTrKc+EEnQ==
|
||||
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"
|
||||
"@puppeteer/browsers" "2.11.2"
|
||||
chromium-bidi "13.0.1"
|
||||
cosmiconfig "^9.0.0"
|
||||
devtools-protocol "0.0.1551306"
|
||||
puppeteer-core "24.36.0"
|
||||
puppeteer-core "24.36.1"
|
||||
typed-query-selector "^2.12.0"
|
||||
|
||||
qs@^6.14.1:
|
||||
@@ -6149,10 +6225,10 @@ 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@19.2.3:
|
||||
version "19.2.3"
|
||||
resolved "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz"
|
||||
integrity sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==
|
||||
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:
|
||||
scheduler "^0.27.0"
|
||||
|
||||
@@ -6213,10 +6289,10 @@ react-window@^1.8.2:
|
||||
"@babel/runtime" "^7.0.0"
|
||||
memoize-one ">=3.1.1 <6"
|
||||
|
||||
react@19.2.3:
|
||||
version "19.2.3"
|
||||
resolved "https://registry.npmjs.org/react/-/react-19.2.3.tgz"
|
||||
integrity sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==
|
||||
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"
|
||||
@@ -6429,7 +6505,7 @@ resolve-protobuf-schema@^2.1.0:
|
||||
dependencies:
|
||||
protocol-buffers-schema "^3.3.1"
|
||||
|
||||
resolve@^1.1.6, resolve@^1.22.10:
|
||||
resolve@^1.1.6:
|
||||
version "1.22.10"
|
||||
resolved "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz"
|
||||
integrity sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==
|
||||
@@ -6438,6 +6514,15 @@ resolve@^1.1.6, resolve@^1.22.10:
|
||||
path-parse "^1.0.7"
|
||||
supports-preserve-symlinks-flag "^1.0.0"
|
||||
|
||||
resolve@^1.22.11:
|
||||
version "1.22.11"
|
||||
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.11.tgz#aad857ce1ffb8bfa9b0b1ac29f1156383f68c262"
|
||||
integrity sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==
|
||||
dependencies:
|
||||
is-core-module "^2.16.1"
|
||||
path-parse "^1.0.7"
|
||||
supports-preserve-symlinks-flag "^1.0.0"
|
||||
|
||||
resolve@^2.0.0-next.5:
|
||||
version "2.0.0-next.5"
|
||||
resolved "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz"
|
||||
@@ -7367,6 +7452,14 @@ update-browserslist-db@^1.1.3:
|
||||
escalade "^3.2.0"
|
||||
picocolors "^1.1.1"
|
||||
|
||||
update-browserslist-db@^1.2.0:
|
||||
version "1.2.3"
|
||||
resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz#64d76db58713136acbeb4c49114366cc6cc2e80d"
|
||||
integrity sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==
|
||||
dependencies:
|
||||
escalade "^3.2.0"
|
||||
picocolors "^1.1.1"
|
||||
|
||||
uri-js@^4.2.2:
|
||||
version "4.4.1"
|
||||
resolved "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz"
|
||||
@@ -7641,10 +7734,10 @@ zod@^3.24.1:
|
||||
resolved "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz"
|
||||
integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==
|
||||
|
||||
zustand@^5.0.10:
|
||||
version "5.0.10"
|
||||
resolved "https://registry.npmjs.org/zustand/-/zustand-5.0.10.tgz"
|
||||
integrity sha512-U1AiltS1O9hSy3rul+Ub82ut2fqIAefiSuwECWt6jlMVUGejvf+5omLcRBSzqbRagSM3hQZbtzdeRc6QVScXTg==
|
||||
zustand@^5.0.11:
|
||||
version "5.0.11"
|
||||
resolved "https://registry.yarnpkg.com/zustand/-/zustand-5.0.11.tgz#99f912e590de1ca9ce6c6d1cab6cdb1f034ab494"
|
||||
integrity sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==
|
||||
|
||||
zwitch@^2.0.0:
|
||||
version "2.0.4"
|
||||
|
||||
Reference in New Issue
Block a user