Compare commits

..

11 Commits

Author SHA1 Message Date
orangecoding
78b762bd9e fixing analytics popup 2025-12-09 14:57:29 +01:00
Christian Kellner
3e5cd97400 Listing management (#223)
* upgrading dependencies, fixing image placeholder

* improving processing times label and hide when screen width is too low

* aligning run now button

* renaming settings -> general settings

* smaller security and memory improvements

* improving footer

* preparing listing management

* improve filtering for listings

* preparing new settings page

* preparing new settings page

* storing settings in db

* next release version
2025-12-09 13:56:46 +01:00
orangecoding
5cfa674d7f adding unraid logo 2025-12-09 09:17:21 +01:00
orangecoding
5bd4219743 upgrading dependencies | adding ohneMakler provider 2025-12-08 20:31:28 +01:00
orangecoding
ea24eb4374 upgrading dependencies 2025-12-04 09:58:58 +01:00
orangecoding
9f67e30ff4 upgrade version 2025-11-27 16:09:44 +01:00
orangecoding
20d44b60ad upgrading dependencies 2025-11-27 15:54:54 +01:00
orangecoding
22df683969 more efficient bot protection 2025-11-27 10:30:47 +01:00
Robin Fuchs
4aab850b4f feat: updated the UI to enable editing of provider URLs (#234)
* feat: updated the UI to enable editing of provider URLs


---------

Co-authored-by: foxx-tech <robin.foxx.tech@gmail.com>
2025-11-26 17:10:42 +01:00
Efe
3eb3f6ee66 fix: notification adapter modal improvements (#230)
* fix: fix notification modal
2025-11-18 12:24:27 +01:00
Efe
1b2fc79536 feat: add http adapter (#231)
* feat: add http adapter
2025-11-18 12:23:50 +01:00
45 changed files with 1631 additions and 644 deletions

View File

@@ -19,4 +19,4 @@ jobs:
cache: 'yarn'
- run: yarn install
- run: yarn test
- run: yarn testGH

View File

@@ -1 +1 @@
{"interval":"60","port":9998,"workingHours":{"from":"","to":""},"demoMode":false,"analyticsEnabled":true,"sqlitepath":"/db"}
{"sqlitepath":"/db"}

BIN
doc/unraid_fredy_logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 417 KiB

View File

@@ -1,6 +1,6 @@
import fs from 'fs';
import path from 'path';
import { checkIfConfigIsAccessible, config, getProviders, refreshConfig } from './lib/utils.js';
import { checkIfConfigIsAccessible, getProviders, refreshConfig } from './lib/utils.js';
import * as similarityCache from './lib/services/similarity-check/similarityCache.js';
import * as jobStorage from './lib/services/storage/jobStorage.js';
import FredyPipeline from './lib/FredyPipeline.js';
@@ -12,28 +12,34 @@ import { initTrackerCron } from './lib/services/crons/tracker-cron.js';
import logger from './lib/services/logger.js';
import { bus } from './lib/services/events/event-bus.js';
import { initActiveCheckerCron } from './lib/services/crons/listing-alive-cron.js';
import { getSettings } from './lib/services/storage/settingsStorage.js';
import SqliteConnection from './lib/services/storage/SqliteConnection.js';
//in the config, we store the path of the sqlite file, thus we must check if it is available
const isConfigAccessible = await checkIfConfigIsAccessible();
await SqliteConnection.init();
// Load configuration before any other startup steps
await refreshConfig();
const isConfigAccessible = await checkIfConfigIsAccessible();
if (!isConfigAccessible) {
logger.error('Configuration exists, but is not accessible. Please check the file permission');
process.exit(1);
}
// Run DB migrations once at startup and block until finished
await runMigrations();
const settings = await getSettings();
// Ensure sqlite directory exists before loading anything else (based on config.sqlitepath)
const rawDir = config.sqlitepath || '/db';
const rawDir = settings.sqlitepath || '/db';
const relDir = rawDir.startsWith('/') ? rawDir.slice(1) : rawDir;
const absDir = path.isAbsolute(relDir) ? relDir : path.join(process.cwd(), relDir);
if (!fs.existsSync(absDir)) {
fs.mkdirSync(absDir, { recursive: true });
}
// Run DB migrations once at startup and block until finished
await runMigrations();
// Load provider modules once at startup
const providers = await getProviders();
@@ -41,17 +47,17 @@ similarityCache.initSimilarityCache();
similarityCache.startSimilarityCacheReloader();
//assuming interval is always in minutes
const INTERVAL = config.interval * 60 * 1000;
const INTERVAL = settings.interval * 60 * 1000;
// Initialize API only after migrations completed
await import('./lib/api/api.js');
if (config.demoMode) {
if (settings.demoMode) {
logger.info('Running in demo mode');
cleanupDemoAtMidnight();
}
logger.info(`Started Fredy successfully. Ui can be accessed via http://localhost:${config.port}`);
logger.info(`Started Fredy successfully. Ui can be accessed via http://localhost:${settings.port}`);
ensureAdminUserExists();
ensureDemoUserExists();
@@ -65,10 +71,10 @@ bus.on('jobs:runAll', () => {
});
const execute = () => {
const isDuringWorkingHoursOrNotSet = duringWorkingHoursOrNotSet(config, Date.now());
if (!config.demoMode) {
const isDuringWorkingHoursOrNotSet = duringWorkingHoursOrNotSet(settings, Date.now());
if (!settings.demoMode) {
if (isDuringWorkingHoursOrNotSet) {
config.lastRun = Date.now();
settings.lastRun = Date.now();
jobStorage
.getJobs()
.filter((job) => job.enabled)

View File

@@ -7,7 +7,6 @@ import { versionRouter } from './routes/versionRouter.js';
import { loginRouter } from './routes/loginRoute.js';
import { userRouter } from './routes/userRoute.js';
import { jobRouter } from './routes/jobRouter.js';
import { config } from '../utils.js';
import bodyParser from 'body-parser';
import restana from 'restana';
import files from 'serve-static';
@@ -16,9 +15,11 @@ import { getDirName } from '../utils.js';
import { demoRouter } from './routes/demoRouter.js';
import logger from '../services/logger.js';
import { listingsRouter } from './routes/listingsRouter.js';
import { getSettings } from '../services/storage/settingsStorage.js';
import { featureRouter } from './routes/featureRouter.js';
const service = restana();
const staticService = files(path.join(getDirName(), '../ui/public'));
const PORT = config.port || 9998;
const PORT = (await getSettings()).port || 9998;
service.use(bodyParser.json());
service.use(cookieSession());
@@ -39,6 +40,7 @@ service.use('/api/version', versionRouter);
service.use('/api/jobs', jobRouter);
service.use('/api/login', loginRouter);
service.use('/api/listings', listingsRouter);
service.use('/api/features', featureRouter);
//this route is unsecured intentionally as it is being queried from the login page
service.use('/api/demo', demoRouter);

View File

@@ -1,10 +1,11 @@
import restana from 'restana';
import { config } from '../../utils.js';
import { getSettings } from '../../services/storage/settingsStorage.js';
const service = restana();
const demoRouter = service.newRouter();
demoRouter.get('/', async (req, res) => {
res.body = Object.assign({}, { demoMode: config.demoMode });
const settings = await getSettings();
res.body = Object.assign({}, { demoMode: settings.demoMode });
res.send();
});

View File

@@ -0,0 +1,12 @@
import restana from 'restana';
import getFeatures from '../../features.js';
const service = restana();
const featureRouter = service.newRouter();
featureRouter.get('/', async (req, res) => {
const features = getFeatures();
res.body = Object.assign({}, { features });
res.send();
});
export { featureRouter };

View File

@@ -1,24 +1,30 @@
import restana from 'restana';
import { config, getDirName, readConfigFromStorage, refreshConfig } from '../../utils.js';
import { getDirName } from '../../utils.js';
import fs from 'fs';
import { ensureDemoUserExists } from '../../services/storage/userStorage.js';
import logger from '../../services/logger.js';
import { getSettings, upsertSettings } from '../../services/storage/settingsStorage.js';
const service = restana();
const generalSettingsRouter = service.newRouter();
generalSettingsRouter.get('/', async (req, res) => {
res.body = Object.assign({}, config);
res.body = Object.assign({}, await getSettings());
res.send();
});
generalSettingsRouter.post('/', async (req, res) => {
const settings = req.body;
const { sqlitepath, ...appSettings } = req.body || {};
const localSettings = await getSettings();
if (localSettings.demoMode) {
res.send(new Error('In demo mode, it is not allowed to change these settings.'));
return;
}
try {
if (config.demoMode) {
res.send(new Error('In demo mode, it is not allowed to change these settings.'));
return;
if (typeof sqlitepath !== 'undefined') {
fs.writeFileSync(`${getDirName()}/../conf/config.json`, JSON.stringify({ sqlitepath }));
}
const currentConfig = await readConfigFromStorage();
fs.writeFileSync(`${getDirName()}/../conf/config.json`, JSON.stringify({ ...currentConfig, ...settings }));
await refreshConfig();
upsertSettings(appSettings);
ensureDemoUserExists();
} catch (err) {
logger.error(err);

View File

@@ -1,10 +1,10 @@
import restana from 'restana';
import * as jobStorage from '../../services/storage/jobStorage.js';
import * as userStorage from '../../services/storage/userStorage.js';
import { config } from '../../utils.js';
import { isAdmin } from '../security.js';
import logger from '../../services/logger.js';
import { bus } from '../../services/events/event-bus.js';
import { getSettings } from '../../services/storage/settingsStorage.js';
const service = restana();
const jobRouter = service.newRouter();
@@ -44,9 +44,10 @@ jobRouter.get('/', async (req, res) => {
});
jobRouter.get('/processingTimes', async (req, res) => {
const settings = await getSettings();
res.body = {
interval: config.interval,
lastRun: config.lastRun || null,
interval: settings.interval,
lastRun: settings.lastRun || null,
};
res.send();
});

View File

@@ -1,9 +1,9 @@
import restana from 'restana';
import * as userStorage from '../../services/storage/userStorage.js';
import * as hasher from '../../services/security/hash.js';
import { config } from '../../utils.js';
import { trackDemoAccessed } from '../../services/tracking/Tracker.js';
import logger from '../../services/logger.js';
import { getSettings } from '../../services/storage/settingsStorage.js';
const service = restana();
const loginRouter = service.newRouter();
loginRouter.get('/user', async (req, res) => {
@@ -20,6 +20,7 @@ loginRouter.get('/user', async (req, res) => {
res.send();
});
loginRouter.post('/', async (req, res) => {
const settings = await getSettings();
const { username, password } = req.body;
const user = userStorage.getUsers(true).find((user) => user.username === username);
if (user == null) {
@@ -27,7 +28,7 @@ loginRouter.post('/', async (req, res) => {
return;
}
if (user.password === hasher.hash(password)) {
if (config.demoMode) {
if (settings.demoMode) {
await trackDemoAccessed();
}

View File

@@ -1,7 +1,7 @@
import restana from 'restana';
import * as userStorage from '../../services/storage/userStorage.js';
import * as jobStorage from '../../services/storage/jobStorage.js';
import { config } from '../../utils.js';
import { getSettings } from '../../services/storage/settingsStorage.js';
const service = restana();
const userRouter = service.newRouter();
function checkIfAnyAdminAfterRemovingUser(userIdToBeRemoved, allUser) {
@@ -23,7 +23,8 @@ userRouter.get('/:userId', async (req, res) => {
res.send();
});
userRouter.delete('/', async (req, res) => {
if (config.demoMode) {
const settings = await getSettings();
if (settings.demoMode) {
res.send(new Error('In demo mode, it is not allowed to remove user.'));
return;
}
@@ -44,7 +45,8 @@ userRouter.delete('/', async (req, res) => {
res.send();
});
userRouter.post('/', async (req, res) => {
if (config.demoMode) {
const settings = await getSettings();
if (settings.demoMode) {
res.send(new Error('In demo mode, it is not allowed to change or add user.'));
return;
}

View File

@@ -3,7 +3,7 @@ export const DEFAULT_CONFIG = {
port: 9998,
workingHours: { from: '', to: '' },
demoMode: false,
analyticsEnabled: null,
analyticsEnabled: true,
// Default path for sqlite storage directory. Interpreted relative to project root.
sqlitepath: '/db',
};

9
lib/features.js Normal file
View File

@@ -0,0 +1,9 @@
const FEATURES = {
WATCHLIST_MANAGEMENT: false,
};
export default function getFeatures() {
return {
...FEATURES,
};
}

View File

@@ -0,0 +1,57 @@
import { markdown2Html } from '../../services/markdown.js';
const mapListing = (listing) => ({
address: listing.address,
description: listing.description,
id: listing.id,
imageUrl: listing.image,
price: listing.price,
size: listing.size,
title: listing.title,
url: listing.link,
});
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
const { authToken, endpointUrl } = notificationConfig.find((a) => a.id === config.id).fields;
const listings = newListings.map(mapListing);
const body = {
jobId: jobKey,
timestamp: new Date().toISOString(),
provider: serviceName,
listings,
};
const headers = {
'Content-Type': 'application/json',
};
if (authToken != null) {
headers['Authorization'] = `Bearer ${authToken}`;
}
return fetch(endpointUrl, {
method: 'POST',
headers: headers,
body: JSON.stringify(body),
});
};
export const config = {
id: 'http',
name: 'HTTP',
readme: markdown2Html('lib/notification/adapter/http.md'),
description: 'Fredy will send a generic HTTP POST request.',
fields: {
endpointUrl: {
description: "Your application's endpoint URL.",
label: 'Endpoint URL',
type: 'text',
},
authToken: {
description: "Your application's auth token, if required by your endpoint.",
label: 'Auth token (optional)',
optional: true,
type: 'text',
},
},
};

View File

@@ -0,0 +1,43 @@
### HTTP Adapter
This is a generic adapter for sending notifications via HTTP requests.
You can leverage this adapter to integrate with various webhooks or APIs that accept HTTP requests. (e.g. Supabase
Functions, a Node.js server, etc.)
HTTP adapter supports a `authToken` field, which can be used to include an authorization token in the request headers.
Your token would be included as a Bearer token in the `Authorization` header, which is a common method for securing API requests.
Request Details:
<details>
Request Method: POST
Headers:
```
Content Type: `application/json`
Authorization: Bearer {your-optional-auth-token}
```
Body:
```json
{
"jobId": "mg1waX4RHmIzL5NDYtYp-",
"provider": "immoscout",
"timestamp": "2024-06-15T12:34:56Z",
"listings": [
{
"address": "Str. 123, Bielefeld, Germany",
"description": "Möbliert: Einziehen & wohlfühlen: Neu möbliert.",
"id": "123456789",
"imageUrl": "https://<target-url>.com/listings/123456789.jpg",
"price": "1.240 €",
"size": "38 m²",
"title": "Schöne 1-Zimmer-Wohnung in Bielefeld",
"url": "https://<target-url>.com/listings/123456789"
}
]
}
```
</details>

45
lib/provider/ohneMakler.js Executable file
View File

@@ -0,0 +1,45 @@
import { isOneOf, buildHash } from '../utils.js';
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
let appliedBlackList = [];
function normalize(o) {
const link = metaInformation.baseUrl + o.link;
const id = buildHash(o.title, o.link, o.price);
return Object.assign(o, { link, id });
}
function applyBlacklist(o) {
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
return titleNotBlacklisted && descNotBlacklisted;
}
const config = {
url: null,
crawlContainer: 'div[data-livecomponent-id*="search/property_list"] .grid > div',
sortByDateParam: null,
waitForSelector: null,
crawlFields: {
id: 'a@href',
title: 'h4 | removeNewline | trim',
price: '.text-xl | trim',
size: 'div[title="Wohnfläche"] | trim',
address: '.text-slate-800 | removeNewline | trim',
image: 'img@src',
link: 'a@href',
},
normalize: normalize,
filter: applyBlacklist,
activeTester: checkIfListingIsActive,
};
export const init = (sourceConfig, blacklist) => {
config.enabled = sourceConfig.enabled;
config.url = sourceConfig.url;
appliedBlackList = blacklist || [];
};
export const metaInformation = {
name: 'OhneMakler',
baseUrl: 'https://www.ohne-makler.net/immobilien',
id: 'ohneMakler',
};
export { config };

View File

@@ -1,8 +1,8 @@
import { removeJobsByUserId } from '../storage/jobStorage.js';
import { config } from '../../utils.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)
@@ -11,12 +11,13 @@ export function cleanupDemoAtMidnight() {
cron.schedule('0 0 * * *', cleanup);
}
function cleanup() {
if (config.demoMode) {
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;
return Promise.resolve();
}
removeJobsByUserId(demoUser.id);
}

View File

@@ -1,10 +1,12 @@
import cron from 'node-cron';
import { config, inDevMode } from '../../utils.js';
import { inDevMode } from '../../utils.js';
import { trackMainEvent } from '../tracking/Tracker.js';
import { getSettings } from '../storage/settingsStorage.js';
async function runTask() {
const settings = await getSettings();
//make sure to only send tracking events if the user gave us the green light and we are not in dev mode
if (config.analyticsEnabled && !inDevMode()) {
if (settings.analyticsEnabled && !inDevMode()) {
await trackMainEvent();
}
}

View File

@@ -0,0 +1,274 @@
import { DEFAULT_HEADER } from './utils.js';
// Helper to safely coerce numbers
const toInt = (v, d) => {
const n = parseInt(v, 10);
return Number.isFinite(n) ? n : d;
};
/**
* Compute pre-launch configuration and flags for Puppeteer with bot prevention in mind.
* Returns language, user agent, viewport (with optional jitter), and additional launch args.
*
* @param {string} url
* @param {object} [options]
*/
export function getPreLaunchConfig(url, options = {}) {
const { hostname } = new URL(url);
const acceptLanguage = options.acceptLanguage || 'de-DE,de;q=0.9,en-US;q=0.7,en;q=0.5';
const langForFlag = acceptLanguage.split(',')[0];
const baseViewport = { width: 1366, height: 768, deviceScaleFactor: 1 };
const jitter = options.viewportJitter !== false ? Math.floor(Math.random() * 6) : 0; // 0..5 px
const width = toInt(options?.viewport?.width, baseViewport.width) + jitter;
const height = toInt(options?.viewport?.height, baseViewport.height) + jitter;
const deviceScaleFactor = toInt(options?.viewport?.deviceScaleFactor, baseViewport.deviceScaleFactor);
const viewport = { width, height, deviceScaleFactor };
const userAgent =
options.userAgent ||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36';
const windowSizeArg = `--window-size=${viewport.width},${viewport.height}`;
const langArg = `--lang=${langForFlag}`;
const extraArgs = [
'--disable-blink-features=AutomationControlled',
'--force-webrtc-ip-handling-policy=disable_non_proxied_udp',
'--webrtc-ip-handling-policy=default_public_interface_only',
'--proxy-bypass-list=<-loopback>',
];
const headers = {
...DEFAULT_HEADER,
'Accept-Language': acceptLanguage,
'User-Agent': userAgent,
Referer: options?.referer || `https://${hostname}/`,
Connection: 'keep-alive',
DNT: '1',
};
const timezone = options?.timezone || 'Europe/Berlin';
return {
acceptLanguage,
langForFlag,
userAgent,
viewport,
windowSizeArg,
langArg,
extraArgs,
headers,
timezone,
humanDelay: options?.humanDelay !== false,
};
}
/**
* Apply bot-prevention hardening to a Puppeteer page.
* Sets UA, viewport, JS enabled, headers, timezone and injects stealth-like patches.
*
* @param {import('puppeteer').Page} page
* @param {ReturnType<typeof getPreLaunchConfig>} cfg
*/
export async function applyBotPreventionToPage(page, cfg) {
await page.setUserAgent(cfg.userAgent);
await page.setViewport(cfg.viewport);
await page.setJavaScriptEnabled(true);
await page.setExtraHTTPHeaders(cfg.headers);
try {
if (cfg.timezone) await page.emulateTimezone(cfg.timezone);
} catch {
// ignore timezone failures
}
// Inject patches as early as possible
await page.evaluateOnNewDocument(() => {
try {
// webdriver
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
// chrome runtime
// @ts-ignore
if (!window.chrome) {
// @ts-ignore
window.chrome = { runtime: {} };
}
// languages
// @ts-ignore
Object.defineProperty(navigator, 'languages', {
get: () => (window.localStorage.getItem('__LANGS__') || 'de-DE,de').split(','),
});
// plugins
// @ts-ignore
Object.defineProperty(navigator, 'plugins', {
get: () => [{}, {}, {}],
});
// platform and concurrency hints
// @ts-ignore
Object.defineProperty(navigator, 'platform', { get: () => 'Win32' });
// @ts-ignore
if (typeof navigator.hardwareConcurrency === 'number' && navigator.hardwareConcurrency < 2) {
Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 4 });
}
// @ts-ignore
if (typeof navigator.deviceMemory === 'number' && navigator.deviceMemory < 2) {
Object.defineProperty(navigator, 'deviceMemory', { get: () => 8 });
}
// userAgentData (Client Hints)
try {
// @ts-ignore
if ('userAgentData' in navigator) {
// @ts-ignore
Object.defineProperty(navigator, 'userAgentData', {
get: () => ({
brands: [
{ brand: 'Chromium', version: '126' },
{ brand: 'Google Chrome', version: '126' },
],
mobile: false,
platform: 'Windows',
getHighEntropyValues: async (hints) => {
const values = {
platform: 'Windows',
platformVersion: '15.0.0',
architecture: 'x86',
model: '',
uaFullVersion: '126.0.0.0',
bitness: '64',
};
const out = {};
for (const k of hints || []) if (k in values) out[k] = values[k];
return out;
},
}),
});
}
} catch {
//noop
}
// Permissions API
const origQuery = navigator.permissions && navigator.permissions.query;
if (origQuery) {
// @ts-ignore
navigator.permissions.query = (parameters) =>
origQuery.call(navigator.permissions, parameters).then((result) => {
if (parameters && parameters.name === 'notifications') {
Object.defineProperty(result, 'state', { get: () => Notification.permission });
}
return result;
});
}
// WebGL vendor/renderer
const patchWebGL = (proto) => {
if (!proto || !proto.getParameter) return;
const getParameter = proto.getParameter;
// @ts-ignore
proto.getParameter = function (param) {
const UNMASKED_VENDOR_WEBGL = 0x9245;
const UNMASKED_RENDERER_WEBGL = 0x9246;
if (param === UNMASKED_VENDOR_WEBGL) return 'Google Inc.';
if (param === UNMASKED_RENDERER_WEBGL)
return 'ANGLE (NVIDIA, NVIDIA GeForce GTX 1660 Ti Direct3D11 vs_5_0 ps_5_0)';
return getParameter.call(this, param);
};
};
// @ts-ignore
patchWebGL(WebGLRenderingContext?.prototype);
// @ts-ignore
patchWebGL(WebGL2RenderingContext?.prototype);
// AudioContext timestamp rounding consistency
const patchAudio = (Ctx) => {
try {
if (!Ctx) return;
const proto = Ctx.prototype;
const createOsc = proto.createOscillator;
proto.createOscillator = function () {
const osc = createOsc.call(this);
const start = osc.start;
osc.start = function (when) {
return start.call(this, when || 0);
};
return osc;
};
} catch {
//noop
}
};
// @ts-ignore
patchAudio(window.AudioContext);
// @ts-ignore
patchAudio(window.OfflineAudioContext);
// Navigator.connection
try {
// @ts-ignore
Object.defineProperty(navigator, 'connection', { get: () => undefined });
} catch {
//noop
}
// Consistent outer sizes
try {
const calcOuter = () => {
const w = window.innerWidth + 16;
const h = window.innerHeight + 88;
return { w, h };
};
const { w: outerW, h: outerH } = calcOuter();
// @ts-ignore
Object.defineProperty(window, 'outerWidth', { get: () => outerW });
// @ts-ignore
Object.defineProperty(window, 'outerHeight', { get: () => outerH });
} catch {
//noop
}
} catch {
//noop
}
});
}
/**
* Persist languages value before navigation via localStorage.
* @param {import('puppeteer').Page} page
* @param {ReturnType<typeof getPreLaunchConfig>} cfg
*/
export async function applyLanguagePersistence(page, cfg) {
await page.evaluateOnNewDocument((langs) => {
try {
window.localStorage.setItem('__LANGS__', langs);
} catch {
// noop
}
}, cfg.acceptLanguage.split(';')[0]);
}
/**
* Perform subtle human-like interactions post navigation.
* @param {import('puppeteer').Page} page
* @param {ReturnType<typeof getPreLaunchConfig>} cfg
*/
export async function applyPostNavigationHumanSignals(page, cfg) {
if (!cfg.humanDelay) return;
const delay = 200 + Math.floor(Math.random() * 400);
await new Promise((res) => setTimeout(res, delay));
try {
const vw = cfg.viewport.width;
const vh = cfg.viewport.height;
const mx = Math.floor(vw * (0.3 + Math.random() * 0.4));
const my = Math.floor(vh * (0.3 + Math.random() * 0.4));
await page.mouse.move(mx, my, { steps: 10 + Math.floor(Math.random() * 10) });
await page.mouse.wheel({ deltaY: 100 + Math.floor(Math.random() * 200) });
} catch {
// ignore if mouse is unavailable
}
}

View File

@@ -1,11 +1,16 @@
import puppeteer from 'puppeteer-extra';
import StealthPlugin from 'puppeteer-extra-plugin-stealth';
import { debug, DEFAULT_HEADER, botDetected } from './utils.js';
import { debug, botDetected } from './utils.js';
import {
getPreLaunchConfig,
applyBotPreventionToPage,
applyLanguagePersistence,
applyPostNavigationHumanSignals,
} from './botPrevention.js';
import logger from '../logger.js';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { URL } from 'url';
puppeteer.use(StealthPlugin());
@@ -40,6 +45,11 @@ export default async function execute(url, waitForSelector, options) {
if (options?.proxyUrl) {
launchArgs.push(`--proxy-server=${options.proxyUrl}`);
}
// Prepare bot prevention pre-launch config
const preCfg = getPreLaunchConfig(url, options || {});
launchArgs.push(preCfg.langArg);
launchArgs.push(preCfg.windowSizeArg);
launchArgs.push(...preCfg.extraArgs);
browser = await puppeteer.launch({
headless: options?.puppeteerHeadless ?? true,
@@ -50,58 +60,9 @@ export default async function execute(url, waitForSelector, options) {
});
page = await browser.newPage();
// Derive domain-specific defaults
const { hostname } = new URL(url);
// Set a realistic modern user agent unless provided
const userAgent =
options?.userAgent ||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36';
await page.setUserAgent(userAgent);
// Viewport and device scale for typical desktop
await page.setViewport({ width: 1366, height: 768, deviceScaleFactor: 1 });
// Extra HTTP headers with localized Accept-Language
const acceptLanguage = options?.acceptLanguage || 'de-DE,de;q=0.9,en-US;q=0.7,en;q=0.5';
const headers = {
...DEFAULT_HEADER,
'Accept-Language': acceptLanguage,
'User-Agent': userAgent,
Referer: options?.referer || `https://${hostname}/`,
Connection: 'keep-alive',
DNT: '1',
};
await page.setExtraHTTPHeaders(headers);
// Timezone and locale tweaks to look German when needed
try {
const tz = options?.timezone || 'Europe/Berlin';
if (tz) await page.emulateTimezone(tz);
} catch {
//noop
}
// Harden navigator properties (stealth already covers many, but we ensure critical ones)
await page.evaluateOnNewDocument(() => {
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
// Plugins and mimeTypes
// @ts-ignore
Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3] });
// @ts-ignore
Object.defineProperty(navigator, 'languages', {
get: () => (window.localStorage.getItem('__LANGS__') || 'de-DE,de').split(','),
});
});
await applyBotPreventionToPage(page, preCfg);
// Provide languages value before navigation
await page.evaluateOnNewDocument((langs) => {
try {
window.localStorage.setItem('__LANGS__', langs);
} catch {
//noop
}
}, acceptLanguage.split(';')[0]);
await applyLanguagePersistence(page, preCfg);
// Optional cookies
if (Array.isArray(options?.cookies) && options.cookies.length > 0) {
@@ -113,11 +74,8 @@ export default async function execute(url, waitForSelector, options) {
waitUntil: options?.waitUntil || 'domcontentloaded',
});
// Optionally wait a random small delay to mimic human rendering time
if (options?.humanDelay !== false) {
const delay = 200 + Math.floor(Math.random() * 400);
await new Promise((res) => setTimeout(res, delay));
}
// Optionally wait and add subtle human-like interactions
await applyPostNavigationHumanSignals(page, preCfg);
let pageSource;
// if we're extracting data from a SPA, we must wait for the selector

View File

@@ -2,7 +2,7 @@ import fs from 'fs';
import path from 'path';
import Database from 'better-sqlite3';
import logger from '../../services/logger.js';
import { config } from '../../utils.js';
import { readConfigFromStorage } from '../../utils.js';
/**
* SqliteConnection
@@ -25,6 +25,15 @@ import { config } from '../../utils.js';
class SqliteConnection {
static #db = null;
static #sqlLiteCfg = null;
static async init() {
if (this.#sqlLiteCfg == null) {
readConfigFromStorage().then((c) => {
this.#sqlLiteCfg = c.sqlitepath;
});
}
}
/**
* Returns a singleton instance of better-sqlite3 Database.
* Respects env var SQLITE_DB_PATH and defaults to db/listings.db.
@@ -32,9 +41,12 @@ class SqliteConnection {
static getConnection() {
if (this.#db) return this.#db;
if (this.#sqlLiteCfg == null) {
logger.warn('No sqlitepath configured. Using default db/listings.db');
}
// Interpret config.sqlitepath as a directory relative to project root when it starts with '/'
const cfg = typeof config === 'object' && config ? config.sqlitepath : undefined;
const rawDir = cfg && cfg.length > 0 ? cfg : '/db';
const rawDir = this.#sqlLiteCfg && this.#sqlLiteCfg.length > 0 ? this.#sqlLiteCfg : '/db';
const relDir = rawDir.startsWith('/') ? rawDir.slice(1) : rawDir;
const absDir = path.isAbsolute(relDir) ? relDir : path.join(process.cwd(), relDir);
const dbPath = path.join(absDir, 'listings.db');

View File

@@ -0,0 +1,73 @@
// Migration: Adding a settings table to store important (config) settings instead of using config file
import fs from 'fs';
import path from 'path';
import { nanoid } from 'nanoid';
import logger from '../../../logger.js';
export function up(db) {
db.exec(`
CREATE TABLE IF NOT EXISTS settings
(
id TEXT PRIMARY KEY,
create_date INTEGER NOT NULL,
user_id TEXT,
name TEXT NOT NULL,
value jsonb NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_settings_name ON settings (name);
`);
// Helper to insert one setting row
const insertSetting = (name, rawValue) => {
try {
const id = nanoid();
const createDate = Date.now();
const value = JSON.stringify(rawValue);
db.prepare(
`INSERT INTO settings (id, create_date, name, value)
VALUES (@id, @create_date, @name, @value)`,
).run({ id, create_date: createDate, name, value });
} catch {
// Ignore duplicate inserts if any (unique by name)
}
};
// Migrate currently existing config.json into settings
try {
const configPath = path.resolve(process.cwd(), 'conf', 'config.json');
// Defaults
const defaults = {
interval: '60',
port: 9998,
workingHours: { from: '', to: '' },
demoMode: false,
analyticsEnabled: true,
};
let config = {};
if (fs.existsSync(configPath)) {
const file = fs.readFileSync(configPath, 'utf8');
try {
config = JSON.parse(file) || {};
} catch (parseErr) {
// If parsing fails, still proceed with defaults
logger.error(parseErr);
config = {};
}
}
// Insert each known setting, using the value from config when present, otherwise default
insertSetting('interval', config.interval != null ? config.interval : defaults.interval);
insertSetting('port', config.port != null ? config.port : defaults.port);
insertSetting('workingHours', config.workingHours != null ? config.workingHours : defaults.workingHours);
insertSetting('demoMode', config.demoMode != null ? config.demoMode : defaults.demoMode);
insertSetting(
'analyticsEnabled',
config.analyticsEnabled != null ? config.analyticsEnabled : defaults.analyticsEnabled,
);
} catch (e) {
logger.error(e);
}
}

View File

@@ -0,0 +1,87 @@
import { nanoid } from 'nanoid';
import SqliteConnection from './SqliteConnection.js';
import { fromJson, readConfigFromStorage, toJson } from '../../utils.js';
// In-memory cache for compiled settings config
/** @type {Record<string, any>|null} */
let cachedSettingsConfig = null;
/**
* Build a config object from DB rows of settings.
* - Unwraps stored shape { value: any } into raw values.
* - Add additional config values from file config. E.g. sqlite part cannot be stored in db for obvious reasons ;)
* @param {{name:string, value:string|null}[]} rows
* @param {{name:value}} configValues
* @returns {Record<string, any>}
*/
function compileSettings(rows, configValues) {
const config = {};
for (const r of rows) {
const parsed = fromJson(r.value, null);
// unwrap { value: any } if present
config[r.name] = parsed && typeof parsed === 'object' && 'value' in parsed ? parsed.value : parsed;
}
return {
...config,
...configValues,
};
}
/**
* Force reload the settings config cache from DB and return it.
* @returns {Record<string, any>}
*/
export async function refreshSettingsCache() {
const rows = SqliteConnection.query(`SELECT name, value FROM settings`);
const configValues = await readConfigFromStorage();
cachedSettingsConfig = compileSettings(rows, configValues);
return cachedSettingsConfig;
}
/**
* Get the compiled settings config. Loads it once and caches the result.
* @returns {Record<string, any>}
*/
export async function getSettings() {
if (cachedSettingsConfig == null) {
return refreshSettingsCache();
}
return cachedSettingsConfig;
}
/**
* Upsert settings rows.
* - Accepts an object map of name -> value, or an entry {name, value}.
* - id: random string (nanoid) when inserting
* - create_date: epoch ms when inserting
* - name: unique key
* - value: JSON string of the raw value (no wrapper)
* @param {Record<string, any>|{name:string, value:any}|[string, any][]} settingsMapOrEntry
* @returns {void}
*/
// Upsert one or more settings by name. Accepts either a single pair or an object map.
// Preferred usage: upsertSettings({ settingName: any, another: any })
export function upsertSettings(settingsMapOrEntry, userId = null) {
const entries = Array.isArray(settingsMapOrEntry)
? settingsMapOrEntry
: typeof settingsMapOrEntry === 'object' &&
settingsMapOrEntry != null &&
'name' in settingsMapOrEntry &&
'value' in settingsMapOrEntry
? [[settingsMapOrEntry.name, settingsMapOrEntry.value]]
: Object.entries(settingsMapOrEntry || {});
for (const [name, rawValue] of entries) {
const id = nanoid();
const create_date = Date.now();
const json = toJson(rawValue);
SqliteConnection.execute(
`INSERT INTO settings (id, create_date, name, value, user_id)
VALUES (@id, @create_date, @name, @value, @userId)
ON CONFLICT(name) DO UPDATE SET value = excluded.value`,
{ id, create_date, name, value: json, userId },
);
}
// keep cache in sync
refreshSettingsCache();
}

View File

@@ -1,7 +1,7 @@
import { config } from '../../utils.js';
import * as hasher from '../security/hash.js';
import { nanoid } from 'nanoid';
import SqliteConnection from './SqliteConnection.js';
import { getSettings } from './settingsStorage.js';
/**
* Get all users.
@@ -129,8 +129,9 @@ export const removeUser = (userId) => {
* Security: The demo user's password is set to a known value ('demo') and should only be enabled in demoMode.
* @returns {void}
*/
export const ensureDemoUserExists = () => {
if (!config.demoMode) {
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'`);
return;

View File

@@ -1,9 +1,10 @@
import { getJobs } from '../storage/jobStorage.js';
import { getUniqueId } from './uniqueId.js';
import { config, getPackageVersion, inDevMode } from '../../utils.js';
import { getPackageVersion, inDevMode } from '../../utils.js';
import os from 'os';
import fetch from 'node-fetch';
import logger from '../logger.js';
import { getSettings } from '../storage/settingsStorage.js';
const deviceId = getUniqueId() || 'N/A';
const version = await getPackageVersion();
@@ -11,7 +12,8 @@ const FREDY_TRACKING_URL = 'https://fredy.orange-coding.net/tracking';
export const trackMainEvent = async () => {
try {
if (config.analyticsEnabled && !inDevMode()) {
const settings = await getSettings();
if (settings.analyticsEnabled && !inDevMode()) {
const activeProvider = new Set();
const activeAdapter = new Set();
@@ -44,7 +46,8 @@ export const trackMainEvent = async () => {
* Note, this will only be used when Fredy runs in demo mode
*/
export async function trackDemoAccessed() {
if (config.analyticsEnabled && !inDevMode() && config.demoMode) {
const settings = await getSettings();
if (settings.analyticsEnabled && !inDevMode() && settings.demoMode) {
try {
await fetch(`${FREDY_TRACKING_URL}/demo/accessed`, {
method: 'POST',
@@ -56,7 +59,8 @@ export async function trackDemoAccessed() {
}
}
function enrichTrackingObject(trackingObject) {
async function enrichTrackingObject(trackingObject) {
const settings = await getSettings();
const operatingSystem = os.platform();
const osVersion = os.release();
const arch = process.arch;
@@ -65,7 +69,7 @@ function enrichTrackingObject(trackingObject) {
return {
...trackingObject,
isDemo: config.demoMode,
isDemo: settings.demoMode,
operatingSystem,
osVersion,
arch,

View File

@@ -215,10 +215,6 @@ export async function refreshConfig() {
try {
config = await readConfigFromStorage();
//backwards compatibility...
config.analyticsEnabled ??= null;
config.demoMode ??= false;
// default sqlitepath when missing in older configs
config.sqlitepath ??= '/db';
} catch (error) {
config = { ...DEFAULT_CONFIG };
@@ -306,7 +302,6 @@ export {
getDirName,
sleep,
randomBetween,
config,
buildHash,
getPackageVersion,
toJson,

View File

@@ -1,6 +1,6 @@
{
"name": "fredy",
"version": "14.3.4",
"version": "15.0.1",
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
"scripts": {
"prepare": "husky",
@@ -12,6 +12,7 @@
"format": "prettier --write \"**/*.js\"",
"format:check": "prettier --check \"**/*.js\"",
"test": "node --import ./test/esmock-loader.mjs ./node_modules/mocha/bin/mocha.js --timeout 60000 test/**/*.test.js",
"testGH": "node --import ./test/esmock-loader.mjs ./node_modules/mocha/bin/mocha.js --timeout 60000 --exclude test/provider/immonet.test.js --exclude test/provider/immowelt.test.js test/**/*.test.js",
"lint": "eslint .",
"lint:fix": "yarn lint --fix",
"migratedb": "node lib/services/storage/migrations/migrate.js",
@@ -56,15 +57,15 @@
"Firefox ESR"
],
"dependencies": {
"@douyinfe/semi-icons": "^2.88.0",
"@douyinfe/semi-ui": "2.88.0",
"@douyinfe/semi-icons": "^2.89.0",
"@douyinfe/semi-ui": "2.89.0",
"@sendgrid/mail": "8.1.6",
"@visactor/react-vchart": "^2.0.8",
"@visactor/vchart": "^2.0.8",
"@visactor/react-vchart": "^2.0.10",
"@visactor/vchart": "^2.0.10",
"@visactor/vchart-semi-theme": "^1.12.2",
"@vitejs/plugin-react": "5.1.1",
"better-sqlite3": "^12.4.1",
"body-parser": "2.2.0",
"@vitejs/plugin-react": "5.1.2",
"better-sqlite3": "^12.5.0",
"body-parser": "2.2.1",
"cheerio": "^1.1.2",
"cookie-session": "2.1.1",
"handlebars": "4.7.8",
@@ -75,21 +76,21 @@
"node-mailjet": "6.0.11",
"p-throttle": "^8.1.0",
"package-up": "^5.0.0",
"puppeteer": "^24.30.0",
"puppeteer": "^24.32.1",
"puppeteer-extra": "^3.3.6",
"puppeteer-extra-plugin-stealth": "^2.11.2",
"query-string": "9.3.1",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-router": "7.9.6",
"react-router-dom": "7.9.6",
"react-router": "7.10.1",
"react-router-dom": "7.10.1",
"restana": "5.1.0",
"semver": "^7.7.3",
"serve-static": "2.2.0",
"slack": "11.0.2",
"vite": "7.2.2",
"vite": "7.2.7",
"x-var": "^3.0.1",
"zustand": "^5.0.8"
"zustand": "^5.0.9"
},
"devDependencies": {
"@babel/core": "7.28.5",
@@ -104,9 +105,9 @@
"history": "5.3.0",
"husky": "9.1.7",
"less": "4.4.2",
"lint-staged": "16.2.6",
"lint-staged": "16.2.7",
"mocha": "11.7.5",
"nodemon": "^3.1.11",
"prettier": "3.6.2"
"prettier": "3.7.4"
}
}

View File

@@ -0,0 +1,33 @@
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
import { get } from '../mocks/mockNotification.js';
import { mockFredy, providerConfig } from '../utils.js';
import { expect } from 'chai';
import * as provider from '../../lib/provider/ohneMakler.js';
describe('#ohneMakler testsuite()', () => {
it('should test ohneMakler provider', async () => {
const Fredy = await mockFredy();
provider.init(providerConfig.ohneMakler, []);
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'ohneMakler', similarityCache);
const listing = await fredy.execute();
expect(listing).to.be.a('array');
const notificationObj = get();
expect(notificationObj).to.be.a('object');
expect(notificationObj.serviceName).to.equal('ohneMakler');
notificationObj.payload.forEach((notify) => {
/** check the actual structure **/
expect(notify.id).to.be.a('string');
expect(notify.price).to.be.a('string');
expect(notify.size).to.be.a('string');
expect(notify.title).to.be.a('string');
expect(notify.link).to.be.a('string');
expect(notify.address).to.be.a('string');
/** check the values if possible **/
expect(notify.size).that.does.include('m²');
expect(notify.title).to.be.not.empty;
expect(notify.address).to.be.not.empty;
});
});
});

View File

@@ -32,6 +32,10 @@
"url": "https://www.mcmakler.de/immobilien/results?placeId=62649&search=Leipzig%252C+Sachsen&propertyTypes=APARTMENT&page=0",
"enabled": true
},
"ohneMakler": {
"url": "https://www.ohne-makler.net/immobilien/wohnung-kaufen/nordrhein-westfalen/dusseldorf/",
"enabled": true
},
"neubauKompass": {
"url": "https://www.neubaukompass.de/neubau-immobilien/duesseldorf-region/eigentumswohnung/",
"enabled": true

View File

@@ -0,0 +1,99 @@
import { describe, it } from 'mocha';
import { expect } from 'chai';
import {
getPreLaunchConfig,
applyBotPreventionToPage,
applyLanguagePersistence,
applyPostNavigationHumanSignals,
} from '../../../lib/services/extractor/botPrevention.js';
describe('botPrevention helper', () => {
it('getPreLaunchConfig builds deterministic values when jitter disabled', () => {
const url = 'https://example.com/some/path';
const options = {
acceptLanguage: 'de-DE,de;q=0.9',
userAgent: 'TestAgent/1.0',
viewport: { width: 1200, height: 700, deviceScaleFactor: 2 },
viewportJitter: false,
referer: 'https://example.com/ref',
timezone: 'Europe/Berlin',
};
const cfg = getPreLaunchConfig(url, options);
expect(cfg.acceptLanguage).to.equal('de-DE,de;q=0.9');
expect(cfg.langArg).to.equal('--lang=de-DE');
expect(cfg.windowSizeArg).to.equal('--window-size=1200,700');
expect(cfg.viewport).to.deep.equal({ width: 1200, height: 700, deviceScaleFactor: 2 });
expect(cfg.userAgent).to.equal('TestAgent/1.0');
expect(cfg.headers['Accept-Language']).to.equal('de-DE,de;q=0.9');
expect(cfg.headers['User-Agent']).to.equal('TestAgent/1.0');
expect(cfg.headers.Referer).to.equal('https://example.com/ref');
expect(cfg.extraArgs).to.include('--disable-blink-features=AutomationControlled');
expect(cfg.extraArgs).to.include('--proxy-bypass-list=<-loopback>');
});
it('applyBotPreventionToPage sets UA, viewport, headers and injects patches', async () => {
const calls = [];
const page = {
setUserAgent: async (ua) => calls.push(['setUserAgent', ua]),
setViewport: async (vp) => calls.push(['setViewport', vp]),
setJavaScriptEnabled: async (on) => calls.push(['setJavaScriptEnabled', on]),
setExtraHTTPHeaders: async (h) => calls.push(['setExtraHTTPHeaders', h]),
emulateTimezone: async (tz) => calls.push(['emulateTimezone', tz]),
evaluateOnNewDocument: async (fn) => calls.push(['evaluateOnNewDocument', typeof fn]),
};
const cfg = getPreLaunchConfig('https://example.org/', {
userAgent: 'Foo/Bar',
acceptLanguage: 'en-US,en',
viewport: { width: 1000, height: 600, deviceScaleFactor: 1 },
viewportJitter: false,
timezone: 'UTC',
});
await applyBotPreventionToPage(page, cfg);
expect(calls[0]).to.deep.equal(['setUserAgent', 'Foo/Bar']);
expect(calls.some((c) => c[0] === 'setViewport' && c[1].width === 1000 && c[1].height === 600)).to.equal(true);
expect(calls.some((c) => c[0] === 'setJavaScriptEnabled' && c[1] === true)).to.equal(true);
const headerCall = calls.find((c) => c[0] === 'setExtraHTTPHeaders');
expect(headerCall).to.exist;
expect(headerCall[1]['Accept-Language']).to.equal('en-US,en');
expect(headerCall[1]['User-Agent']).to.equal('Foo/Bar');
expect(calls.some((c) => c[0] === 'emulateTimezone' && c[1] === 'UTC')).to.equal(true);
expect(calls.some((c) => c[0] === 'evaluateOnNewDocument' && c[1] === 'function')).to.equal(true);
});
it('applyLanguagePersistence stores languages early', async () => {
const calls = [];
const page = {
evaluateOnNewDocument: async (fn, arg) => calls.push(['evaluateOnNewDocument', typeof fn, arg]),
};
const cfg = getPreLaunchConfig('https://example.org/', {
acceptLanguage: 'de-DE,de;q=0.9',
viewportJitter: false,
});
await applyLanguagePersistence(page, cfg);
const call = calls[0];
expect(call[0]).to.equal('evaluateOnNewDocument');
expect(call[1]).to.equal('function');
expect(call[2]).to.equal('de-DE,de');
});
it('applyPostNavigationHumanSignals moves mouse and scrolls when enabled', async () => {
const mouseCalls = [];
const page = {
mouse: {
move: async (x, y, opts) => mouseCalls.push(['move', x, y, opts && typeof opts.steps === 'number']),
wheel: async (opts) => mouseCalls.push(['wheel', typeof opts.deltaY === 'number']),
},
};
const cfg = {
humanDelay: true,
viewport: { width: 1200, height: 800 },
};
await applyPostNavigationHumanSignals(page, cfg);
expect(mouseCalls.some((c) => c[0] === 'move')).to.equal(true);
expect(mouseCalls.some((c) => c[0] === 'wheel')).to.equal(true);
});
});

View File

@@ -21,6 +21,7 @@ import Navigation from './components/navigation/Navigation.jsx';
import { Layout } from '@douyinfe/semi-ui';
import FredyFooter from './components/footer/FredyFooter.jsx';
import ProcessingTimes from './views/jobs/ProcessingTimes.jsx';
import WatchlistManagement from './views/listings/management/WatchlistManagement.jsx';
export default function FredyApp() {
const actions = useActions();
@@ -34,6 +35,7 @@ export default function FredyApp() {
async function init() {
await actions.user.getCurrentUser();
if (!needsLogin()) {
await actions.features.getFeatures();
await actions.provider.getProvider();
await actions.jobs.getJobs();
await actions.jobs.getProcessingTimes();
@@ -91,6 +93,7 @@ export default function FredyApp() {
<Route path="/jobs/insights/:jobId" element={<JobInsight />} />
<Route path="/jobs" element={<Jobs />} />
<Route path="/listings" element={<Listings />} />
<Route path="/watchlistManagement" element={<WatchlistManagement />} />
{/* Permission-aware routes */}
<Route

View File

@@ -1,12 +1,13 @@
import React from 'react';
import { Nav } from '@douyinfe/semi-ui';
import { IconUser, IconStar, IconSetting, IconTerminal } from '@douyinfe/semi-icons';
import { IconStar, IconSetting, IconTerminal } from '@douyinfe/semi-icons';
import logoWhite from '../../assets/logo_white.png';
import Logout from '../logout/Logout.jsx';
import { useLocation, useNavigate } from 'react-router-dom';
import './Navigate.less';
import { useScreenWidth } from '../../hooks/screenWidth.js';
import { useFeature } from '../../hooks/featureHook.js';
export default function Navigation({ isAdmin }) {
const navigate = useNavigate();
@@ -14,15 +15,28 @@ export default function Navigation({ isAdmin }) {
const width = useScreenWidth();
const collapsed = width <= 850;
const watchlistFeature = useFeature('WATCHLIST_MANAGEMENT') || false;
const items = [
{ itemKey: '/jobs', text: 'Jobs', icon: <IconTerminal /> },
{ itemKey: '/listings', text: 'Found Listings', icon: <IconStar /> },
{ itemKey: '/listings', text: 'Listings', icon: <IconStar /> },
];
if (isAdmin) {
items.push({ itemKey: '/users', text: 'User Management', icon: <IconUser /> });
items.push({ itemKey: '/generalSettings', text: 'General Settings', icon: <IconSetting /> });
const settingsItems = [
{ itemKey: '/users', text: 'User Management' },
{ itemKey: '/generalSettings', text: 'General Settings' },
];
if (watchlistFeature) {
settingsItems.push({ itemKey: '/watchlistManagement', text: 'Watchlist Management' });
}
items.push({
itemKey: 'settings',
text: 'Settings',
icon: <IconSetting />,
items: settingsItems,
});
}
function parsePathName(name) {
@@ -32,7 +46,7 @@ export default function Navigation({ isAdmin }) {
return (
<Nav
style={{ height: '100%', width: collapsed ? '' : '13rem' }}
style={{ height: '100%', width: collapsed ? '' : '13.2rem' }}
items={items}
isCollapsed={collapsed}
selectedKeys={[parsePathName(location.pathname)]}

View File

@@ -1,9 +1,9 @@
import React from 'react';
import { Empty, Table, Button } from '@douyinfe/semi-ui';
import { IconDelete } from '@douyinfe/semi-icons';
import { IconDelete, IconEdit } from '@douyinfe/semi-icons';
export default function ProviderTable({ providerData = [], onRemove } = {}) {
export default function ProviderTable({ providerData = [], onRemove, onEdit } = {}) {
return (
<Table
pagination={false}
@@ -30,6 +30,8 @@ export default function ProviderTable({ providerData = [], onRemove } = {}) {
render: (_, record) => {
return (
<div style={{ float: 'right' }}>
<Button type="secondary" icon={<IconEdit />} onClick={() => onEdit(record)} />
<div style={{ display: 'inline-block', width: '16px' }} />
<Button type="danger" icon={<IconDelete />} onClick={() => onRemove(record.url)} />
</div>
);

View File

@@ -1,53 +0,0 @@
import { Card, Checkbox, Descriptions, Divider, Select } from '@douyinfe/semi-ui';
import React from 'react';
import { useSelector } from '../../../services/state/store.js';
import { Typography } from '@douyinfe/semi-ui';
import './ListingsFilter.less';
export default function ListingsFilter({ onWatchListFilter, onActivityFilter, onJobNameFilter, onProviderFilter }) {
const jobs = useSelector((state) => state.jobs.jobs);
const provider = useSelector((state) => state.provider);
const { Title } = Typography;
return (
<Card className="listingsFilter">
<Title heading={6}>Filter by:</Title>
<Divider />
<br />
<Descriptions row>
<Descriptions.Item itemKey="Watch List">
<Checkbox onChange={(e) => onWatchListFilter(e.target.checked)}>Only Watch List</Checkbox>
</Descriptions.Item>
<Descriptions.Item itemKey="Activity status">
<Checkbox onChange={(e) => onActivityFilter(e.target.checked)}>Only Active Listings</Checkbox>
</Descriptions.Item>
<Descriptions.Item itemKey="Job Name">
<Select showClear placeholder="Select Job to Filter" onChange={(val) => onJobNameFilter(val)}>
{jobs != null &&
jobs.length > 0 &&
jobs.map((job) => {
return (
<Select.Option value={job.id} key={job.id}>
{job.name}
</Select.Option>
);
})}
</Select>
</Descriptions.Item>
<Descriptions.Item itemKey="Provider">
<Select showClear placeholder="Select Provider to Filter" onChange={(val) => onProviderFilter(val)}>
{provider != null &&
provider.length > 0 &&
provider.map((prov) => {
return (
<Select.Option value={prov.id} key={prov.id}>
{prov.name}
</Select.Option>
);
})}
</Select>
</Descriptions.Item>
</Descriptions>
</Card>
);
}

View File

@@ -1,4 +0,0 @@
.listingsFilter {
margin-bottom: 1rem;
background: rgb(53, 54, 60);
}

View File

@@ -1,5 +1,18 @@
import React, { useState, useEffect, useMemo } from 'react';
import { Table, Popover, Input, Descriptions, Tag, Image, Empty, Button, Toast, Divider } from '@douyinfe/semi-ui';
import {
Table,
Popover,
Input,
Descriptions,
Tag,
Image,
Empty,
Button,
Toast,
Divider,
Space,
Select,
} from '@douyinfe/semi-ui';
import { useActions, useSelector } from '../../../services/state/store.js';
import { IconClose, IconDelete, IconSearch, IconStar, IconStarStroked, IconTick } from '@douyinfe/semi-icons';
import * as timeService from '../../../services/time/timeService.js';
@@ -10,166 +23,224 @@ import './ListingsTable.less';
import { format } from '../../../services/time/timeService.js';
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
import { xhrDelete, xhrPost } from '../../../services/xhr.js';
import ListingsFilter from './ListingsFilter.jsx';
import { useNavigate } from 'react-router-dom';
import { useFeature } from '../../../hooks/featureHook.js';
const columns = [
{
title: 'Watchlist',
width: 110,
dataIndex: 'isWatched',
sorter: true,
render: (id, row) => {
return (
<div>
<Popover
style={{
padding: '.4rem',
color: 'var(--semi-color-white)',
}}
content={row.isWatched === 1 ? 'Unwatch Listing' : 'Watch Listing'}
>
<Button
icon={
row.isWatched === 1 ? (
<IconStar style={{ color: 'rgba(var(--semi-green-5), 1)' }} />
) : (
<IconStarStroked />
)
}
theme="borderless"
size="small"
onClick={async () => {
try {
await xhrPost('/api/listings/watch', { listingId: row.id });
Toast.success(row.isWatched === 1 ? 'Listing removed from Watchlist' : 'Listing added to Watchlist');
row.reloadTable();
} catch (e) {
console.error(e);
Toast.error('Failed to operate Watchlist');
}
const getColumns = (provider, setProviderFilter, jobs, setJobNameFilter) => {
return [
{
title: 'Watchlist',
width: 133,
dataIndex: 'isWatched',
sorter: true,
filters: [
{
text: 'Show only watched listings',
value: 'watchList',
},
],
render: (id, row) => {
return (
<div>
<Popover
style={{
padding: '.4rem',
color: 'var(--semi-color-white)',
}}
/>
</Popover>
<Divider layout="vertical" margin="4px" />
<Popover
style={{
padding: '.4rem',
color: 'var(--semi-color-white)',
}}
content="Delete Listing"
>
<Button
icon={<IconDelete />}
theme="borderless"
size="small"
type="danger"
onClick={async () => {
try {
await xhrDelete('/api/listings/', { ids: [row.id] });
Toast.success('Listing(s) successfully removed');
row.reloadTable();
} catch (error) {
Toast.error(error);
content={row.isWatched === 1 ? 'Unwatch Listing' : 'Watch Listing'}
>
<Button
icon={
row.isWatched === 1 ? (
<IconStar style={{ color: 'rgba(var(--semi-green-5), 1)' }} />
) : (
<IconStarStroked />
)
}
theme="borderless"
size="small"
onClick={async () => {
try {
await xhrPost('/api/listings/watch', { listingId: row.id });
Toast.success(
row.isWatched === 1 ? 'Listing removed from Watchlist' : 'Listing added to Watchlist',
);
row.reloadTable();
} catch (e) {
console.error(e);
Toast.error('Failed to operate Watchlist');
}
}}
/>
</Popover>
<Divider layout="vertical" margin="4px" />
<Popover
style={{
padding: '.4rem',
color: 'var(--semi-color-white)',
}}
/>
</Popover>
</div>
);
content="Delete Listing"
>
<Button
icon={<IconDelete />}
theme="borderless"
size="small"
type="danger"
onClick={async () => {
try {
await xhrDelete('/api/listings/', { ids: [row.id] });
Toast.success('Listing(s) successfully removed');
row.reloadTable();
} catch (error) {
Toast.error(error);
}
}}
/>
</Popover>
</div>
);
},
},
},
{
title: 'State',
dataIndex: 'is_active',
width: 84,
sorter: true,
render: (value) => {
return value ? (
<div style={{ color: 'rgba(var(--semi-green-6), 1)' }}>
<Popover
style={{
padding: '.4rem',
color: 'var(--semi-color-white)',
}}
content="Listing is still active"
>
<IconTick />
</Popover>
</div>
) : (
<div style={{ color: 'rgba(var(--semi-red-5), 1)' }}>
<Popover
style={{
padding: '.4rem',
color: 'var(--semi-color-white)',
}}
content="Listing is inactive"
>
<IconClose />
</Popover>
</div>
);
{
title: 'Active',
dataIndex: 'is_active',
width: 110,
sorter: true,
filters: [
{
text: 'Show only active listings',
value: 'activityStatus',
},
],
render: (value) => {
return value ? (
<div style={{ color: 'rgba(var(--semi-green-6), 1)' }}>
<Popover
style={{
padding: '.4rem',
color: 'var(--semi-color-white)',
}}
content="Listing is still active"
>
<IconTick />
</Popover>
</div>
) : (
<div style={{ color: 'rgba(var(--semi-red-5), 1)' }}>
<Popover
style={{
padding: '.4rem',
color: 'var(--semi-color-white)',
}}
content="Listing is inactive"
>
<IconClose />
</Popover>
</div>
);
},
},
},
{
title: 'Job-Name',
sorter: true,
ellipsis: true,
dataIndex: 'job_name',
width: 150,
},
{
title: 'Listing date',
width: 130,
dataIndex: 'created_at',
sorter: true,
render: (text) => timeService.format(text, false),
},
{
title: 'Provider',
width: 130,
dataIndex: 'provider',
sorter: true,
render: (text) => text.charAt(0).toUpperCase() + text.slice(1),
},
{
title: 'Price',
width: 110,
dataIndex: 'price',
sorter: true,
render: (text) => text + ' €',
},
{
title: 'Address',
width: 150,
dataIndex: 'address',
sorter: true,
},
{
title: 'Title',
dataIndex: 'title',
sorter: true,
ellipsis: true,
render: (text, row) => {
return (
<a href={row.url} target="_blank" rel="noopener noreferrer">
{text}
</a>
);
{
title: 'Job-Name',
sorter: true,
ellipsis: true,
dataIndex: 'job_name',
width: 150,
onFilter: () => true,
renderFilterDropdown: () => {
return (
<Space vertical style={{ padding: 8 }}>
<Select showClear placeholder="Select Job to Filter" onChange={(val) => setJobNameFilter(val)}>
{jobs != null &&
jobs.length > 0 &&
jobs.map((job) => {
return (
<Select.Option value={job.id} key={job.id}>
{job.name}
</Select.Option>
);
})}
</Select>
</Space>
);
},
},
},
];
{
title: 'Listing date',
width: 130,
dataIndex: 'created_at',
sorter: true,
render: (text) => timeService.format(text, false),
},
{
title: 'Provider',
width: 130,
dataIndex: 'provider',
sorter: true,
render: (text) => text.charAt(0).toUpperCase() + text.slice(1),
onFilter: () => true,
renderFilterDropdown: () => {
return (
<Space vertical style={{ padding: 8 }}>
<Select showClear placeholder="Select Provider to Filter" onChange={(val) => setProviderFilter(val)}>
{provider != null &&
provider.length > 0 &&
provider.map((prov) => {
return (
<Select.Option value={prov.id} key={prov.id}>
{prov.name}
</Select.Option>
);
})}
</Select>
</Space>
);
},
},
{
title: 'Price',
width: 110,
dataIndex: 'price',
sorter: true,
render: (text) => text + ' €',
},
{
title: 'Address',
width: 150,
dataIndex: 'address',
sorter: true,
},
{
title: 'Title',
dataIndex: 'title',
sorter: true,
ellipsis: true,
render: (text, row) => {
return (
<a href={row.url} target="_blank" rel="noopener noreferrer">
{text}
</a>
);
},
},
];
};
const empty = (
<Empty
image={<IllustrationNoResult />}
darkModeImage={<IllustrationNoResultDark />}
description="No listings available."
description="No listings found."
/>
);
export default function ListingsTable() {
const tableData = useSelector((state) => state.listingsTable);
const provider = useSelector((state) => state.provider);
const jobs = useSelector((state) => state.jobs.jobs);
const navigate = useNavigate();
const watchlistFeature = useFeature('WATCHLIST_MANAGEMENT') || false;
const actions = useActions();
const [page, setPage] = useState(1);
const pageSize = 10;
@@ -179,12 +250,14 @@ export default function ListingsTable() {
const [jobNameFilter, setJobNameFilter] = useState(null);
const [activityFilter, setActivityFilter] = useState(null);
const [providerFilter, setProviderFilter] = useState(null);
const [allFilters, setAllFilters] = useState([]);
const [imageWidth, setImageWidth] = useState('100%');
const handlePageChange = (_page) => {
setPage(_page);
};
const columns = getColumns(provider, setProviderFilter, jobs, setJobNameFilter);
const loadTable = () => {
let sortfield = null;
let sortdir = null;
@@ -209,6 +282,20 @@ export default function ListingsTable() {
const handleFilterChange = useMemo(() => debounce((value) => setFreeTextFilter(value), 500), []);
const diffArrays = (primary, secondary) => {
const result = {};
for (const item of secondary) {
if (!primary.includes(item)) result[item] = true;
}
for (const item of primary) {
if (!secondary.includes(item)) result[item] = false;
}
return [result];
};
useEffect(() => {
return () => {
// cleanup debounced handler to avoid memory leaks
@@ -258,12 +345,6 @@ export default function ListingsTable() {
return (
<div>
<ListingsFilter
onActivityFilter={setActivityFilter}
onWatchListFilter={setWatchListFilter}
onJobNameFilter={setJobNameFilter}
onProviderFilter={setProviderFilter}
/>
<Input
prefix={<IconSearch />}
showClear
@@ -271,6 +352,16 @@ export default function ListingsTable() {
placeholder="Search"
onChange={handleFilterChange}
/>
{watchlistFeature && (
<Button
className="listingsTable__setupButton"
onClick={() => {
navigate('/watchlistManagement');
}}
>
Setup notifications on watchlist changes
</Button>
)}
<Table
rowKey="id"
empty={empty}
@@ -285,7 +376,23 @@ export default function ListingsTable() {
};
})}
onChange={(changeSet) => {
if (changeSet?.extra?.changeType === 'sorter') {
if (changeSet?.extra?.changeType === 'filter') {
const transformed = changeSet.filters.map((f) => f.dataIndex);
const diff = diffArrays(allFilters, transformed);
setAllFilters(transformed);
diff.forEach((filter) => {
switch (Object.keys(filter)[0]) {
case 'isWatched':
setWatchListFilter(Object.values(filter)[0]);
break;
case 'is_active':
setActivityFilter(Object.values(filter)[0]);
break;
default:
console.error('Unknown filter: ', filter.dataIndex);
}
});
} else if (changeSet?.extra?.changeType === 'sorter') {
setSortData({
field: changeSet.sorter.dataIndex,
direction: changeSet.sorter.sortOrder === 'ascend' ? 'asc' : 'desc',

View File

@@ -11,4 +11,8 @@
&__toolbar {
margin-bottom: 1rem;
}
&__setupButton {
margin-bottom: 1rem;
}
}

View File

@@ -0,0 +1,15 @@
import { useSelector } from '../services/state/store.js';
export function useFeature(name) {
const currentFeatureFlags = useSelector((state) => state.features);
if (Object.keys(currentFeatureFlags || {}).length === 0) {
return null;
}
if (currentFeatureFlags[name] == null) {
console.warn(`Feature flag with name ${name} is unknown.`);
return null;
}
return currentFeatureFlags[name];
}

View File

@@ -48,6 +48,16 @@ export const useFredyState = create(
}
},
},
features: {
async getFeatures() {
try {
const response = await xhrGet('/api/features');
set((state) => ({ ...state.features, ...response.json }));
} catch (Exception) {
console.error('Error while trying to get resource for api/features. Error:', Exception);
}
},
},
provider: {
async getProvider() {
try {
@@ -176,6 +186,7 @@ export const useFredyState = create(
page: 1,
result: [],
},
features: {},
generalSettings: { settings: {} },
demoMode: { demoMode: false },
versionUpdate: {},
@@ -192,6 +203,7 @@ export const useFredyState = create(
versionUpdate: { ...effects.versionUpdate },
listingsTable: { ...effects.listingsTable },
provider: { ...effects.provider },
features: { ...effects.features },
jobs: { ...effects.jobs },
user: { ...effects.user },
};

View File

@@ -11,7 +11,15 @@ import { useNavigate, useParams } from 'react-router-dom';
import { Divider, Input, Switch, Button, TagInput, Toast, Select } from '@douyinfe/semi-ui';
import './JobMutation.less';
import { SegmentPart } from '../../../components/segment/SegmentPart';
import { IconBell, IconBriefcase, IconPaperclip, IconPlayCircle, IconPlusCircle, IconUser } from '@douyinfe/semi-icons';
import {
IconBell,
IconBriefcase,
IconPaperclip,
IconPlayCircle,
IconPlusCircle,
IconUser,
IconClear,
} from '@douyinfe/semi-icons';
export default function JobMutator() {
const jobs = useSelector((state) => state.jobs.jobs);
@@ -26,6 +34,7 @@ export default function JobMutator() {
const defaultNotificationAdapter = jobToBeEdit?.notificationAdapter || [];
const defaultEnabled = jobToBeEdit?.enabled ?? true;
const [providerToEdit, setProviderToEdit] = useState(null);
const [providerCreationVisible, setProviderCreationVisibility] = useState(false);
const [notificationCreationVisible, setNotificationCreationVisibility] = useState(false);
const [editNotificationAdapter, setEditNotificationAdapter] = useState(null);
@@ -42,6 +51,12 @@ export default function JobMutator() {
return Boolean(notificationAdapterData.length && providerData.length && name);
};
const handleProviderEdit = (data) => {
setProviderData(
providerData.map((provider) => (provider.url === data.oldProviderToEdit.url ? data.newData : provider)),
);
};
const mutateJob = async () => {
try {
await xhrPost('/api/jobs', {
@@ -70,6 +85,8 @@ export default function JobMutator() {
onData={(data) => {
setProviderData([...providerData, data]);
}}
onEditData={handleProviderEdit}
providerToEdit={providerToEdit}
/>
{notificationCreationVisible && (
@@ -119,7 +136,10 @@ export default function JobMutator() {
type="primary"
icon={<IconPlusCircle />}
className="jobMutation__newButton"
onClick={() => setProviderCreationVisibility(true)}
onClick={() => {
setProviderToEdit(null);
setProviderCreationVisibility(true);
}}
>
Add new Provider
</Button>
@@ -129,6 +149,10 @@ export default function JobMutator() {
onRemove={(providerUrl) => {
setProviderData(providerData.filter((provider) => provider.url !== providerUrl));
}}
onEdit={(provider) => {
setProviderCreationVisibility(true);
setProviderToEdit(provider);
}}
/>
</SegmentPart>
<Divider margin="1rem" />
@@ -160,7 +184,7 @@ export default function JobMutator() {
</SegmentPart>
<Divider margin="1rem" />
<SegmentPart
Icon={IconBell}
Icon={IconClear}
name="Blacklist"
helpText="If a listing contains one of these words, it will be filtered out. Type in a word, then hit enter."
>

View File

@@ -7,6 +7,7 @@ import { useSelector } from '../../../../../services/state/store';
import { Banner, Button, Form, Modal, Select, Switch } from '@douyinfe/semi-ui';
import './NotificationAdapterMutator.less';
import { useScreenWidth } from '../../../../../hooks/screenWidth.js';
const sortAdapter = (a, b) => {
if (a.name < b.name) {
@@ -53,6 +54,8 @@ function spreadPrefilledAdapterWithValues(prefilled, fields) {
}
export default function NotificationAdapterMutator({
title,
description,
onVisibilityChanged,
visible = false,
selected = [],
@@ -70,6 +73,9 @@ export default function NotificationAdapterMutator({
const [validationMessage, setValidationMessage] = useState(null);
const [successMessage, setSuccessMessage] = useState(null);
const width = useScreenWidth();
const isMobile = width <= 850;
const onSubmit = (doStore) => {
if (doStore) {
const validationResults = validate(selectedAdapter);
@@ -168,20 +174,21 @@ export default function NotificationAdapterMutator({
return (
<Modal
title="Adding a new Notification Adapter"
title={title != null ? title : 'Adding a new Notification Adapter'}
visible={visible}
style={{ width: '95%' }}
style={{ width: isMobile ? '95%' : '50rem' }}
onCancel={() => onSubmit(false)}
footer={
<div>
<Button type="secondary" disabled={selectedAdapter == null} style={{ float: 'left' }} onClick={() => onTry()}>
<Button type="secondary" disabled={selectedAdapter == null} style={{ float: 'left' }} onClick={onTry}>
Try
</Button>
<Button type="danger" onClick={() => onSubmit(true)}>
Save
</Button>
<Button type="primary" onClick={() => onSubmit(false)}>
<Button theme="light" type="tertiary" onClick={() => onSubmit(false)}>
Cancel
</Button>
<Button theme="solid" type="primary" onClick={() => onSubmit(true)}>
Save
</Button>
</div>
}
>
@@ -206,11 +213,15 @@ export default function NotificationAdapterMutator({
/>
)}
<p>
When Fredy found new listings, we like to report them to you. To do so, notification adapter can be configured.{' '}
<br />
There are multiple ways how Fredy can send new listings to you. Chose your weapon...
</p>
{description != null ? (
<p>{description}</p>
) : (
<p>
When Fredy finds new listings, we like to report them to you. To do so, notification adapter can be
configured. <br />
There are multiple ways how Fredy can send new listings to you. Chose your weapon...
</p>
)}
<Select
filter

View File

@@ -1,10 +1,11 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { Banner, Modal, Select, Input } from '@douyinfe/semi-ui';
import { transform } from '../../../../../services/transformer/providerTransformer';
import { useSelector } from '../../../../../services/state/store';
import { IconLikeHeart } from '@douyinfe/semi-icons';
import './ProviderMutator.less';
import { useScreenWidth } from '../../../../../hooks/screenWidth.js';
const sortProvider = (a, b) => {
if (a.key < b.key) {
@@ -16,11 +17,35 @@ const sortProvider = (a, b) => {
return 0;
};
export default function ProviderMutator({ onVisibilityChanged, visible = false, onData } = {}) {
const returnOriginalSelectedProvider = (providerToEdit, provider) => {
return provider.find((pro) => pro.id === providerToEdit.id);
};
export default function ProviderMutator({
onVisibilityChanged,
visible = false,
onData,
onEditData,
providerToEdit,
} = {}) {
const provider = useSelector((state) => state.provider);
const [selectedProvider, setSelectedProvider] = useState(null);
const [providerUrl, setProviderUrl] = useState(null);
const [validationMessage, setValidationMessage] = useState(null);
useEffect(() => {
if (providerToEdit) {
setSelectedProvider(returnOriginalSelectedProvider(providerToEdit, provider));
setProviderUrl(providerToEdit.url);
} else {
setSelectedProvider(null);
setProviderUrl(null);
}
}, [providerToEdit, visible]);
const width = useScreenWidth();
const isMobile = width <= 850;
const validate = () => {
if (selectedProvider == null || selectedProvider.length === 0 || providerUrl == null || providerUrl.length === 0) {
return 'Please select a provider and copy the browser url into the textfield after configuring your search parameter.';
@@ -41,13 +66,24 @@ export default function ProviderMutator({ onVisibilityChanged, visible = false,
if (doStore) {
const validationResult = validate();
if (validationResult == null) {
onData(
transform({
url: providerUrl,
id: selectedProvider.id,
name: selectedProvider.name,
}),
);
if (providerToEdit != null) {
onEditData({
newData: transform({
url: providerUrl,
id: selectedProvider.id,
name: selectedProvider.name,
}),
oldProviderToEdit: providerToEdit,
});
} else {
onData(
transform({
url: providerUrl,
id: selectedProvider.id,
name: selectedProvider.name,
}),
);
}
setProviderUrl(null);
setSelectedProvider(null);
onVisibilityChanged(false);
@@ -63,11 +99,11 @@ export default function ProviderMutator({ onVisibilityChanged, visible = false,
return (
<Modal
title="Adding a new Provider"
title={providerToEdit ? 'Editing an existing Provider' : 'Adding a new Provider'}
visible={visible}
onOk={() => onSubmit(true)}
onCancel={() => onSubmit(false)}
style={{ width: '50rem' }}
style={{ width: isMobile ? '95%' : '50rem' }}
okText="Save"
>
{validationMessage != null && (
@@ -80,19 +116,26 @@ export default function ProviderMutator({ onVisibilityChanged, visible = false,
description={validationMessage}
/>
)}
<p>
Provider are the <IconLikeHeart style={{ color: '#ff0000' }} /> of Fredy. We're supporting multiple Provider
such as Immowelt, Kalaydo etc. Select a provider from the list below.
<br />
Fredy will then open the provider's url in a new tab.
</p>
<p>
You will need to configure your search parameter like you would do when you do a regular search on the
provider's website.
<br />
When the search results are shown on the website, copy the url and paste it into the textfield below.
</p>
{providerToEdit != null ? (
<p>
You can now edit the <strong>{providerToEdit.name}</strong> provider's URL in the input field below.
</p>
) : (
<>
<p>
Provider are the <IconLikeHeart style={{ color: '#ff0000' }} /> of Fredy. We're supporting multiple Provider
such as Immowelt, Kalaydo etc. Select a provider from the list below.
<br />
Fredy will then open the provider's url in a new tab.
</p>
<p>
You will need to configure your search parameter like you would do when you do a regular search on the
provider's website.
<br />
When the search results are shown on the website, copy the url and paste it into the textfield below.
</p>
</>
)}
<Banner
fullMode={false}
type="warning"
@@ -112,6 +155,7 @@ export default function ProviderMutator({ onVisibilityChanged, visible = false,
filter
placeholder="Select a provider"
className="providerMutator__fields"
disabled={providerToEdit != null}
optionList={provider
.map((pro) => {
return {
@@ -126,7 +170,6 @@ export default function ProviderMutator({ onVisibilityChanged, visible = false,
onChange={(value) => {
const selectedProvider = provider.find((pro) => pro.id === value);
setSelectedProvider(selectedProvider);
window.open(selectedProvider.baseUrl);
}}
/>
@@ -137,7 +180,8 @@ export default function ProviderMutator({ onVisibilityChanged, visible = false,
placeholder="Provider Url"
width={10}
className="providerMutator__fields"
onBlur={(e) => {
value={providerUrl}
onInput={(e) => {
setProviderUrl(e.target.value);
}}
/>

View File

@@ -3,9 +3,5 @@ import React from 'react';
import ListingsTable from '../../components/table/listings/ListingsTable.jsx';
export default function Listings() {
return (
<div>
<ListingsTable />
</div>
);
return <ListingsTable />;
}

View File

@@ -0,0 +1,59 @@
import React, { useState } from 'react';
import { IconHorn } from '@douyinfe/semi-icons';
import { SegmentPart } from '../../../components/segment/SegmentPart.jsx';
import { Banner, Button, Checkbox, Space } from '@douyinfe/semi-ui';
import NotificationAdapterMutator from '../../jobs/mutation/components/notificationAdapter/NotificationAdapterMutator.jsx';
import Headline from '../../../components/headline/Headline.jsx';
export default function WatchlistManagement() {
const [notificationChooserVisible, setNotificationChooserVisible] = useState(false);
const [notificationAdapterData, setNotificationAdapterData] = useState([]);
//TODO: Set default
const [activityChanges, setActivityChanges] = useState(false);
const [priceChanges, setPriceChanges] = useState(false);
return (
<div>
<SegmentPart
name="Notification for Watch List"
helpText="You can get notified for changes on listings from your watch list."
Icon={IconHorn}
>
<Banner
fullMode={false}
type="info"
closeIcon={null}
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>Note</div>}
description="Youll receive notifications only for listings that are on your watch list. To add listings to it, open the 'Listings' section and tag the ones you want to follow."
/>
<Space />
<Headline size={5} text="Notify me when:" style={{ marginTop: '1rem' }} />
<Checkbox checked={activityChanges} onChange={(e) => setActivityChanges(e.target.checked)}>
Listing state changes (e.g. listing becomes inactive)
</Checkbox>
<Checkbox checked={priceChanges} onChange={(e) => setPriceChanges(e.target.checked)}>
Listing price changes
</Checkbox>
<Space />
<Headline size={5} text="Notify me with:" style={{ marginTop: '1rem' }} />
<Button onClick={() => setNotificationChooserVisible(true)}>Select notification method</Button>
<NotificationAdapterMutator
title="Add notification method"
description="When something has changed, Fredy will notify you using the selected notification adapter. Note, some adapter like SqLite are not available here."
visible={notificationChooserVisible}
onVisibilityChanged={(visible) => {
setNotificationChooserVisible(visible);
}}
selected={notificationAdapterData}
editNotificationAdapter={null}
onData={(data) => {
const oldData = [...notificationAdapterData].filter((o) => o.id !== data.id);
setNotificationAdapterData([...oldData, data]);
}}
/>
</SegmentPart>
</div>
);
}

526
yarn.lock
View File

@@ -997,34 +997,34 @@
dependencies:
tslib "^2.0.0"
"@douyinfe/semi-animation-react@2.88.0":
version "2.88.0"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation-react/-/semi-animation-react-2.88.0.tgz#34d951e46a263b14db563b4044b3144f787e44e5"
integrity sha512-K6WzTDnLn75I+XOB/9C/hA2Mwjqd+TQpYiEjxSC+l3Ep6MiLS/5VbkGOSt4jiRJJQs584xfw59ReUJ5LGuPQLQ==
"@douyinfe/semi-animation-react@2.89.0":
version "2.89.0"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation-react/-/semi-animation-react-2.89.0.tgz#be95b42a928ffe60b54d688dcf1d0f65e81b5bcc"
integrity sha512-6GSQMF2bIoWN2Bua4wYGCe//ltfE1/iNQRMF7+TybVMz9kBJU0gelFsvxxVnqpka994RuTvhe73CSWWdpLwjng==
dependencies:
"@douyinfe/semi-animation" "2.88.0"
"@douyinfe/semi-animation-styled" "2.88.0"
"@douyinfe/semi-animation" "2.89.0"
"@douyinfe/semi-animation-styled" "2.89.0"
classnames "^2.2.6"
"@douyinfe/semi-animation-styled@2.88.0":
version "2.88.0"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation-styled/-/semi-animation-styled-2.88.0.tgz#abc29d577fc910ee3707af0f581548608c388d27"
integrity sha512-iHqrD2HoWL9Vd40DAsSjZHONHU91ayelMlziFoBjvvmaiuvcQms2ead7hLFkDtvkDswT0Mfd8BqkVDJSxTwxnw==
"@douyinfe/semi-animation-styled@2.89.0":
version "2.89.0"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation-styled/-/semi-animation-styled-2.89.0.tgz#72cd09f73abf5198bcfb47f6254c0f2799c146b2"
integrity sha512-y1wXswseGbJpPh3hJQ9aNjnMzecLh9eUERmSpQaWbDSdrzk65hBa91MMC2rk/wlIN0/Q6OAKU8FcMoSiBiuI0Q==
"@douyinfe/semi-animation@2.88.0":
version "2.88.0"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation/-/semi-animation-2.88.0.tgz#2c069476b24a55041837e976b0d045c2c0da0049"
integrity sha512-J7fjwnVJEYvS2ZbKvWTjRRXTWQPlmYwkeXasICom+KFuE2vrkCzeqTXXIJ25MuaWlM/OWBPqrkAZBIfmNNQXWg==
"@douyinfe/semi-animation@2.89.0":
version "2.89.0"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation/-/semi-animation-2.89.0.tgz#a30de59827f6b8452100a5dd2a828aebe8cb86ec"
integrity sha512-y6an913b841V0BAdR5qSLYvoK5C2OAbNKImzM+FzWmbRQjzbOEYcF3bqi5AZhY4mYk7v05k2W7U6fmaXYNOS1Q==
dependencies:
bezier-easing "^2.1.0"
"@douyinfe/semi-foundation@2.88.0":
version "2.88.0"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-foundation/-/semi-foundation-2.88.0.tgz#8fa4d5373acb5bb9f1e9fe1ca97c553c0ae76bfc"
integrity sha512-WYT1blbg2873xAU9iCasMRnTUsE/9WP/9gE1Zd87vsnZYWwl3WP9imH0iSqeSXkFdJllNo/KBImBY7clOoVIYA==
"@douyinfe/semi-foundation@2.89.0":
version "2.89.0"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-foundation/-/semi-foundation-2.89.0.tgz#299b10ecb92289bd4158471d91a08dfc461b7ef2"
integrity sha512-Ryc2XywB3BVoUHETp5e7cY9x/ccweeKyCjqw/dcM16txeSpGxW7p1ykexGHRl3+dz1QcVrU4vp/ELD6GutC0Sg==
dependencies:
"@douyinfe/semi-animation" "2.88.0"
"@douyinfe/semi-json-viewer-core" "2.88.0"
"@douyinfe/semi-animation" "2.89.0"
"@douyinfe/semi-json-viewer-core" "2.89.0"
"@mdx-js/mdx" "^3.0.1"
async-validator "^3.5.0"
classnames "^2.2.6"
@@ -1038,53 +1038,53 @@
remark-gfm "^4.0.0"
scroll-into-view-if-needed "^2.2.24"
"@douyinfe/semi-icons@2.88.0", "@douyinfe/semi-icons@^2.88.0":
version "2.88.0"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-icons/-/semi-icons-2.88.0.tgz#8bc28881aba3fa5a190599e1ddf4c6fb1840dbaa"
integrity sha512-kZSni5KZFL6fxs+c2nF4e3biPNcnAxV9U27577kOlaqP7l2FqP9U+d4x2YQisgsoT+Z3brqfWEayastQk5fzig==
"@douyinfe/semi-icons@2.89.0", "@douyinfe/semi-icons@^2.89.0":
version "2.89.0"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-icons/-/semi-icons-2.89.0.tgz#c9252981fde29668e3a88862948d09f71360a4fa"
integrity sha512-LfUhh/S0+3bOdD7jy1xg5F1y6mXrYtDiIsA1Hmuhy3zhNSpSKSwfqPiV3IxwRRmGXFWjgiSefKd99h5OmKMPHg==
dependencies:
classnames "^2.2.6"
"@douyinfe/semi-illustrations@2.88.0":
version "2.88.0"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-illustrations/-/semi-illustrations-2.88.0.tgz#7ba4dad1fe98c813386c3baf7fd9720974cab1b3"
integrity sha512-fQ+Q9g9KjE9a2nH59uNHEzUdSt40GDloPCB4n7J3Q9EUeOiWpOsXbC/3NCDZc2ElZVryMChT3g6vjvIzHAl9Hw==
"@douyinfe/semi-illustrations@2.89.0":
version "2.89.0"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-illustrations/-/semi-illustrations-2.89.0.tgz#93611cfa572d79eb4bd50a1e13ee416161e6f41f"
integrity sha512-yAU4sSHr236E7ygTlwxupQkeF/W7EtfrUfRx3NUdGWuswMPAICz7d6Upa0XAZoCJ4skBZ5ItcQq9FfM+pw4wKg==
"@douyinfe/semi-json-viewer-core@2.88.0":
version "2.88.0"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-json-viewer-core/-/semi-json-viewer-core-2.88.0.tgz#53cd6e6aa2a7f4b517c4cd532b08e65af4d60da7"
integrity sha512-LLdLZ477eJBQKlCPIqPhpIcXL1GOy9mvjpwryqiAj/h6BXmwcvp1zJwJQP9Rq9inePawdYMSZozaB2X1FPjKOg==
"@douyinfe/semi-json-viewer-core@2.89.0":
version "2.89.0"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-json-viewer-core/-/semi-json-viewer-core-2.89.0.tgz#4254ce7d36c24f70267980f8e8a42faf6757f502"
integrity sha512-BGMJgg+tBFcwg3/7aJmtIXaHW+tSA6Tae3UfyhLYjUxcl6cFtYjtN0DAGwoia9KzUdNHoSAhl3GJVtGCBsmApQ==
dependencies:
jsonc-parser "^3.3.1"
"@douyinfe/semi-theme-default@2.88.0":
version "2.88.0"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-theme-default/-/semi-theme-default-2.88.0.tgz#caa8c24c3afd3c24689a74efacdd6e11199cc22c"
integrity sha512-Cykl39Tkw9cJYTBpDToyj0uyXBGS15QDZGR2zCskdG52+eaCyZAoCds4W3HOxlToUmuw0JgVES5VSalIy3M07A==
"@douyinfe/semi-theme-default@2.89.0":
version "2.89.0"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-theme-default/-/semi-theme-default-2.89.0.tgz#eb1fed1939a16fb903f6845867007020d76a503b"
integrity sha512-mdL6Ui1XMGW9L5tYl9uG3MnmyHaIXnRVa7b4PB0Y8kfFGAVzls5XBjZT5ACVtwxlVZrU5BrJBFOXGER5p1FVDg==
"@douyinfe/semi-ui@2.88.0":
version "2.88.0"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-ui/-/semi-ui-2.88.0.tgz#a220fcfcad593f9669acb44b74c3c1e10efcb262"
integrity sha512-MlfLjUpTqnfk3Sg6pQOA2JETvZaWFEQwLvEcbfwA5LijX/hu7hG1Zhj1AVnpXTXrOUiU+ENTOiLu4GggoW2EaA==
"@douyinfe/semi-ui@2.89.0":
version "2.89.0"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-ui/-/semi-ui-2.89.0.tgz#563adb7f33b9d888a882573024df2296be3c8bf4"
integrity sha512-XZ2yo2TgGWk8ubukJq7zbpKePpswQRq3nxeBlmL39SEben8AUfEq92vus0Hcmua5Y2wgi6TY2qWPgo+WEZCrkQ==
dependencies:
"@dnd-kit/core" "^6.0.8"
"@dnd-kit/sortable" "^7.0.2"
"@dnd-kit/utilities" "^3.2.1"
"@douyinfe/semi-animation" "2.88.0"
"@douyinfe/semi-animation-react" "2.88.0"
"@douyinfe/semi-foundation" "2.88.0"
"@douyinfe/semi-icons" "2.88.0"
"@douyinfe/semi-illustrations" "2.88.0"
"@douyinfe/semi-theme-default" "2.88.0"
"@tiptap/core" "^3.1.0"
"@tiptap/extension-document" "^3.3.0"
"@tiptap/extension-hard-break" "^3.3.0"
"@tiptap/extension-mention" "^3.1.0"
"@tiptap/extension-paragraph" "^3.3.0"
"@tiptap/extension-text" "^3.3.0"
"@tiptap/extensions" "^3.1.0"
"@tiptap/pm" "^3.1.0"
"@tiptap/react" "^3.1.0"
"@douyinfe/semi-animation" "2.89.0"
"@douyinfe/semi-animation-react" "2.89.0"
"@douyinfe/semi-foundation" "2.89.0"
"@douyinfe/semi-icons" "2.89.0"
"@douyinfe/semi-illustrations" "2.89.0"
"@douyinfe/semi-theme-default" "2.89.0"
"@tiptap/core" "^3.10.7"
"@tiptap/extension-document" "^3.10.7"
"@tiptap/extension-hard-break" "^3.10.7"
"@tiptap/extension-mention" "^3.10.7"
"@tiptap/extension-paragraph" "^3.10.7"
"@tiptap/extension-text" "^3.10.7"
"@tiptap/extensions" "^3.10.7"
"@tiptap/pm" "^3.10.7"
"@tiptap/react" "^3.10.7"
async-validator "^3.5.0"
classnames "^2.2.6"
copy-text-to-clipboard "^2.1.1"
@@ -1434,10 +1434,10 @@
resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33"
integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==
"@puppeteer/browsers@2.10.13":
version "2.10.13"
resolved "https://registry.yarnpkg.com/@puppeteer/browsers/-/browsers-2.10.13.tgz#42c8b7df14e992f311ca9dca5fed3f0c2182fd17"
integrity sha512-a9Ruw3j3qlnB5a/zHRTkruppynxqaeE4H9WNj5eYGRWqw0ZauZ23f4W2ARf3hghF5doozyD+CRtt7XSYuYRI/Q==
"@puppeteer/browsers@2.11.0":
version "2.11.0"
resolved "https://registry.yarnpkg.com/@puppeteer/browsers/-/browsers-2.11.0.tgz#b2dcd7cb02dd2de5909531d00e717a04bd61de73"
integrity sha512-n6oQX6mYkG8TRPuPXmbPidkUbsSRalhmaaVAQxvH1IkQy63cwsH+kOjB3e4cpCDHg0aSvsiX9bQ4s2VB6mGWUQ==
dependencies:
debug "^4.4.3"
extract-zip "^2.0.1"
@@ -1530,10 +1530,10 @@
"@resvg/resvg-js-win32-ia32-msvc" "2.4.1"
"@resvg/resvg-js-win32-x64-msvc" "2.4.1"
"@rolldown/pluginutils@1.0.0-beta.47":
version "1.0.0-beta.47"
resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz#c282c4a8c39f3d6d2f1086aae09a34e6241f7a50"
integrity sha512-8QagwMH3kNCuzD8EWL8R2YPW5e4OrHNSAHRFDdmFqEwEaD/KcNKjVoumo+gP2vW5eKB2UPbM6vTYiGZX0ixLnw==
"@rolldown/pluginutils@1.0.0-beta.53":
version "1.0.0-beta.53"
resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz#c57a5234ae122671aff6fe72e673a7ed90f03f87"
integrity sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==
"@rollup/rollup-android-arm-eabi@4.49.0":
version "4.49.0"
@@ -1658,57 +1658,57 @@
"@sendgrid/client" "^8.1.5"
"@sendgrid/helpers" "^8.0.0"
"@tiptap/core@^3.1.0":
version "3.10.7"
resolved "https://registry.yarnpkg.com/@tiptap/core/-/core-3.10.7.tgz#3e56d68d2a8f7e686b31261c720052a580d1d5c0"
integrity sha512-4rD3oHkXNOS6Fxm0mr+ECyq35iMFnnAXheIO+UsQbOexwTxn2yZ5Q1rQiFKcCf+p+rrg1yt8TtxQPM8VLWS+1g==
"@tiptap/core@^3.10.7":
version "3.11.0"
resolved "https://registry.yarnpkg.com/@tiptap/core/-/core-3.11.0.tgz#122a1db7852c9cea48221290210e713bb4efd66e"
integrity sha512-kmS7ZVpHm1EMnW1Wmft9H5ZLM7E0G0NGBx+aGEHGDcNxZBXD2ZUa76CuWjIhOGpwsPbELp684ZdpF2JWoNi4Dg==
"@tiptap/extension-bubble-menu@^3.10.7":
version "3.10.7"
resolved "https://registry.yarnpkg.com/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.10.7.tgz#0393b889a6ad29ab1b6ac08542d47cd8b05da626"
integrity sha512-ezsNpClKQ4Bq6R+Y/jGcmxhSBuYYOCGXV72yy3SlX1w6seA/I8h27ktWy9zAD2RPX560NzpZEyBjaASL3961sQ==
"@tiptap/extension-bubble-menu@^3.11.0":
version "3.11.0"
resolved "https://registry.yarnpkg.com/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.11.0.tgz#2ce7820c9aecd0f4ce36c2668353aa8194ea55a5"
integrity sha512-P3j9lQ+EZ5Zg/isJzLpCPX7bp7WUBmz8GPs/HPlyMyN2su8LqXntITBZr8IP1JNBlB/wR83k/W0XqdC57mG7cA==
dependencies:
"@floating-ui/dom" "^1.0.0"
"@tiptap/extension-document@^3.3.0":
version "3.10.7"
resolved "https://registry.yarnpkg.com/@tiptap/extension-document/-/extension-document-3.10.7.tgz#c2e179785dafc778af5842740a2c04153a352912"
integrity sha512-RlezqyAf0voUblrMLArh+AZJ9t+rE6buFa+U1V37Ey+I1z+Y8pPqlhtYJoTUz0GtSZWMReirSvoQpQJHM9x3Yw==
"@tiptap/extension-document@^3.10.7":
version "3.11.0"
resolved "https://registry.yarnpkg.com/@tiptap/extension-document/-/extension-document-3.11.0.tgz#fa4ed625730dcfbb5ea35a630f9163d6843adfed"
integrity sha512-N2G3cwL2Dtur/CgD/byJmFx9T5no6fTO/U462VP3rthQYrRA1AB3TCYqtlwJkmyoxRTNd4qIg4imaPl8ej6Heg==
"@tiptap/extension-floating-menu@^3.10.7":
version "3.10.7"
resolved "https://registry.yarnpkg.com/@tiptap/extension-floating-menu/-/extension-floating-menu-3.10.7.tgz#d147fcde8961453c0b3d50693a7f1cc98345dccd"
integrity sha512-yuTIGDbx0Q2IWOUrkhVQ/i1fU0Qi+8fCS8jkGB34/+3nbhtqXNYfFajpeaU9rkcCJqXH4aiFJdSGy44kCnYP2g==
"@tiptap/extension-floating-menu@^3.11.0":
version "3.11.0"
resolved "https://registry.yarnpkg.com/@tiptap/extension-floating-menu/-/extension-floating-menu-3.11.0.tgz#521109d9c0d5f6dc5fb6f2fd8181367af8a91be2"
integrity sha512-nEHdWZHEJYX1II1oJQ4aeZ8O/Kss4BRbYFXQFGIvPelCfCYEATpUJh3aq3767ARSq40bOWyu+Dcd4SCW0We6Sw==
"@tiptap/extension-hard-break@^3.3.0":
version "3.10.7"
resolved "https://registry.yarnpkg.com/@tiptap/extension-hard-break/-/extension-hard-break-3.10.7.tgz#34e7c432058ba66a3432232f76d6a3f08015ae1d"
integrity sha512-EIdTsD2pV4FSef/6nrKlXV8H5861PElnIjuoHkwk1alowAVL/HSvJqPxZwH6k2qLcsabkr0cSdaDixw9gJGAdg==
"@tiptap/extension-hard-break@^3.10.7":
version "3.11.0"
resolved "https://registry.yarnpkg.com/@tiptap/extension-hard-break/-/extension-hard-break-3.11.0.tgz#a0d7c5564c4fed1c4446c53f924ff2e468e157cb"
integrity sha512-NJEHTj++kFOayQXKSQSi9j9eAG33eSiJqai2pf4U+snW94fmb8cYLUurDmfYRe20O6EzBSX0X3GjVlkOz+5b7A==
"@tiptap/extension-mention@^3.1.0":
version "3.10.7"
resolved "https://registry.yarnpkg.com/@tiptap/extension-mention/-/extension-mention-3.10.7.tgz#06fd050c8424239b54e34a5c4ef89ee56fd77f0f"
integrity sha512-XzHJ7Pgj8uC9QO1PO2Q+yoczupJhaoiXqtVegCaiTJHwzOmdEg20WK5/fYrNNI/3NdS9cEBka1dccdvkT3+a2A==
"@tiptap/extension-mention@^3.10.7":
version "3.11.0"
resolved "https://registry.yarnpkg.com/@tiptap/extension-mention/-/extension-mention-3.11.0.tgz#0edf8171587fba2658cf8ee379e7687018c0063b"
integrity sha512-4y789hKNEvZoNals7PNSGAKThQ+b5nuP/KIEe4wPIfzknjwxzGi0f2YY3L/f+gIhueoZymYpkmhtiRND+wvAWA==
"@tiptap/extension-paragraph@^3.3.0":
version "3.10.7"
resolved "https://registry.yarnpkg.com/@tiptap/extension-paragraph/-/extension-paragraph-3.10.7.tgz#f751b4c8c7991747a3f5899fa39a7c197fbd92bc"
integrity sha512-53+nCxNaKcmeqQ+aWrSauEWywuWPp8qkUTOO2rHlpmM+rk/1bv3IZePKQ2JtHZzYCeRd3xOC33kl60HE7EwakQ==
"@tiptap/extension-paragraph@^3.10.7":
version "3.11.0"
resolved "https://registry.yarnpkg.com/@tiptap/extension-paragraph/-/extension-paragraph-3.11.0.tgz#60ecdcb24330b39f72b58760bcaf299b20de43da"
integrity sha512-hxgjZOXOqstRTWv+QjWJjK23rD5qzIV9ePlhX3imLeq/MgX0aU9VBDaG5SGKbSjaBNQnpLw6+sABJi3CDP6Z5A==
"@tiptap/extension-text@^3.3.0":
version "3.10.7"
resolved "https://registry.yarnpkg.com/@tiptap/extension-text/-/extension-text-3.10.7.tgz#3a9f4f104362012e84da4f2751f52c02ec385106"
integrity sha512-b7Rjil/uqiabWnRHyd1P84rWD2XRyZZSrmIAO9mDMD/jB2bE+f7rDJcHG76GF03UicDhEEEf2/8mz0dMLa6mUA==
"@tiptap/extension-text@^3.10.7":
version "3.11.0"
resolved "https://registry.yarnpkg.com/@tiptap/extension-text/-/extension-text-3.11.0.tgz#cf55c8c0fa3a18fbc93ec53be7c31fe60ed4e9bd"
integrity sha512-ELAYm2BuChzZOqDG9B0k3W6zqM4pwNvXkam28KgHGiT2y7Ni68Rb+NXp16uVR+5zR6hkqnQ/BmJSKzAW59MXpA==
"@tiptap/extensions@^3.1.0":
version "3.10.7"
resolved "https://registry.yarnpkg.com/@tiptap/extensions/-/extensions-3.10.7.tgz#56f2b2ae58d216bcfcc6c3554c52c454ae3ebe5c"
integrity sha512-jYYR7NA7t2hdyJmSLYVAJ3usyIOZ2mfFqPCCHbSn/k3jqmGaPFZuxJSwmYjfmTxisZ9rGn+49/YJF2y/Yej/0Q==
"@tiptap/extensions@^3.10.7":
version "3.11.0"
resolved "https://registry.yarnpkg.com/@tiptap/extensions/-/extensions-3.11.0.tgz#d6f6020312cda743738bbc1e918cd10a7f7d84fc"
integrity sha512-g43beA73ZMLezez1st9LEwYrRHZ0FLzlsSlOZKk7sdmtHLmuqWHf4oyb0XAHol1HZIdGv104rYaGNgmQXr1ecQ==
"@tiptap/pm@^3.1.0":
version "3.10.7"
resolved "https://registry.yarnpkg.com/@tiptap/pm/-/pm-3.10.7.tgz#d7028d96824e555f78e1b4490107e9db72eb53b4"
integrity sha512-/iiurioqSukJk6CrEtfRpdOEafDybyVPToAllgn7i2XcusXSxJSX+K0GUndMUwVR+UqVOCyMYBTRTnE0hdQqgA==
"@tiptap/pm@^3.10.7":
version "3.11.0"
resolved "https://registry.yarnpkg.com/@tiptap/pm/-/pm-3.11.0.tgz#c9d2bef0db08a5a5b2c6cce035fe893a475ee638"
integrity sha512-plCQDLCZIOc92cizB8NNhBRN0szvYR3cx9i5IXo6v9Xsgcun8KHNcJkesc2AyeqdIs0BtOJZaqQ9adHThz8UDw==
dependencies:
prosemirror-changeset "^2.3.0"
prosemirror-collab "^1.3.1"
@@ -1729,17 +1729,17 @@
prosemirror-transform "^1.10.2"
prosemirror-view "^1.38.1"
"@tiptap/react@^3.1.0":
version "3.10.7"
resolved "https://registry.yarnpkg.com/@tiptap/react/-/react-3.10.7.tgz#cfd2ade1c6db316136bac46457c394a1e09a80c7"
integrity sha512-hhKj62zvs/mSu5HlcmZDRFHVHCjJ6v6/7vB45MTAziP+cZ0+CEbEh2rnGNRNwooumWwm5pWdkVqI1efp7GtnUA==
"@tiptap/react@^3.10.7":
version "3.11.0"
resolved "https://registry.yarnpkg.com/@tiptap/react/-/react-3.11.0.tgz#b9dd344101cd64df45cb7a5785f98c7d3a689f72"
integrity sha512-SDGei/2DjwmhzsxIQNr6dkB6NxLgXZjQ6hF36NfDm4937r5NLrWrNk5tCsoDQiKZ0DHEzuJ6yZM5C7I7LZLB6w==
dependencies:
"@types/use-sync-external-store" "^0.0.6"
fast-deep-equal "^3.1.3"
use-sync-external-store "^1.4.0"
optionalDependencies:
"@tiptap/extension-bubble-menu" "^3.10.7"
"@tiptap/extension-floating-menu" "^3.10.7"
"@tiptap/extension-bubble-menu" "^3.11.0"
"@tiptap/extension-floating-menu" "^3.11.0"
"@tootallnate/quickjs-emscripten@^0.23.0":
version "0.23.0"
@@ -1937,30 +1937,30 @@
resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz#d06bbb384ebcf6c505fde1c3d0ed4ddffe0aaff8"
integrity sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==
"@visactor/react-vchart@^2.0.8":
version "2.0.8"
resolved "https://registry.yarnpkg.com/@visactor/react-vchart/-/react-vchart-2.0.8.tgz#4afdc9e41e13a5544edd1bdc58a961f53f9f4314"
integrity sha512-/O7dqHp/5CL7Q58eFrnyKfxnBvE/RTGKEoEJXSqyJNNB1JDiPplz10TvlDoz+cmsUCsfoC/Apaj4QIxewXOKqQ==
"@visactor/react-vchart@^2.0.10":
version "2.0.10"
resolved "https://registry.yarnpkg.com/@visactor/react-vchart/-/react-vchart-2.0.10.tgz#103e8f555a56a6f134bd2f5136480b3912153617"
integrity sha512-OGpSBT7kutZZKSw3HlxivsmeqRZ6GEOXAxt20+hcZyeH34yqRHklksJYS6ET9E9uivbfrGzIAPniC4iizR8lhQ==
dependencies:
"@visactor/vchart" "2.0.8"
"@visactor/vchart-extension" "2.0.8"
"@visactor/vrender-core" "~1.0.24"
"@visactor/vrender-kits" "~1.0.24"
"@visactor/vchart" "2.0.10"
"@visactor/vchart-extension" "2.0.10"
"@visactor/vrender-core" "~1.0.30"
"@visactor/vrender-kits" "~1.0.30"
"@visactor/vutils" "~1.0.12"
react-is "^18.2.0"
"@visactor/vchart-extension@2.0.8":
version "2.0.8"
resolved "https://registry.yarnpkg.com/@visactor/vchart-extension/-/vchart-extension-2.0.8.tgz#8fffc4a42920cf2ec31714a262b88dc376474ee5"
integrity sha512-zq6Wc7d9LIhdGRdmhK9I9k+6uNsB7AMwgMUWQID3/rU9y5rgwmqRGOwQWV7h9UFFmI5iUXxVFdSTbHSdVRQ84A==
"@visactor/vchart-extension@2.0.10":
version "2.0.10"
resolved "https://registry.yarnpkg.com/@visactor/vchart-extension/-/vchart-extension-2.0.10.tgz#1fab69e983ba4a743f2b1a7a53ef5cd3f820cfb4"
integrity sha512-D/WDodDPtDFtMRXYEm9GNC/Qthu4WkMXMR4hS43dmqA2GYOcAB5/O3CfUFwm+efwBh2EEh46O3STEam20444og==
dependencies:
"@visactor/vchart" "2.0.8"
"@visactor/vchart" "2.0.10"
"@visactor/vdataset" "~1.0.12"
"@visactor/vlayouts" "~1.0.12"
"@visactor/vrender-animate" "~1.0.24"
"@visactor/vrender-components" "~1.0.24"
"@visactor/vrender-core" "~1.0.24"
"@visactor/vrender-kits" "~1.0.24"
"@visactor/vrender-animate" "~1.0.30"
"@visactor/vrender-components" "~1.0.30"
"@visactor/vrender-core" "~1.0.30"
"@visactor/vrender-kits" "~1.0.30"
"@visactor/vutils" "~1.0.12"
"@visactor/vchart-semi-theme@^1.12.2":
@@ -1975,20 +1975,20 @@
resolved "https://registry.yarnpkg.com/@visactor/vchart-theme-utils/-/vchart-theme-utils-1.12.2.tgz#bad0035e79dabbe80890bbd6196668551a12c874"
integrity sha512-PkgSAivtUZukCWVUGCXxKcbTzI/oMj1Ky22VYcVs/KM4VFmmCywU2xjBBe1du0LUey6CAKB7bMlj5bL2jctG0A==
"@visactor/vchart@2.0.8", "@visactor/vchart@^2.0.8":
version "2.0.8"
resolved "https://registry.yarnpkg.com/@visactor/vchart/-/vchart-2.0.8.tgz#10d78f5571781b7bda7294504e028fb2223947e3"
integrity sha512-OyP5LBBTrXOjiauoWdUXW4W5iKLencsASvKqBw3BE6qwHbRrBgo6k5OgyUv3Gt+4jlEZ+PeD+gqnFiWUb4xtJw==
"@visactor/vchart@2.0.10", "@visactor/vchart@^2.0.10":
version "2.0.10"
resolved "https://registry.yarnpkg.com/@visactor/vchart/-/vchart-2.0.10.tgz#a357cfe8ab33c59d743c902920e9e7489268dd89"
integrity sha512-YKOdrU08CVB7851UaxPWRC2+9B0zydzwps2Kdpm4fa8t/KCAn/tfL1/jLdhoTu5brk5UI68w414MX8OsjVosSQ==
dependencies:
"@visactor/vdataset" "~1.0.12"
"@visactor/vlayouts" "~1.0.12"
"@visactor/vrender-animate" "~1.0.24"
"@visactor/vrender-components" "~1.0.24"
"@visactor/vrender-core" "~1.0.24"
"@visactor/vrender-kits" "~1.0.24"
"@visactor/vrender-animate" "~1.0.30"
"@visactor/vrender-components" "~1.0.30"
"@visactor/vrender-core" "~1.0.30"
"@visactor/vrender-kits" "~1.0.30"
"@visactor/vscale" "~1.0.12"
"@visactor/vutils" "~1.0.12"
"@visactor/vutils-extension" "2.0.8"
"@visactor/vutils-extension" "2.0.10"
"@visactor/vdataset@~1.0.12":
version "1.0.16"
@@ -2024,44 +2024,44 @@
"@visactor/vutils" "1.0.16"
eventemitter3 "^4.0.7"
"@visactor/vrender-animate@1.0.24", "@visactor/vrender-animate@~1.0.24":
version "1.0.24"
resolved "https://registry.yarnpkg.com/@visactor/vrender-animate/-/vrender-animate-1.0.24.tgz#928b8d0272b4b43bcd9588417d6e4f62eafc1f52"
integrity sha512-XGTzM0r9bObs6MQ9u0IJ29Oxr1h9eKW6QzSppMnhXtQhiPGFzppp6SiRosI+Gjq0FAR/vmHTeu2C6tWZPDwrcg==
"@visactor/vrender-animate@1.0.31", "@visactor/vrender-animate@~1.0.30":
version "1.0.31"
resolved "https://registry.yarnpkg.com/@visactor/vrender-animate/-/vrender-animate-1.0.31.tgz#4f25203a2073eecbb05dc20af4718b45107bdd12"
integrity sha512-9xA9B8JihlsEfBziFUHdUGUizh6xRk07lejUk4f0+qGmGwMPvz32btszs2gw1eF9J6FVmBhxJdZILzcXz4ha0Q==
dependencies:
"@visactor/vrender-core" "1.0.24"
"@visactor/vrender-core" "1.0.31"
"@visactor/vutils" "~1.0.12"
"@visactor/vrender-components@~1.0.24":
version "1.0.24"
resolved "https://registry.yarnpkg.com/@visactor/vrender-components/-/vrender-components-1.0.24.tgz#6bfb93fa9f6b8d6f0947a15cd8b286cc475d6202"
integrity sha512-GwtRWUuaVw7HJM/GTA3XY/6kjyHzCi10yE4tUSuvrytF2yLdOO+yG920B1nV+rBZGpKgyTpdJmszngQ1RZN4BQ==
"@visactor/vrender-components@~1.0.30":
version "1.0.31"
resolved "https://registry.yarnpkg.com/@visactor/vrender-components/-/vrender-components-1.0.31.tgz#76445c52150e6e4c6f2e23a0afc19afdfe4cc0af"
integrity sha512-kB+ZdqCnfcmoLHleGPml/NmqgXViC0Vqk/64XzQzXd5fvFqCrgS1G6mpq+VrZjZhJx3gxCTQoct6q2qXN1aYIw==
dependencies:
"@visactor/vrender-animate" "1.0.24"
"@visactor/vrender-core" "1.0.24"
"@visactor/vrender-kits" "1.0.24"
"@visactor/vrender-animate" "1.0.31"
"@visactor/vrender-core" "1.0.31"
"@visactor/vrender-kits" "1.0.31"
"@visactor/vscale" "~1.0.12"
"@visactor/vutils" "~1.0.12"
"@visactor/vrender-core@1.0.24", "@visactor/vrender-core@~1.0.24":
version "1.0.24"
resolved "https://registry.yarnpkg.com/@visactor/vrender-core/-/vrender-core-1.0.24.tgz#0efd7717796cb1dc91898b90246101fbd15d8f2a"
integrity sha512-npcXOil6cyP2pLXk1L9XwVyHDGw7eNnjUEtpwUBn34pyI+d1IWJ8hi19IaBSsl3uzc/qfu9MPXiiHGGoTLzH0A==
"@visactor/vrender-core@1.0.31", "@visactor/vrender-core@~1.0.30":
version "1.0.31"
resolved "https://registry.yarnpkg.com/@visactor/vrender-core/-/vrender-core-1.0.31.tgz#995551e4d519dfd0d71dbd27aa1112ff4e1125c3"
integrity sha512-4tzN2M5GfI7612IHRiDqUetAjd3J3Ns5gHQxQvmMxismdw7UTrlB8PgnWx9djTYwoxZnhNX0MpPGz9CKgbb7RA==
dependencies:
"@visactor/vutils" "~1.0.12"
color-convert "2.0.1"
"@visactor/vrender-kits@1.0.24", "@visactor/vrender-kits@~1.0.24":
version "1.0.24"
resolved "https://registry.yarnpkg.com/@visactor/vrender-kits/-/vrender-kits-1.0.24.tgz#61f9535340fbbf88cdc2e617f6c8210f54528613"
integrity sha512-4gaZtCxHXPx4njJq417UfiPp9WbtUsPOJ+NyenhqLghdQXcqI8t1GfrXg2QiGcmqSSn8XDsHsQL6poZR1+wdVw==
"@visactor/vrender-kits@1.0.31", "@visactor/vrender-kits@~1.0.30":
version "1.0.31"
resolved "https://registry.yarnpkg.com/@visactor/vrender-kits/-/vrender-kits-1.0.31.tgz#3c57cd84b48208fe6c7a19f4f7f05ba60bca4409"
integrity sha512-ZS1vslveNfK6MrkL0tMIzQWT9G/q+P7201nNA55YM89N1Tzpzm0X55YHQiIx9TnWTWQijMlqkz1PR5n3Xh+Hjg==
dependencies:
"@resvg/resvg-js" "2.4.1"
"@visactor/vrender-core" "1.0.24"
"@visactor/vrender-core" "1.0.31"
"@visactor/vutils" "~1.0.12"
gifuct-js "2.1.2"
lottie-web "^5.12.2"
roughjs "4.5.2"
roughjs "4.6.6"
"@visactor/vscale@1.0.16", "@visactor/vscale@~1.0.12":
version "1.0.16"
@@ -2070,10 +2070,10 @@
dependencies:
"@visactor/vutils" "1.0.16"
"@visactor/vutils-extension@2.0.8":
version "2.0.8"
resolved "https://registry.yarnpkg.com/@visactor/vutils-extension/-/vutils-extension-2.0.8.tgz#969380d2517358ac9b7702aa39b0a7a98a9bd99e"
integrity sha512-NzA1HRH9VnoiR5q2313+undU7IawdZozaytslE0HKoJmr3I7z6Eod7yDO8nQ7/4ssYboMIzvYNXgakzo3yJClA==
"@visactor/vutils-extension@2.0.10":
version "2.0.10"
resolved "https://registry.yarnpkg.com/@visactor/vutils-extension/-/vutils-extension-2.0.10.tgz#512a65286cef0307bbddab06628da538021a043f"
integrity sha512-XKps9vwm3rLLuP/oY9CHP1RZMmnXroT1l1yi+Ny+CD6NUaZpe0dlAcXgtsS6j0NztUA9KmIHol7Rnv5hhFz/iA==
dependencies:
"@visactor/vdataset" "~1.0.12"
"@visactor/vutils" "~1.0.12"
@@ -2087,15 +2087,15 @@
"@turf/invariant" "^6.5.0"
eventemitter3 "^4.0.7"
"@vitejs/plugin-react@5.1.1":
version "5.1.1"
resolved "https://registry.yarnpkg.com/@vitejs/plugin-react/-/plugin-react-5.1.1.tgz#fa1957e053fe90d7cc2deea5593ae382a9761595"
integrity sha512-WQfkSw0QbQ5aJ2CHYw23ZGkqnRwqKHD/KYsMeTkZzPT4Jcf0DcBxBtwMJxnu6E7oxw5+JC6ZAiePgh28uJ1HBA==
"@vitejs/plugin-react@5.1.2":
version "5.1.2"
resolved "https://registry.yarnpkg.com/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz#46f47be184c05a18839cb8705d79578b469ac6eb"
integrity sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==
dependencies:
"@babel/core" "^7.28.5"
"@babel/plugin-transform-react-jsx-self" "^7.27.1"
"@babel/plugin-transform-react-jsx-source" "^7.27.1"
"@rolldown/pluginutils" "1.0.0-beta.47"
"@rolldown/pluginutils" "1.0.0-beta.53"
"@types/babel__core" "^7.20.5"
react-refresh "^0.18.0"
@@ -2393,10 +2393,10 @@ basic-ftp@^5.0.2:
resolved "https://registry.yarnpkg.com/basic-ftp/-/basic-ftp-5.0.5.tgz#14a474f5fffecca1f4f406f1c26b18f800225ac0"
integrity sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==
better-sqlite3@^12.4.1:
version "12.4.1"
resolved "https://registry.yarnpkg.com/better-sqlite3/-/better-sqlite3-12.4.1.tgz#f78df6c80530d1a0b750b538033e6199b7d30d26"
integrity sha512-3yVdyZhklTiNrtg+4WqHpJpFDd+WHTg2oM7UcR80GqL05AOV0xEJzc6qNvFYoEtE+hRp1n9MpN6/+4yhlGkDXQ==
better-sqlite3@^12.5.0:
version "12.5.0"
resolved "https://registry.yarnpkg.com/better-sqlite3/-/better-sqlite3-12.5.0.tgz#c570873d9635b5d56baa52f7e72634c2c589f35f"
integrity sha512-WwCZ/5Diz7rsF29o27o0Gcc1Du+l7Zsv7SYtVPG0X3G/uUI1LqdxrQI7c9Hs2FWpqXXERjW9hp6g3/tH7DlVKg==
dependencies:
bindings "^1.5.0"
prebuild-install "^7.1.1"
@@ -2432,20 +2432,20 @@ bl@^4.0.3:
inherits "^2.0.4"
readable-stream "^3.4.0"
body-parser@2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-2.2.0.tgz#f7a9656de305249a715b549b7b8fd1ab9dfddcfa"
integrity sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==
body-parser@2.2.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-2.2.1.tgz#6df606b0eb0a6e3f783dde91dde182c24c82438c"
integrity sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==
dependencies:
bytes "^3.1.2"
content-type "^1.0.5"
debug "^4.4.0"
debug "^4.4.3"
http-errors "^2.0.0"
iconv-lite "^0.6.3"
iconv-lite "^0.7.0"
on-finished "^2.4.1"
qs "^6.14.0"
raw-body "^3.0.0"
type-is "^2.0.0"
raw-body "^3.0.1"
type-is "^2.0.1"
boolbase@^1.0.0:
version "1.0.0"
@@ -2507,7 +2507,7 @@ buffer@^5.5.0:
base64-js "^1.3.1"
ieee754 "^1.1.13"
bytes@3.1.2, bytes@^3.1.2:
bytes@^3.1.2, bytes@~3.1.2:
version "3.1.2"
resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5"
integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==
@@ -2739,10 +2739,10 @@ commander@2:
resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
commander@^14.0.1:
version "14.0.1"
resolved "https://registry.yarnpkg.com/commander/-/commander-14.0.1.tgz#2f9225c19e6ebd0dc4404dd45821b2caa17ea09b"
integrity sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A==
commander@^14.0.2:
version "14.0.2"
resolved "https://registry.yarnpkg.com/commander/-/commander-14.0.2.tgz#b71fd37fe4069e4c3c7c13925252ada4eba14e8e"
integrity sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==
compute-scroll-into-view@^1.0.20:
version "1.0.20"
@@ -2957,7 +2957,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.0, debug@^4.4.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:
version "4.4.1"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.1.tgz#e5a8bc6cbc4c6cd3e64308b0693a3d4fa550189b"
integrity sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==
@@ -3064,10 +3064,10 @@ devlop@^1.0.0, devlop@^1.1.0:
dependencies:
dequal "^2.0.0"
devtools-protocol@0.0.1521046:
version "0.0.1521046"
resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.1521046.tgz#918e6175ea83100fefcb2b78779f15a77aa8a41b"
integrity sha512-vhE6eymDQSKWUXwwA37NtTTVEzjtGVfDr3pRbsWEQ5onH/Snp2c+2xZHWJJawG/0hCCJLRGt4xVtEVUVILol4w==
devtools-protocol@0.0.1534754:
version "0.0.1534754"
resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.1534754.tgz#75fb0496ff133d8d7e73d2e49600b37fcb4f46a9"
integrity sha512-26T91cV5dbOYnXdJi5qQHoTtUoNEqwkHcAyu/IKtjIAxiEqPMrDiRkDOPWVsGfNZGmlQVHQbZRSjD8sxagWVsQ==
diff@^7.0.0:
version "7.0.0"
@@ -4048,6 +4048,11 @@ graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0:
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3"
integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==
hachure-fill@^0.5.2:
version "0.5.2"
resolved "https://registry.yarnpkg.com/hachure-fill/-/hachure-fill-0.5.2.tgz#d19bc4cc8750a5962b47fb1300557a85fcf934cc"
integrity sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==
handlebars@4.7.8:
version "4.7.8"
resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.8.tgz#41c42c18b1be2365439188c77c6afae71c0cd9e9"
@@ -4180,7 +4185,7 @@ htmlparser2@^10.0.0:
domutils "^3.2.1"
entities "^6.0.0"
http-errors@2.0.0, http-errors@^2.0.0:
http-errors@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3"
integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==
@@ -4191,6 +4196,17 @@ http-errors@2.0.0, http-errors@^2.0.0:
statuses "2.0.1"
toidentifier "1.0.1"
http-errors@~2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.1.tgz#36d2f65bc909c8790018dd36fb4d93da6caae06b"
integrity sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==
dependencies:
depd "~2.0.0"
inherits "~2.0.4"
setprototypeof "~1.2.0"
statuses "~2.0.2"
toidentifier "~1.0.1"
http-proxy-agent@^7.0.0, http-proxy-agent@^7.0.1:
version "7.0.2"
resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz#9a8b1f246866c028509486585f62b8f2c18c270e"
@@ -4226,6 +4242,13 @@ iconv-lite@0.6.3, iconv-lite@^0.6.3:
dependencies:
safer-buffer ">= 2.1.2 < 3.0.0"
iconv-lite@^0.7.0, iconv-lite@~0.7.0:
version "0.7.0"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.7.0.tgz#c50cd80e6746ca8115eb98743afa81aa0e147a3e"
integrity sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==
dependencies:
safer-buffer ">= 2.1.2 < 3.0.0"
ieee754@^1.1.12, ieee754@^1.1.13:
version "1.2.1"
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
@@ -4267,7 +4290,7 @@ inflight@^1.0.4:
once "^1.3.0"
wrappy "1"
inherits@2, inherits@2.0.4, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.1:
inherits@2, inherits@2.0.4, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.1, inherits@~2.0.4:
version "2.0.4"
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
@@ -4771,12 +4794,12 @@ linkify-it@^5.0.0:
dependencies:
uc.micro "^2.0.0"
lint-staged@16.2.6:
version "16.2.6"
resolved "https://registry.yarnpkg.com/lint-staged/-/lint-staged-16.2.6.tgz#760675e80f4b53337083d3f8bdecdd1f88079bf5"
integrity sha512-s1gphtDbV4bmW1eylXpVMk2u7is7YsrLl8hzrtvC70h4ByhcMLZFY01Fx05ZUDNuv1H8HO4E+e2zgejV1jVwNw==
lint-staged@16.2.7:
version "16.2.7"
resolved "https://registry.yarnpkg.com/lint-staged/-/lint-staged-16.2.7.tgz#c4a635960c17b52fe774f1f40aee8ce1bd86531f"
integrity sha512-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow==
dependencies:
commander "^14.0.1"
commander "^14.0.2"
listr2 "^9.0.5"
micromatch "^4.0.8"
nano-spawn "^2.0.0"
@@ -6107,10 +6130,10 @@ prelude-ls@^1.2.1:
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"
integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==
prettier@3.6.2:
version "3.6.2"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.6.2.tgz#ccda02a1003ebbb2bfda6f83a074978f608b9393"
integrity sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==
prettier@3.7.4:
version "3.7.4"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.7.4.tgz#d2f8335d4b1cec47e1c8098645411b0c9dff9c0f"
integrity sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==
prismjs@^1.29.0:
version "1.30.0"
@@ -6342,17 +6365,17 @@ punycode@^2.1.0:
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
puppeteer-core@24.30.0:
version "24.30.0"
resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-24.30.0.tgz#7d0d15ce6aee4f1aa8a8f046bf0198f025ee6c81"
integrity sha512-2S3Smy0t0W4wJnNvDe7W0bE7wDmZjfZ3ljfMgJd6hn2Hq/f0jgN+x9PULZo2U3fu5UUIJ+JP8cNUGllu8P91Pg==
puppeteer-core@24.32.1:
version "24.32.1"
resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-24.32.1.tgz#def7d96620d3460cb36a6e0dcd79cb897c19af30"
integrity sha512-GdWTOgy3RqaW6Etgx93ydlVJ4FBJ6TmhMksG5W7v4uawKAzLHNj33k4kBQ1SFZ9NvoXNjhdQuIQ+uik2kWnarA==
dependencies:
"@puppeteer/browsers" "2.10.13"
"@puppeteer/browsers" "2.11.0"
chromium-bidi "11.0.0"
debug "^4.4.3"
devtools-protocol "0.0.1521046"
devtools-protocol "0.0.1534754"
typed-query-selector "^2.12.0"
webdriver-bidi-protocol "0.3.8"
webdriver-bidi-protocol "0.3.9"
ws "^8.18.3"
puppeteer-extra-plugin-stealth@^2.11.2:
@@ -6402,16 +6425,16 @@ puppeteer-extra@^3.3.6:
debug "^4.1.1"
deepmerge "^4.2.2"
puppeteer@^24.30.0:
version "24.30.0"
resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-24.30.0.tgz#26ed830277d23c43fdc30104226d117be19e1a3d"
integrity sha512-A5OtCi9WpiXBQgJ2vQiZHSyrAzQmO/WDsvghqlN4kgw21PhxA5knHUaUQq/N3EMt8CcvSS0RM+kmYLJmedR3TQ==
puppeteer@^24.32.1:
version "24.32.1"
resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-24.32.1.tgz#d11e204138750eeada834fdd6746ffba30733655"
integrity sha512-wa8vGswFjH1iCyG6bGGydIYssEBluXixbMibK4x2x6/lIAuR87gF+c+Jjzom2Wiw/dDOtuki89VBurRWrgYaUA==
dependencies:
"@puppeteer/browsers" "2.10.13"
"@puppeteer/browsers" "2.11.0"
chromium-bidi "11.0.0"
cosmiconfig "^9.0.0"
devtools-protocol "0.0.1521046"
puppeteer-core "24.30.0"
devtools-protocol "0.0.1534754"
puppeteer-core "24.32.1"
typed-query-selector "^2.12.0"
qs@^6.14.0:
@@ -6442,15 +6465,15 @@ range-parser@^1.2.1:
resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031"
integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==
raw-body@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-3.0.0.tgz#25b3476f07a51600619dae3fe82ddc28a36e5e0f"
integrity sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==
raw-body@^3.0.1:
version "3.0.2"
resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-3.0.2.tgz#3e3ada5ae5568f9095d84376fd3a49b8fb000a51"
integrity sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==
dependencies:
bytes "3.1.2"
http-errors "2.0.0"
iconv-lite "0.6.3"
unpipe "1.0.0"
bytes "~3.1.2"
http-errors "~2.0.1"
iconv-lite "~0.7.0"
unpipe "~1.0.0"
rc@^1.2.7:
version "1.2.8"
@@ -6501,17 +6524,17 @@ react-resizable@^3.0.5:
prop-types "15.x"
react-draggable "^4.0.3"
react-router-dom@7.9.6:
version "7.9.6"
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.9.6.tgz#f2a0d12961d67bd87ab48e5ef42fa1f45beae357"
integrity sha512-2MkC2XSXq6HjGcihnx1s0DBWQETI4mlis4Ux7YTLvP67xnGxCvq+BcCQSO81qQHVUTM1V53tl4iVVaY5sReCOA==
react-router-dom@7.10.1:
version "7.10.1"
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.10.1.tgz#fddea814d30a3630c11d9ea539932482ff6f744c"
integrity sha512-JNBANI6ChGVjA5bwsUIwJk7LHKmqB4JYnYfzFwyp2t12Izva11elds2jx7Yfoup2zssedntwU0oZ5DEmk5Sdaw==
dependencies:
react-router "7.9.6"
react-router "7.10.1"
react-router@7.9.6:
version "7.9.6"
resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.9.6.tgz#003c8de335fdd7390286a478dcfd9579c1826137"
integrity sha512-Y1tUp8clYRXpfPITyuifmSoE2vncSME18uVLgaqyxh9H35JWpIfzHo+9y3Fzh5odk/jxPW29IgLgzcdwxGqyNA==
react-router@7.10.1:
version "7.10.1"
resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.10.1.tgz#e973146ed5f10a80783fdb3f27dbe37679557a7c"
integrity sha512-gHL89dRa3kwlUYtRQ+m8NmxGI6CgqN+k4XyGjwcFoQwwCWF6xXpOCUlDovkXClS0d0XJN/5q7kc5W3kiFEd0Yw==
dependencies:
cookie "^1.0.1"
set-cookie-parser "^2.6.0"
@@ -6831,11 +6854,12 @@ rope-sequence@^1.3.0:
resolved "https://registry.yarnpkg.com/rope-sequence/-/rope-sequence-1.3.4.tgz#df85711aaecd32f1e756f76e43a415171235d425"
integrity sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==
roughjs@4.5.2:
version "4.5.2"
resolved "https://registry.yarnpkg.com/roughjs/-/roughjs-4.5.2.tgz#aab644dcb41e9a75826c8bd5a5b0a859095f2f10"
integrity sha512-2xSlLDKdsWyFxrveYWk9YQ/Y9UfK38EAMRNkYkMqYBJvPX8abCa9PN0x3w02H8Oa6/0bcZICJU+U95VumPqseg==
roughjs@4.6.6:
version "4.6.6"
resolved "https://registry.yarnpkg.com/roughjs/-/roughjs-4.6.6.tgz#1059f49a5e0c80dee541a005b20cc322b222158b"
integrity sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==
dependencies:
hachure-fill "^0.5.2"
path-data-parser "^0.1.0"
points-on-curve "^0.2.0"
points-on-path "^0.2.1"
@@ -6992,7 +7016,7 @@ set-proto@^1.0.0:
es-errors "^1.3.0"
es-object-atoms "^1.0.0"
setprototypeof@1.2.0:
setprototypeof@1.2.0, setprototypeof@~1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424"
integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==
@@ -7197,7 +7221,7 @@ statuses@2.0.1:
resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63"
integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==
statuses@^2.0.1:
statuses@^2.0.1, statuses@~2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.2.tgz#8f75eecef765b5e1cfcdc080da59409ed424e382"
integrity sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==
@@ -7497,7 +7521,7 @@ to-regex-range@^5.0.1:
dependencies:
is-number "^7.0.0"
toidentifier@1.0.1:
toidentifier@1.0.1, toidentifier@~1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35"
integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==
@@ -7562,7 +7586,7 @@ type-check@^0.4.0, type-check@~0.4.0:
dependencies:
prelude-ls "^1.2.1"
type-is@^2.0.0:
type-is@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/type-is/-/type-is-2.0.1.tgz#64f6cf03f92fce4015c2b224793f6bdd4b068c97"
integrity sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==
@@ -7752,7 +7776,7 @@ universalify@^2.0.0:
resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.1.tgz#168efc2180964e6386d061e094df61afe239b18d"
integrity sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==
unpipe@1.0.0:
unpipe@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"
integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==
@@ -7808,10 +7832,10 @@ vfile@^6.0.0:
"@types/unist" "^3.0.0"
vfile-message "^4.0.0"
vite@7.2.2:
version "7.2.2"
resolved "https://registry.yarnpkg.com/vite/-/vite-7.2.2.tgz#17dd62eac2d0ca0fa90131c5f56e4fefb8845362"
integrity sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==
vite@7.2.7:
version "7.2.7"
resolved "https://registry.yarnpkg.com/vite/-/vite-7.2.7.tgz#0789a4c3206081699f34a9ecca2dda594a07478e"
integrity sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==
dependencies:
esbuild "^0.25.0"
fdir "^6.5.0"
@@ -7832,10 +7856,10 @@ web-streams-polyfill@^3.0.3:
resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz#2073b91a2fdb1fbfbd401e7de0ac9f8214cecb4b"
integrity sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==
webdriver-bidi-protocol@0.3.8:
version "0.3.8"
resolved "https://registry.yarnpkg.com/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.3.8.tgz#9c822b2647fd16d22b1b6fd730d4a3b863c93b93"
integrity sha512-21Yi2GhGntMc671vNBCjiAeEVknXjVRoyu+k+9xOMShu+ZQfpGQwnBqbNz/Sv4GXZ6JmutlPAi2nIJcrymAWuQ==
webdriver-bidi-protocol@0.3.9:
version "0.3.9"
resolved "https://registry.yarnpkg.com/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.3.9.tgz#89abf021f2a557a2dd81772f9ce7172b01f8a0f0"
integrity sha512-uIYvlRQ0PwtZR1EzHlTMol1G0lAlmOe6wPykF9a77AK3bkpvZHzIVxRE2ThOx5vjy2zISe0zhwf5rzuUfbo1PQ==
whatwg-encoding@^3.1.1:
version "3.1.1"
@@ -8039,10 +8063,10 @@ zod@^3.24.1:
resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.76.tgz#26841c3f6fd22a6a2760e7ccb719179768471e34"
integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==
zustand@^5.0.8:
version "5.0.8"
resolved "https://registry.yarnpkg.com/zustand/-/zustand-5.0.8.tgz#b998a0c088c7027a20f2709141a91cb07ac57f8a"
integrity sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==
zustand@^5.0.9:
version "5.0.9"
resolved "https://registry.yarnpkg.com/zustand/-/zustand-5.0.9.tgz#389dcd0309b9c545d7a461bd3c54955962847654"
integrity sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==
zwitch@^2.0.0:
version "2.0.4"