Compare commits

...

16 Commits

Author SHA1 Message Date
orangecoding
791822e7c8 next release version 2026-04-07 19:55:33 +02:00
Christian Kellner
cdc0cbda2f Feature/kleinanzeigen new (#292)
* Feature/Kleinanzeigen addresses (#289)

* upgrade dependencies

* immoscout_details -> provider_details

* fetching details more generic

* removing claude action

* fixing sparkassen selector

* improvements

* fixing immobilienDE test

* upgrading dependencies

* settings for many provider

---------

Co-authored-by: Adrian Bach <65734063+realDayaa@users.noreply.github.com>
2026-04-07 19:53:40 +02:00
Adrian Bach
7888c5b340 fix: broken filters (#294) 2026-04-04 12:26:34 +02:00
orangecoding
d7f46d6c68 security update 2026-03-31 13:33:01 +02:00
orangecoding
1c9d7c9d92 storing filter settings in url 2026-03-31 11:46:22 +02:00
orangecoding
bc73de6703 upgrade dependencies 2026-03-31 10:38:50 +02:00
orangecoding
568e0abfa1 fixing login not showing if username or password is incorrect 2026-03-31 09:18:49 +02:00
Stephan
3992a9c81c fix: maplibre-gl runtime errors in production build by isoliting it into a chunk (#288) 2026-03-31 09:14:49 +02:00
Christian Kellner
7346075b9d Add Claude Code GitHub Workflow (#285)
* "Claude PR Assistant workflow"

* "Claude Code Review workflow"
2026-03-24 08:40:04 +01:00
Christian Kellner
8c039f0026 UI improvements (#283)
* ui-improvements

* improving dashboard and settings

* improve job overview

* improving job card

* improving grid view of listings+

* restructuring settings

* next release version
2026-03-23 13:22:34 +01:00
orangecoding
a1289acf15 fixing some docker issues 2026-03-22 09:41:20 +01:00
orangecoding
8501fc7266 upgrading dependencies 2026-03-21 08:09:15 +01:00
orangecoding
4960846cd7 fixing docker run 2026-03-21 08:08:52 +01:00
orangecoding
3ed17f4442 fixing broken puppeteer providers in docker caused by alpine chromium 146 crashing / switched to debian slim with puppeteer's own chrome for testing / dropped 2-stage build / run as non-root / purge build tools after install, improve docker-test.sh to verify it all works. That's it. ;) 2026-03-20 19:19:20 +01:00
orangecoding
b531a7b77a fixing mcp issue, adding claude example 2026-03-20 13:45:42 +01:00
Adrian Bach
3523057221 feat: add smtp adapter (#279) 2026-03-20 11:37:28 +01:00
60 changed files with 2757 additions and 1400 deletions

View File

@@ -62,6 +62,7 @@ jobs:
- name: Test container with docker compose
run: |
echo "Starting container with docker compose..."
mkdir -p ./db ./conf && chmod 777 ./db ./conf
docker compose up --build -d
echo "Waiting for container to be ready (60 seconds for start_period)..."
sleep 60

View File

@@ -1,70 +1,55 @@
# ================================
# Stage 1: Build stage
# ================================
FROM node:22-alpine AS builder
FROM node:22-slim
WORKDIR /build
ARG TARGETARCH
# Install build dependencies needed for native modules (better-sqlite3)
RUN apk add --no-cache python3 make g++
# System deps for Chrome for Testing + build tools for native modules (better-sqlite3)
# On ARM64 we also install system Chromium (Chrome for Testing has no ARM64 binary)
RUN apt-get update && apt-get install -y --no-install-recommends \
curl ca-certificates fonts-liberation libasound2 \
libatk-bridge2.0-0 libatk1.0-0 libcups2 libdbus-1-3 \
libdrm2 libgbm1 libgtk-3-0 libnspr4 libnss3 \
libx11-xcb1 libxcomposite1 libxdamage1 libxrandr2 xdg-utils \
python3 make g++ \
&& if [ "$TARGETARCH" = "arm64" ]; then apt-get install -y --no-install-recommends chromium; fi \
&& rm -rf /var/lib/apt/lists/* \
&& mkdir -p /db /conf /fredy
WORKDIR /fredy
ENV NODE_ENV=production \
IS_DOCKER=true
# Copy package files first for better layer caching
COPY package.json yarn.lock ./
# Install all dependencies (including devDependencies for building)
# Install dependencies and purge build tools (only needed to compile better-sqlite3)
RUN yarn config set network-timeout 600000 \
&& yarn --frozen-lockfile
&& yarn --frozen-lockfile \
&& yarn cache clean
# on arm64 use the system Chromium installed above
RUN if [ "$TARGETARCH" != "arm64" ]; then npx puppeteer browsers install chrome; fi
# Purge build tools now that native modules are compiled
RUN apt-get purge -y python3 make g++ \
&& apt-get autoremove -y \
&& rm -rf /var/lib/apt/lists/*
# Copy source files needed for build
COPY index.html vite.config.js ./
COPY ui ./ui
COPY lib ./lib
# Build frontend assets
RUN yarn build:frontend
# ================================
# Stage 2: Production stage
# ================================
FROM node:22-alpine
WORKDIR /fredy
# Install Chromium and curl (for healthcheck)
# Using Alpine's chromium package which is much smaller
RUN apk add --no-cache chromium curl
ENV NODE_ENV=production \
IS_DOCKER=true \
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
# Install build dependencies for native modules, then remove them after yarn install
COPY package.json yarn.lock ./
RUN apk add --no-cache --virtual .build-deps python3 make g++ \
&& yarn config set network-timeout 600000 \
&& yarn --frozen-lockfile --production \
&& yarn cache clean \
&& apk del .build-deps
# Copy built frontend from builder stage
COPY --from=builder /build/ui/public ./ui/public
# Copy application source (only what's needed at runtime)
COPY index.js ./
COPY index.html ./
COPY lib ./lib
# Prepare runtime directories and symlinks for data and config
RUN mkdir -p /db /conf \
&& chown 1000:1000 /db /conf \
&& chmod 777 /db /conf \
&& ln -s /db /fredy/db \
RUN ln -s /db /fredy/db \
&& ln -s /conf /fredy/conf
EXPOSE 9998
VOLUME /db
VOLUME /conf
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD curl -f http://localhost:9998/ || exit 1
CMD ["node", "index.js"]

View File

@@ -7,12 +7,72 @@ if [ "$(docker ps -aq -f name=fredy)" ]; then
docker rm fredy || true
fi
# On Apple Silicon, force linux/amd64 to match production CI and avoid arm64/x86_64
# Chrome mismatch under Rosetta. On native Linux (amd64 or arm64) let Docker pick naturally. That took me fucking 1 hour to figure out.
PLATFORM=""
if [ "$(uname -m)" = "arm64" ] && [ "$(uname -s)" = "Darwin" ]; then
PLATFORM="linux/amd64"
fi
# Build image from local Dockerfile, forcing a fresh build without cache
docker build --no-cache -t fredy:local .
if [ -n "$PLATFORM" ]; then
docker build --no-cache --platform "$PLATFORM" -t fredy:local .
else
docker build --no-cache -t fredy:local .
fi
# Run container with volumes and port mapping
docker run -d --name fredy \
-v fredy_conf:/conf \
-v fredy_db:/db \
-p 9998:9998 \
fredy:local
if [ -n "$PLATFORM" ]; then
docker run -d --name fredy --platform "$PLATFORM" -v fredy_conf:/conf -v fredy_db:/db -p 9998:9998 fredy:local
else
docker run -d --name fredy -v fredy_conf:/conf -v fredy_db:/db -p 9998:9998 fredy:local
fi
echo "Waiting for app to be ready..."
for i in $(seq 1 30); do
if docker exec fredy curl -sf http://localhost:9998/ > /dev/null 2>&1; then
echo "App is up"
break
fi
if [ "$i" = "30" ]; then
echo "App did not come up in time"
docker logs fredy
exit 1
fi
sleep 2
done
# Verify the DB is readable/writable via the API.
# /api/demo is unauthenticated and reads the settings table — if SQLite is broken this returns an error.
echo "Testing DB via API (/api/demo)..."
DEMO_RESPONSE=$(docker exec fredy curl -sf http://localhost:9998/api/demo 2>&1)
if echo "$DEMO_RESPONSE" | grep -q "demoMode"; then
echo "DB is readable (got demoMode from /api/demo)"
else
echo "DB check failed — unexpected response from /api/demo: $DEMO_RESPONSE"
docker logs fredy
exit 1
fi
# Verify Chrome launches without crashing.
# On amd64: Chrome for Testing lives in the puppeteer cache.
# On arm64: system Chromium is used instead.
echo "Testing Chrome..."
CHROME=$(docker exec fredy find /root/.cache/puppeteer /home -name chrome -type f 2>/dev/null | head -1)
if [ -z "$CHROME" ]; then
CHROME=$(docker exec fredy which chromium 2>/dev/null || true)
fi
if [ -z "$CHROME" ]; then
echo "Chrome/Chromium binary not found"
exit 1
fi
if docker exec fredy "$CHROME" --headless --no-sandbox --disable-gpu --dump-dom https://example.com 2>&1 | grep -q "<html"; then
echo "Chrome works"
else
echo "Chrome failed to render a page"
docker exec fredy "$CHROME" --headless --no-sandbox --disable-gpu --dump-dom https://example.com 2>&1 | head -20
exit 1
fi
echo ""
echo "All checks passed."

View File

@@ -63,6 +63,7 @@ class FredyPipelineExecutioner {
* @param {(raw:any)=>Listing} providerConfig.normalize Function to convert raw scraped data into a Listing shape.
* @param {(listing:Listing)=>boolean} providerConfig.filter Function to filter out unwanted listings.
* @param {(url:string, waitForSelector?:string)=>Promise<void>|Promise<Listing[]>} [providerConfig.getListings] Optional override to fetch listings.
* @param {(listing:Listing, browser:any)=>Promise<Listing>} [providerConfig.fetchDetails] Optional per-listing detail enrichment. Called in parallel for each new listing after deduplication. Receives the shared browser instance. Must always resolve (never reject).
* @param {Object} notificationConfig Notification configuration passed to notification adapters.
* @param {Object} spatialFilter Optional spatial filter configuration.
* @param {string} providerId The ID of the provider currently in use.
@@ -92,6 +93,7 @@ class FredyPipelineExecutioner {
.then(this._normalize.bind(this))
.then(this._filter.bind(this))
.then(this._findNew.bind(this))
.then(this._fetchDetails.bind(this))
.then(this._geocode.bind(this))
.then(this._save.bind(this))
.then(this._calculateDistance.bind(this))
@@ -101,6 +103,32 @@ class FredyPipelineExecutioner {
.catch(this._handleError.bind(this));
}
/**
* Optionally enrich new listings with data from their detail pages.
* Only called when the provider config defines a `fetchDetails` function.
* Runs all fetches in parallel. Each individual fetch must handle its own errors
* and always resolve (never reject) to avoid aborting other listings.
*
* @param {Listing[]} newListings New listings to enrich.
* @returns {Promise<Listing[]>} Resolves with enriched listings.
*/
async _fetchDetails(newListings) {
if (typeof this._providerConfig.fetchDetails !== 'function') {
return newListings;
}
const userId = getJob(this._jobKey)?.userId;
const enabledProviders = getUserSettings(userId)?.provider_details ?? [];
if (!userId || !Array.isArray(enabledProviders) || !enabledProviders.includes(this._providerId)) {
return newListings;
}
const listingsToEnrich = process.env.NODE_ENV === 'test' ? newListings.slice(0, 1) : newListings;
const enriched = [];
for (const listing of listingsToEnrich) {
enriched.push(await this._providerConfig.fetchDetails(listing, this._browser));
}
return enriched;
}
/**
* Geocode new listings.
*

View File

@@ -20,7 +20,7 @@ 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 { getSettings, getOrCreateSessionSecret } from '../services/storage/settingsStorage.js';
import { dashboardRouter } from './routes/dashboardRouter.js';
import { backupRouter } from './routes/backupRouter.js';
import { trackingRouter } from './routes/trackingRoute.js';
@@ -28,9 +28,10 @@ import { registerMcpRoutes } from '../mcp/mcpHttpRoute.js';
const service = restana();
const staticService = files(path.join(getDirName(), '../ui/public'));
const PORT = (await getSettings()).port || 9998;
const sessionSecret = await getOrCreateSessionSecret();
service.use(bodyParser.json());
service.use(cookieSession());
service.use(cookieSession(sessionSecret));
service.use(staticService);
service.use('/api/admin', authInterceptor());
service.use('/api/jobs', authInterceptor());

View File

@@ -9,6 +9,27 @@ import * as hasher from '../../services/security/hash.js';
import { trackDemoAccessed } from '../../services/tracking/Tracker.js';
import logger from '../../services/logger.js';
import { getSettings } from '../../services/storage/settingsStorage.js';
const MAX_LOGIN_ATTEMPTS = 10;
const LOGIN_WINDOW_MS = 15 * 60 * 1000; // 15 minutes
const loginAttempts = new Map(); // ip -> { count, firstAttempt }
function getClientIp(req) {
const forwarded = req.headers['x-forwarded-for'];
return (forwarded ? forwarded.split(',')[0] : req.socket?.remoteAddress) || 'unknown';
}
function isRateLimited(ip) {
const now = Date.now();
const record = loginAttempts.get(ip);
if (!record || now - record.firstAttempt > LOGIN_WINDOW_MS) {
loginAttempts.set(ip, { count: 1, firstAttempt: now });
return false;
}
record.count++;
return record.count > MAX_LOGIN_ATTEMPTS;
}
const service = restana();
const loginRouter = service.newRouter();
loginRouter.get('/user', async (req, res) => {
@@ -25,6 +46,12 @@ loginRouter.get('/user', async (req, res) => {
res.send();
});
loginRouter.post('/', async (req, res) => {
const ip = getClientIp(req);
if (isRateLimited(ip)) {
logger.error(`Login rate limit exceeded for IP ${ip}`);
res.send(429);
return;
}
const settings = await getSettings();
const { username, password } = req.body;
const user = userStorage.getUsers(true).find((user) => user.username === username);
@@ -38,6 +65,8 @@ loginRouter.post('/', async (req, res) => {
}
req.session.currentUser = user.id;
req.session.createdAt = Date.now();
loginAttempts.delete(ip);
userStorage.setLastLoginToNow({ userId: user.id });
res.send(200);
return;

View File

@@ -97,9 +97,9 @@ userSettingsRouter.post('/news-hash', async (req, res) => {
}
});
userSettingsRouter.post('/immoscout-details', async (req, res) => {
userSettingsRouter.post('/provider-details', async (req, res) => {
const userId = req.session.currentUser;
const { immoscout_details } = req.body;
const { provider_details } = req.body;
const globalSettings = await getSettings();
if (globalSettings.demoMode) {
@@ -108,11 +108,17 @@ userSettingsRouter.post('/immoscout-details', async (req, res) => {
return;
}
if (!Array.isArray(provider_details)) {
res.statusCode = 400;
res.send({ error: 'provider_details must be an array of provider ids.' });
return;
}
try {
upsertSettings({ immoscout_details: !!immoscout_details }, userId);
upsertSettings({ provider_details }, userId);
res.send({ success: true });
} catch (error) {
logger.error('Error updating immoscout details setting', error);
logger.error('Error updating provider details setting', error);
res.statusCode = 500;
res.send({ error: error.message });
}

View File

@@ -5,12 +5,17 @@
import * as userStorage from '../services/storage/userStorage.js';
import cookieSession from 'cookie-session';
import { nanoid } from 'nanoid';
const SESSION_MAX_AGE = 2 * 60 * 60 * 1000; // 2 hours
const unauthorized = (res) => {
return res.send(401);
};
const isUnauthorized = (req) => {
return req.session.currentUser == null;
if (req.session.currentUser == null) return true;
if (Date.now() - req.session.createdAt > SESSION_MAX_AGE) {
req.session = null;
return true;
}
return false;
};
const isAdmin = (req) => {
if (!isUnauthorized(req)) {
@@ -37,12 +42,11 @@ const adminInterceptor = () => {
}
};
};
const cookieSession$0 = (userId) => {
const cookieSession$0 = (secret) => {
return cookieSession({
name: 'fredy-admin-session',
keys: ['fredy', 'super', 'fancy', 'key', nanoid()],
userId,
maxAge: 2 * 60 * 60 * 1000, // 2 hours
keys: [secret],
maxAge: SESSION_MAX_AGE,
});
};
export { cookieSession$0 as cookieSession };

View File

@@ -1,4 +1,4 @@
# Fredy MCP Server
# Fredy MCP Server
The Fredy MCP Server exposes your real estate jobs and listings data to LLM clients. It supports two transports:
@@ -126,6 +126,54 @@ The LLM will automatically call the appropriate Fredy MCP tools and present the
> **Tip:** Make sure Fredy is running and the database is accessible before starting the MCP server in LM Studio. The stdio transport initializes its own database connection, so Fredy's main process does not need to be running, but the database file must exist and be up-to-date (migrations applied).
### Claude Desktop Configuration
[Claude Desktop](https://claude.ai/download) supports MCP servers natively via its developer settings.
#### Setup
1. Open **Claude Desktop**
2. Go to **Settings → Developer → Edit Config** — this opens the `claude_desktop_config.json` file
3. Add the `fredy` server to the `mcpServers` object:
```json
{
"mcpServers": {
"fredy": {
"command": "/opt/homebrew/opt/node@22/bin/node",
"args": ["/absolute/path/to/fredy/lib/mcp/stdio.js"],
"env": {
"MCP_TOKEN": "fredy_<your-token>"
}
}
}
}
```
Replace `/absolute/path/to/fredy` with the actual path on your machine (e.g. `/Users/you/dev/fredy`).
> **Important:** Claude Desktop launches with a restricted `PATH` and often cannot find `node` by name. Always use the **full absolute path** to the node binary. Find yours by running `which node` in a terminal. Common locations:
> - Homebrew (default): `/opt/homebrew/bin/node`
> - Homebrew (versioned, e.g. node@22): `/opt/homebrew/opt/node@22/bin/node`
> - nvm: `/Users/<you>/.nvm/versions/node/<version>/bin/node`
4. Save the file and **restart Claude Desktop**
5. You should see a hammer icon (🔨) in the chat input — click it to confirm the Fredy tools are listed
#### Usage
Once connected, simply ask Claude about your real estate data:
- *"Show me all my active search jobs"*
- *"List the latest listings from my Berlin apartment search"*
- *"What are the cheapest apartments added this week?"*
Claude will automatically call the appropriate Fredy MCP tools.
> **Note:** Fredy's main web process does not need to be running — the stdio transport opens its own database connection directly. But the SQLite database file must exist and migrations must have been applied.
---
## Usage with Remote LLM (Streamable HTTP transport)
The HTTP transport is automatically available when Fredy is running. It uses the MCP Streamable HTTP protocol at:

View File

@@ -0,0 +1,112 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import nodemailer from 'nodemailer';
import path from 'path';
import fs from 'fs';
import Handlebars from 'handlebars';
import { markdown2Html } from '../../services/markdown.js';
import { getDirName, normalizeImageUrl } from '../../utils.js';
const __dirname = getDirName();
const template = fs.readFileSync(path.resolve(__dirname + '/notification/emailTemplate/template.hbs'), 'utf8');
const emailTemplate = Handlebars.compile(template);
const mapListings = (serviceName, jobKey, listings) =>
listings.map((l) => {
const image = normalizeImageUrl(l.image);
return {
title: l.title || '',
link: l.link || '',
address: l.address || '',
size: l.size || '',
price: l.price || '',
image,
hasImage: Boolean(image),
serviceName,
jobKey,
};
});
export const send = async ({ serviceName, newListings, notificationConfig, jobKey }) => {
const { host, port, secure, username, password, receiver, from } = notificationConfig.find(
(adapter) => adapter.id === config.id,
).fields;
const to = receiver
.trim()
.split(',')
.map((r) => r.trim())
.filter(Boolean);
const transporter = nodemailer.createTransport({
host,
port: Number(port),
secure: secure === 'true',
auth: {
user: username,
pass: password,
},
});
const listings = mapListings(serviceName, jobKey, newListings);
const html = emailTemplate({
serviceName: `Job: (${jobKey}) | Service: ${serviceName}`,
numberOfListings: listings.length,
listings,
});
return transporter.sendMail({
from,
to: to.join(','),
subject: `Fredy found ${listings.length} new listing(s) for ${serviceName}`,
html,
});
};
export const config = {
id: 'smtp',
name: 'SMTP',
description: 'Send notifications via any SMTP server using Nodemailer.',
readme: markdown2Html('lib/notification/adapter/smtp.md'),
fields: {
host: {
type: 'text',
label: 'SMTP Host',
description: 'The hostname of the SMTP server (e.g., smtp.gmail.com).',
},
port: {
type: 'text',
label: 'SMTP Port',
description: 'The port of the SMTP server (e.g., 587 for STARTTLS, 465 for SSL).',
},
secure: {
type: 'text',
label: 'Secure (SSL/TLS)',
description: 'Set to "true" for port 465 (SSL). Leave empty or "false" for STARTTLS on port 587.',
},
username: {
type: 'text',
label: 'Username',
description: 'The username for SMTP authentication.',
},
password: {
type: 'text',
label: 'Password',
description: 'The password (or app password) for SMTP authentication.',
},
receiver: {
type: 'text',
label: 'Receiver Email(s)',
description: 'Comma-separated email addresses Fredy will send notifications to.',
},
from: {
type: 'email',
label: 'Sender Email',
description: 'The email address Fredy sends from.',
},
},
};

View File

@@ -0,0 +1,22 @@
### SMTP Adapter
Send notifications through any SMTP server using [Nodemailer](https://nodemailer.com/).
This works with Gmail, Outlook, self-hosted mail servers, or any provider that supports SMTP.
Setup:
- Provide the SMTP host and port of your mail server.
- For **SSL/TLS** (port 465), set Secure to `true`.
- For **STARTTLS** (port 587), leave Secure empty or set it to `false`.
- Enter the username and password for authentication. For Gmail, use an [App Password](https://support.google.com/accounts/answer/185833).
- Set the sender email address (must be allowed by your SMTP server).
Multiple recipients:
- Separate email addresses with commas (e.g., `some@email.com`, `someOther@email.com`).
Common SMTP settings:
- **Gmail** — `smtp.gmail.com`, port 587, secure: false
- **Outlook** — `smtp.office365.com`, port 587, secure: false
- **Yahoo** — `smtp.mail.yahoo.com`, port 465, secure: true

View File

@@ -5,6 +5,9 @@
import { buildHash, isOneOf } from '../utils.js';
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
import puppeteerExtractor from '../services/extractor/puppeteerExtractor.js';
import * as cheerio from 'cheerio';
import logger from '../services/logger.js';
let appliedBlackList = [];
@@ -18,6 +21,51 @@ function parseId(shortenedLink) {
return shortenedLink.substring(shortenedLink.lastIndexOf('/') + 1);
}
async function fetchDetails(listing, browser) {
try {
const html = await puppeteerExtractor(listing.link, null, { browser });
if (!html) return listing;
const $ = cheerio.load(html);
// Try JSON-LD first
let description = null;
let address = listing.address;
$('script[type="application/ld+json"]').each((_, el) => {
if (description) return;
try {
const data = JSON.parse($(el).text());
const nodes = Array.isArray(data) ? data : [data];
for (const node of nodes) {
if (node.description && !description) description = String(node.description).replace(/\s+/g, ' ').trim();
const addr = node.address || node?.mainEntity?.address;
if (addr && addr.streetAddress && address === listing.address) {
const parts = [addr.streetAddress, addr.postalCode, addr.addressLocality].filter(Boolean);
if (parts.length) address = parts.join(' ');
}
}
} catch {
// ignore malformed JSON-LD
}
});
// Fallback: common description selectors used by immobilien.de
if (!description) {
const sel = ['.beschreibung', '.freitext', '.objektbeschreibung', '.description'].find((s) => $(s).length > 0);
if (sel) description = $(sel).text().replace(/\s+/g, ' ').trim();
}
return {
...listing,
address,
description: description || listing.description,
};
} catch (error) {
logger.warn(`Could not fetch immobilien.de detail page for listing '${listing.id}'.`, error?.message || error);
return listing;
}
}
function normalize(o) {
const baseUrl = 'https://www.immobilien.de';
const size = o.size || null;
@@ -25,8 +73,8 @@ function normalize(o) {
const title = o.title || 'No title available';
const address = o.address || null;
const shortLink = shortenLink(o.link);
const link = baseUrl + shortLink;
const image = baseUrl + o.image;
const link = shortLink ? (shortLink.startsWith('http') ? shortLink : baseUrl + shortLink) : baseUrl;
const image = o.image ? (o.image.startsWith('http') ? o.image : baseUrl + o.image) : null;
const id = buildHash(parseId(shortLink), o.price);
return Object.assign(o, { id, price, size, title, address, link, image });
}
@@ -39,21 +87,22 @@ function applyBlacklist(o) {
const config = {
url: null,
crawlContainer: 'a:has(div.list_entry)',
crawlContainer: 'a.lr-card',
sortByDateParam: 'sort_col=*created_ts&sort_dir=desc',
waitForSelector: 'body',
waitForSelector: null,
crawlFields: {
id: '@href', //will be transformed later
price: '.immo_preis .label_info',
size: '.flaeche .label_info | removeNewline | trim',
title: 'h3 span',
price: '.lr-card__price-amount | trim',
size: '.lr-card__fact:has(.lr-card__fact-label:contains("Fläche")) .lr-card__fact-value | trim',
title: '.lr-card__title | trim',
description: '.description | trim',
link: '@href',
address: '.place',
image: 'img@src',
address: '.lr-card__address span | trim',
image: 'img.lr-card__gallery-img@src',
},
normalize: normalize,
normalize,
filter: applyBlacklist,
fetchDetails,
activeTester: checkIfListingIsActive,
};
export const init = (sourceConfig, blacklist) => {

View File

@@ -46,9 +46,7 @@ import {
convertWebToMobile,
} from '../services/immoscout/immoscout-web-translator.js';
import logger from '../services/logger.js';
import { getUserSettings } from '../services/storage/settingsStorage.js';
let appliedBlackList = [];
let currentUserId = null;
async function getListings(url) {
const response = await fetch(url, {
@@ -68,42 +66,40 @@ async function getListings(url) {
}
const responseBody = await response.json();
return Promise.all(
responseBody.resultListItems
.filter((item) => item.type === 'EXPOSE_RESULT')
.map(async (expose) => {
const item = expose.item;
const [price, size] = item.attributes;
const image = item?.titlePicture?.full ?? item?.titlePicture?.preview ?? null;
let listing = {
id: item.id,
price: price?.value,
size: size?.value,
title: item.title,
link: `${metaInformation.baseUrl}expose/${item.id}`,
address: item.address?.line,
image,
};
if (currentUserId) {
const userSettings = getUserSettings(currentUserId);
if (userSettings.immoscout_details) {
return await pushDetails(listing);
}
}
return listing;
}),
);
return responseBody.resultListItems
.filter((item) => item.type === 'EXPOSE_RESULT')
.map((expose) => {
const item = expose.item;
const [price, size] = item.attributes;
const image = item?.titlePicture?.full ?? item?.titlePicture?.preview ?? null;
return {
id: item.id,
price: price?.value,
size: size?.value,
title: item.title,
link: `${metaInformation.baseUrl}expose/${item.id}`,
address: item.address?.line,
image,
};
});
}
async function fetchDetails(listing) {
return pushDetails(listing);
}
async function pushDetails(listing) {
const detailed = await fetch(`https://api.mobile.immobilienscout24.de/expose/${listing.id}`, {
const exposeId = listing.link?.split('/').pop();
const detailed = await fetch(`https://api.mobile.immobilienscout24.de/expose/${exposeId}`, {
headers: {
'User-Agent': 'ImmoScout_27.3_26.0_._',
'Content-Type': 'application/json',
},
});
if (!detailed.ok) {
logger.error('Error fetching listing details from ImmoScout Mobile API:', detailed.statusText);
logger.warn(
`Error fetching listing details from ImmoScout Mobile API for id: ${exposeId} Status: ${detailed.statusText}`,
);
return listing;
}
const detailBody = await detailed.json();
@@ -196,13 +192,13 @@ const config = {
normalize: normalize,
filter: applyBlacklist,
getListings: getListings,
fetchDetails: fetchDetails,
activeTester: isListingActive,
};
export const init = (sourceConfig, blacklist) => {
config.enabled = sourceConfig.enabled;
config.url = convertWebToMobile(sourceConfig.url);
appliedBlackList = blacklist || [];
currentUserId = sourceConfig.userId || null;
};
export const metaInformation = {
name: 'Immoscout',

View File

@@ -5,9 +5,49 @@
import { buildHash, isOneOf } from '../utils.js';
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
import puppeteerExtractor from '../services/extractor/puppeteerExtractor.js';
import * as cheerio from 'cheerio';
import logger from '../services/logger.js';
let appliedBlackList = [];
async function fetchDetails(listing, browser) {
try {
const html = await puppeteerExtractor(listing.link, null, { browser });
if (!html) return listing;
const $ = cheerio.load(html);
const nextDataRaw = $('#__NEXT_DATA__').text();
if (!nextDataRaw) return listing;
const classified = JSON.parse(nextDataRaw)?.props?.pageProps?.classified;
if (!classified) return listing;
const description = (classified.Texts || [])
.map((t) => [t.Title, t.Content].filter(Boolean).join('\n'))
.filter(Boolean)
.join('\n\n');
const addr = classified.EstateAddress;
let address = listing.address;
if (addr) {
const street = [addr.Street, addr.HouseNumber].filter(Boolean).join(' ');
const cityLine = [addr.ZipCode, addr.District || addr.City].filter(Boolean).join(' ');
const full = [street, cityLine].filter(Boolean).join(', ');
if (full) address = full;
}
return {
...listing,
address,
description: description || listing.description,
};
} catch (error) {
logger.warn(`Could not fetch immowelt detail page for listing '${listing.id}'.`, error?.message || error);
return listing;
}
}
function normalize(o) {
const id = buildHash(o.id, o.price);
return Object.assign(o, { id });
@@ -37,6 +77,7 @@ const config = {
},
normalize: normalize,
filter: applyBlacklist,
fetchDetails: fetchDetails,
activeTester: checkIfListingIsActive,
};
export const init = (sourceConfig, blacklist) => {

View File

@@ -5,14 +5,151 @@
import { buildHash, isOneOf } from '../utils.js';
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
import puppeteerExtractor from '../services/extractor/puppeteerExtractor.js';
import logger from '../services/logger.js';
import * as cheerio from 'cheerio';
let appliedBlackList = [];
let appliedBlacklistedDistricts = [];
function toAbsoluteLink(link) {
if (!link) return null;
return link.startsWith('http') ? link : `https://www.kleinanzeigen.de${link}`;
}
function cleanText(value) {
if (value == null) return '';
return String(value)
.replace(/<[^>]*>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function buildAddressFromJsonLd(address) {
if (!address || typeof address !== 'object') return null;
const locality = cleanText(address.addressLocality);
const region = cleanText(address.addressRegion);
const postalCode = cleanText(address.postalCode);
const streetAddress = cleanText(address.streetAddress);
const cityPart = [region, locality].filter(Boolean).join(' - ');
const tail = [postalCode, cityPart || locality || region].filter(Boolean).join(' ');
const fullAddress = [streetAddress, tail].filter(Boolean).join(', ');
return fullAddress || null;
}
function flattenJsonLdNodes(node, acc = []) {
if (node == null) return acc;
if (Array.isArray(node)) {
node.forEach((item) => flattenJsonLdNodes(item, acc));
return acc;
}
if (typeof node !== 'object') return acc;
acc.push(node);
if (Array.isArray(node['@graph'])) {
node['@graph'].forEach((item) => flattenJsonLdNodes(item, acc));
}
if (node.mainEntity) {
flattenJsonLdNodes(node.mainEntity, acc);
}
if (node.itemOffered) {
flattenJsonLdNodes(node.itemOffered, acc);
}
return acc;
}
function extractDetailFromHtml(html) {
const $ = cheerio.load(html);
const nodes = [];
// Prefer the rendered postal address block from the detail page because
// it contains the street line that is missing from list results.
const streetFromDom = cleanText($('#street-address').first().text());
const localityFromDom = cleanText($('#viewad-locality').first().text());
const domAddress = [streetFromDom, localityFromDom].filter(Boolean).join(' ');
$('script[type="application/ld+json"]').each((_, element) => {
const content = $(element).text();
if (!content) return;
try {
const parsed = JSON.parse(content);
flattenJsonLdNodes(parsed, nodes);
} catch {
// Ignore broken JSON-LD blocks from ads/trackers and keep trying others.
}
});
let detailAddress = null;
let detailDescription = null;
if (domAddress) {
detailAddress = domAddress;
}
for (const node of nodes) {
const candidateAddress = buildAddressFromJsonLd(
node.address || node?.itemOffered?.address || node?.offers?.address,
);
if (!detailAddress && candidateAddress) {
detailAddress = candidateAddress;
}
const candidateDescription = cleanText(node.description || node?.itemOffered?.description);
if (!detailDescription && candidateDescription) {
detailDescription = candidateDescription;
}
if (detailAddress && detailDescription) {
break;
}
}
return {
detailAddress,
detailDescription,
};
}
async function enrichListingFromDetails(listing, browser) {
const absoluteLink = toAbsoluteLink(listing.link);
if (!absoluteLink) return listing;
try {
const html = await puppeteerExtractor(absoluteLink, null, { browser });
if (!html) return { ...listing, link: absoluteLink };
const { detailAddress, detailDescription } = extractDetailFromHtml(html);
return {
...listing,
link: absoluteLink,
address: detailAddress || listing.address,
description: detailDescription || listing.description,
};
} catch (error) {
logger.warn(`Could not fetch Kleinanzeigen detail page for listing '${listing.id}'.`, error?.message || error);
return { ...listing, link: absoluteLink };
}
}
async function fetchDetails(listing, browser) {
return enrichListingFromDetails(listing, browser);
}
function normalize(o) {
const size = o.size || '--- m²';
const id = buildHash(o.id, o.price);
const link = `https://www.kleinanzeigen.de${o.link}`;
const link = toAbsoluteLink(o.link) || o.link;
return Object.assign(o, { id, size, link });
}
@@ -40,12 +177,13 @@ const config = {
address: '.aditem-main--top--left | trim | removeNewline',
image: 'img@src',
},
fetchDetails,
normalize: normalize,
filter: applyBlacklist,
activeTester: checkIfListingIsActive,
};
export const metaInformation = {
name: 'Ebay Kleinanzeigen',
name: 'Kleinanzeigen',
baseUrl: 'https://www.kleinanzeigen.de/',
id: 'kleinanzeigen',
};

View File

@@ -5,12 +5,60 @@
import { isOneOf, buildHash } from '../utils.js';
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
import puppeteerExtractor from '../services/extractor/puppeteerExtractor.js';
import * as cheerio from 'cheerio';
import logger from '../services/logger.js';
let appliedBlackList = [];
async function fetchDetails(listing, browser) {
try {
const html = await puppeteerExtractor(listing.link, 'body', { browser });
const $ = cheerio.load(html);
const nextDataRaw = $('#__NEXT_DATA__').text;
if (!nextDataRaw) return listing;
const estate = JSON.parse(nextDataRaw)?.props?.pageProps?.estate;
if (!estate) return listing;
const description = (estate.frontendItems || [])
.map((item) => {
const texts = (item.contents || [])
.filter((c) => c.type === 'contentBoxes')
.flatMap((c) => c.data || [])
.filter((d) => d.type === 'text' && d.content)
.map((d) => d.content);
if (!texts.length) return null;
return [item.label, ...texts].filter(Boolean).join('\n');
})
.filter(Boolean)
.join('\n\n');
const addr = estate.address;
let address = listing.address;
if (addr) {
const street = [addr.street, addr.streetNumber].filter(Boolean).join(' ');
const cityLine = [addr.zip, addr.city].filter(Boolean).join(' ');
const full = [street, cityLine].filter(Boolean).join(', ');
if (full) address = full;
}
return {
...listing,
address,
description: description || listing.description,
};
} catch (error) {
logger.warn(`Could not fetch Sparkasse detail page for listing '${listing.id}'.`, error?.message || error);
return listing;
}
}
function normalize(o) {
const originalId = o.id.split('/').pop().replace('.html', '');
const id = buildHash(originalId, o.price);
const size = o.size?.replace(' Wohnfläche', '') ?? null;
const size = o.size?.replace(' Wohnfläche', '').replace(' m²', 'm²') ?? null;
const title = o.title || 'No title available';
const link = o.link != null ? `https://immobilien.sparkasse.de${o.link}` : config.url;
return Object.assign(o, { id, size, title, link });
@@ -22,20 +70,21 @@ function applyBlacklist(o) {
}
const config = {
url: null,
crawlContainer: '.estate-list-item-row',
crawlContainer: 'div[data-testid="estate-link"]',
sortByDateParam: 'sortBy=date_desc',
waitForSelector: 'body',
crawlFields: {
id: 'div[data-testid="estate-link"] a@href',
id: 'a@href',
title: 'h3 | trim',
price: '.estate-list-price | trim',
size: '.estate-mainfact:first-child span | trim',
size: '.estate-mainfact span | trim',
address: 'h6 | trim',
image: '.estate-list-item-image-container img@src',
link: 'div[data-testid="estate-link"] a@href',
image: 'img@src',
link: 'a@href',
},
normalize: normalize,
filter: applyBlacklist,
fetchDetails,
activeTester: (url) => checkIfListingIsActive(url, 'Angebot nicht gefunden'),
};
export const init = (sourceConfig, blacklist) => {

View File

@@ -5,9 +5,34 @@
import { isOneOf, buildHash } from '../utils.js';
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
import puppeteerExtractor from '../services/extractor/puppeteerExtractor.js';
import * as cheerio from 'cheerio';
import logger from '../services/logger.js';
let appliedBlackList = [];
async function fetchDetails(listing, browser) {
try {
const html = await puppeteerExtractor(listing.link, null, { browser });
if (!html) return listing;
const $ = cheerio.load(html);
$('#freitext_0 script').remove();
const description = $('#freitext_0').text().replace(/\s+/g, ' ').trim();
const address = $('a[href="#map_container"] .section_panel_detail').text().replace(/\s+/g, ' ').trim();
return {
...listing,
address: address || listing.address,
description: description || listing.description,
};
} catch (error) {
logger.warn(`Could not fetch wgGesucht detail page for listing '${listing.id}'.`, error?.message || error);
return listing;
}
}
function normalize(o) {
const id = buildHash(o.id, o.price);
const link = `https://www.wg-gesucht.de${o.link}`;
@@ -37,6 +62,7 @@ const config = {
},
normalize: normalize,
filter: applyBlacklist,
fetchDetails,
activeTester: checkIfListingIsActive,
};
export const init = (sourceConfig, blacklist) => {

View File

@@ -94,12 +94,34 @@ export async function applyBotPreventionToPage(page, cfg) {
// webdriver
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
// chrome runtime
// chrome runtime — expose loadTimes, csi and app like real Chrome
// @ts-ignore
if (!window.chrome) {
window.chrome = {
runtime: {},
// @ts-ignore
window.chrome = { runtime: {} };
}
loadTimes: () => ({
requestTime: performance.timeOrigin / 1000,
startLoadTime: performance.timeOrigin / 1000,
commitLoadTime: performance.timeOrigin / 1000 + 0.1,
finishDocumentLoadTime: 0,
finishLoadTime: 0,
firstPaintTime: 0,
firstPaintAfterLoadTime: 0,
navigationType: 'Other',
wasFetchedViaSpdy: false,
wasNpnNegotiated: false,
npnNegotiatedProtocol: '',
wasAlternateProtocolAvailable: false,
connectionInfo: 'http/1.1',
}),
// @ts-ignore
csi: () => ({ startE: performance.timeOrigin, onloadT: Date.now(), pageT: performance.now(), tran: 15 }),
app: {
isInstalled: false,
InstallState: { DISABLED: 'disabled', INSTALLED: 'installed', NOT_INSTALLED: 'not_installed' },
RunningState: { CANNOT_RUN: 'cannot_run', READY_TO_RUN: 'ready_to_run', RUNNING: 'running' },
},
};
// languages
// @ts-ignore
@@ -107,23 +129,38 @@ export async function applyBotPreventionToPage(page, cfg) {
get: () => (window.localStorage.getItem('__LANGS__') || 'de-DE,de').split(','),
});
// plugins
// plugins — mimic real Chrome's built-in PDF plugins
const makePlugin = (name, filename, description, mimeType, mimeTypeSuffix) => {
const mimeObj = { type: mimeType, suffixes: mimeTypeSuffix, description, enabledPlugin: null };
const plugin = { name, filename, description, length: 1, 0: mimeObj };
mimeObj.enabledPlugin = plugin;
return plugin;
};
const fakePlugins = [
makePlugin('PDF Viewer', 'internal-pdf-viewer', 'Portable Document Format', 'application/pdf', 'pdf'),
makePlugin('Chrome PDF Viewer', 'internal-pdf-viewer', 'Portable Document Format', 'application/pdf', 'pdf'),
makePlugin('Chromium PDF Viewer', 'internal-pdf-viewer', 'Portable Document Format', 'application/pdf', 'pdf'),
makePlugin(
'Microsoft Edge PDF Viewer',
'internal-pdf-viewer',
'Portable Document Format',
'application/pdf',
'pdf',
),
makePlugin('WebKit built-in PDF', 'internal-pdf-viewer', 'Portable Document Format', 'application/pdf', 'pdf'),
];
// @ts-ignore
Object.defineProperty(navigator, 'plugins', {
get: () => [{}, {}, {}],
});
Object.defineProperty(navigator, 'plugins', { get: () => fakePlugins });
// @ts-ignore
Object.defineProperty(navigator, 'mimeTypes', { get: () => [fakePlugins[0][0]] });
// 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 });
}
Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 8 });
// @ts-ignore
if (typeof navigator.deviceMemory === 'number' && navigator.deviceMemory < 2) {
Object.defineProperty(navigator, 'deviceMemory', { get: () => 8 });
}
Object.defineProperty(navigator, 'deviceMemory', { get: () => 8 });
// userAgentData (Client Hints)
try {
@@ -236,6 +273,21 @@ export async function applyBotPreventionToPage(page, cfg) {
} catch {
//noop
}
// document.hasFocus — headless returns false; real active tabs return true
try {
document.hasFocus = () => true;
} catch {
//noop
}
// screen color depth — normalise in case headless reports 0
try {
Object.defineProperty(screen, 'colorDepth', { get: () => 24 });
Object.defineProperty(screen, 'pixelDepth', { get: () => 24 });
} catch {
//noop
}
} catch {
//noop
}
@@ -273,6 +325,8 @@ export async function applyPostNavigationHumanSignals(page, cfg) {
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) });
await new Promise((res) => setTimeout(res, 150 + Math.floor(Math.random() * 200)));
await page.mouse.wheel({ deltaY: -(30 + Math.floor(Math.random() * 60)) });
} catch {
// ignore if mouse is unavailable
}

View File

@@ -47,12 +47,17 @@ export async function launchBrowser(url, options) {
removeUserDataDir = true;
}
// On ARM64 Docker, Chrome for Testing has no native binary — use system Chromium instead.
const executablePath =
options?.executablePath ||
(process.arch === 'arm64' && process.env.IS_DOCKER === 'true' ? '/usr/bin/chromium' : undefined);
const browser = await puppeteer.launch({
headless: options?.puppeteerHeadless ?? true,
args: launchArgs,
timeout: options?.puppeteerTimeout || 45_000,
userDataDir,
executablePath: options?.executablePath,
executablePath,
});
browser.__fredy_userDataDir = userDataDir;
@@ -105,6 +110,7 @@ export default async function execute(url, waitForSelector, options) {
// Navigation
const response = await page.goto(url, {
waitUntil: options?.waitUntil || 'domcontentloaded',
timeout: options?.puppeteerTimeout || 60000,
});
// Optionally wait and add subtle human-like interactions

View File

@@ -29,12 +29,12 @@
*/
import fs from 'fs';
import path from 'path';
import { pathToFileURL } from 'url';
import { pathToFileURL, fileURLToPath } from 'url';
import crypto from 'crypto';
import SqliteConnection from '../SqliteConnection.js';
import logger from '../../logger.js';
const ROOT = path.resolve('.');
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..');
/**
* Absolute path to the migrations directory (lib/services/storage/migrations/sql).
* @type {string}

View File

@@ -0,0 +1,17 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
// We have moved the previous immoscout_details setting to provider_details and enable this by default
// We also set it to false per default as this is increasing the chance to be detected as a bot by a lot
export function up(db) {
db.exec(`
UPDATE settings
SET name = 'provider_details', value = false
WHERE name = 'immoscout_details'
AND NOT EXISTS (
SELECT 1 FROM settings WHERE name = 'provider_details'
);
`);
}

View File

@@ -0,0 +1,15 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
// Convert provider_details from a boolean to an array of provider id strings.
// Users will re-configure which providers they want to fetch details from.
export function up(db) {
const row = db.prepare("SELECT value FROM settings WHERE name = 'provider_details'").get();
if (row) {
db.prepare("UPDATE settings SET value = ? WHERE name = 'provider_details'").run(JSON.stringify([]));
} else {
db.prepare("INSERT INTO settings (name, value) VALUES ('provider_details', ?)").run(JSON.stringify([]));
}
}

View File

@@ -67,6 +67,19 @@ export async function getSettings() {
return cachedSettingsConfig;
}
/**
* Get or create a persistent session signing secret.
* Generated once and stored in the settings table under the key 'session_secret'.
* @returns {Promise<string>}
*/
export async function getOrCreateSessionSecret() {
const settings = await getSettings();
if (settings.session_secret) return settings.session_secret;
const secret = nanoid(64);
upsertSettings({ session_secret: secret });
return secret;
}
/**
* Upsert settings rows.
* - Accepts an object map of name -> value, or an entry {name, value}.

View File

@@ -1,6 +1,6 @@
{
"name": "fredy",
"version": "20.0.5",
"version": "20.2.0",
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
"scripts": {
"prepare": "husky",
@@ -65,41 +65,41 @@
"@douyinfe/semi-ui": "2.93.0",
"@douyinfe/semi-ui-19": "^2.93.0",
"@mapbox/mapbox-gl-draw": "^1.5.1",
"@modelcontextprotocol/sdk": "^1.27.1",
"@modelcontextprotocol/sdk": "^1.29.0",
"@sendgrid/mail": "8.1.6",
"@turf/boolean-point-in-polygon": "^7.3.4",
"@vitejs/plugin-react": "6.0.1",
"adm-zip": "^0.5.16",
"adm-zip": "^0.5.17",
"better-sqlite3": "^12.8.0",
"body-parser": "2.2.2",
"chart.js": "^4.5.1",
"cheerio": "^1.2.0",
"cookie-session": "2.1.1",
"handlebars": "4.7.8",
"lodash": "4.17.23",
"maplibre-gl": "^5.20.1",
"handlebars": "4.7.9",
"maplibre-gl": "^5.22.0",
"nanoid": "5.1.7",
"node-cron": "^4.2.1",
"node-fetch": "3.3.2",
"node-mailjet": "6.0.11",
"nodemailer": "^8.0.5",
"p-throttle": "^8.1.0",
"package-up": "^5.0.0",
"puppeteer": "^24.39.1",
"puppeteer": "^24.40.0",
"puppeteer-extra": "^3.3.6",
"puppeteer-extra-plugin-stealth": "^2.11.2",
"query-string": "9.3.1",
"react": "19.2.4",
"react-chartjs-2": "^5.3.1",
"react-dom": "19.2.4",
"react-range-slider-input": "^3.3.2",
"react-router": "7.13.1",
"react-router-dom": "7.13.1",
"resend": "^6.9.4",
"react-range-slider-input": "^3.3.5",
"react-router": "7.14.0",
"react-router-dom": "7.14.0",
"resend": "^6.10.0",
"restana": "5.1.0",
"semver": "^7.7.4",
"serve-static": "2.2.1",
"slack": "11.0.2",
"vite": "8.0.0",
"vite": "8.0.7",
"x-var": "^3.0.1",
"zustand": "^5.0.12"
},
@@ -110,7 +110,7 @@
"@babel/preset-react": "7.28.5",
"@eslint/js": "^10.0.1",
"chalk": "^5.6.2",
"eslint": "10.0.3",
"eslint": "10.2.0",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-react": "7.37.5",
"globals": "^17.4.0",
@@ -120,6 +120,6 @@
"lint-staged": "16.4.0",
"nodemon": "^3.1.14",
"prettier": "3.8.1",
"vitest": "^4.1.0"
"vitest": "^4.1.3"
}
}

View File

@@ -13,7 +13,7 @@ describe('#einsAImmobilien testsuite()', () => {
provider.init(providerConfig.einsAImmobilien, [], []);
it('should test einsAImmobilien provider', async () => {
const Fredy = await mockFredy();
return await new Promise((resolve) => {
return await new Promise((resolve, reject) => {
const fredy = new Fredy(
provider.config,
null,
@@ -23,6 +23,10 @@ describe('#einsAImmobilien testsuite()', () => {
similarityCache,
);
fredy.execute().then((listings) => {
if (listings == null || listings.length === 0) {
reject('Listings is empty!');
return;
}
expect(listings).toBeInstanceOf(Array);
const notificationObj = get();
expect(notificationObj).toBeTypeOf('object');

View File

@@ -6,36 +6,69 @@
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
import { get } from '../mocks/mockNotification.js';
import { providerConfig, mockFredy } from '../utils.js';
import { expect } from 'vitest';
import { expect, vi } from 'vitest';
import * as provider from '../../lib/provider/immobilienDe.js';
import * as mockStore from '../mocks/mockStore.js';
describe('#immobilien.de testsuite()', () => {
provider.init(providerConfig.immobilienDe, [], []);
it('should test immobilien.de provider', async () => {
const Fredy = await mockFredy();
return await new Promise((resolve) => {
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'test1', similarityCache);
fredy.execute().then((listing) => {
expect(listing).toBeInstanceOf(Array);
const notificationObj = get();
expect(notificationObj).toBeTypeOf('object');
expect(notificationObj.serviceName).toBe('immobilienDe');
notificationObj.payload.forEach((notify) => {
/** check the actual structure **/
expect(notify.id).toBeTypeOf('string');
expect(notify.price).toBeTypeOf('string');
expect(notify.size).toBeTypeOf('string');
expect(notify.title).toBeTypeOf('string');
expect(notify.link).toBeTypeOf('string');
expect(notify.address).toBeTypeOf('string');
/** check the values if possible **/
expect(notify.price).toContain('€');
expect(notify.size).toContain('m²');
expect(notify.title).not.toBe('');
expect(notify.link).toContain('https://www.immobilien.de');
expect(notify.address).not.toBe('');
});
resolve();
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'test1', similarityCache);
const listing = await fredy.execute();
if (listing == null || listing.length === 0) {
throw new Error('Listings is empty!');
}
expect(listing).toBeInstanceOf(Array);
const notificationObj = get();
expect(notificationObj).toBeTypeOf('object');
expect(notificationObj.serviceName).toBe('immobilienDe');
notificationObj.payload.forEach((notify) => {
/** check the actual structure **/
expect(notify.id).toBeTypeOf('string');
expect(notify.price).toBeTypeOf('string');
expect(notify.size).toBeTypeOf('string');
expect(notify.title).toBeTypeOf('string');
expect(notify.link).toBeTypeOf('string');
expect(notify.address).toBeTypeOf('string');
/** check the values if possible **/
expect(notify.price).toContain('');
expect(notify.size).toContain('m²');
expect(notify.title).not.toBe('');
expect(notify.link).toContain('https://www.immobilien.de');
expect(notify.address).not.toBe('');
});
});
describe('with provider_details enabled', () => {
beforeEach(() => {
vi.spyOn(mockStore, 'getUserSettings').mockReturnValue({ provider_details: [provider.metaInformation.id] });
vi.spyOn(mockStore, 'getKnownListingHashesForJobAndProvider').mockReturnValue([]);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should enrich listings with details', async () => {
const Fredy = await mockFredy();
provider.init(providerConfig.immobilienDe, [], []);
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'test1', {
checkAndAddEntry: () => false,
});
const listings = await fredy.execute();
if (listings == null) return;
expect(listings).toBeInstanceOf(Array);
listings.forEach((listing) => {
expect(listing.link).toContain('https://www.immobilien.de');
expect(listing.address).toBeTypeOf('string');
expect(listing.address).not.toBe('');
// description may be null if selectors don't match yet — falls back gracefully
if (listing.description != null) {
expect(listing.description).toBeTypeOf('string');
}
});
});
});

View File

@@ -3,19 +3,25 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { expect } from 'vitest';
import { expect, vi } from 'vitest';
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
import { mockFredy, providerConfig } from '../utils.js';
import { get } from '../mocks/mockNotification.js';
import * as provider from '../../lib/provider/immoscout.js';
import * as mockStore from '../mocks/mockStore.js';
describe('#immoscout provider testsuite()', () => {
provider.init(providerConfig.immoscout, [], []);
it('should test immoscout provider', async () => {
const Fredy = await mockFredy();
return await new Promise((resolve) => {
return await new Promise((resolve, reject) => {
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, '', similarityCache);
fredy.execute().then((listings) => {
if (listings == null || listings.length === 0) {
reject('Listings is empty!');
return;
}
expect(listings).toBeInstanceOf(Array);
const notificationObj = get();
expect(notificationObj).toBeTypeOf('object');
@@ -37,4 +43,29 @@ describe('#immoscout provider testsuite()', () => {
});
});
});
describe('with provider_details enabled', () => {
beforeEach(() => {
vi.spyOn(mockStore, 'getUserSettings').mockReturnValue({ provider_details: [provider.metaInformation.id] });
vi.spyOn(mockStore, 'getKnownListingHashesForJobAndProvider').mockReturnValue([]);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should enrich listings with details', async () => {
const Fredy = await mockFredy();
provider.init(providerConfig.immoscout, [], []);
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, '', {
checkAndAddEntry: () => false,
});
const listings = await fredy.execute();
expect(listings).toBeInstanceOf(Array);
listings.forEach((listing) => {
expect(listing.description).toBeTypeOf('string');
expect(listing.description).not.toBe('');
});
});
});
});

View File

@@ -13,9 +13,14 @@ describe('#immoswp testsuite()', () => {
provider.init(providerConfig.immoswp, [], []);
it('should test immoswp provider', async () => {
const Fredy = await mockFredy();
return await new Promise((resolve) => {
return await new Promise((resolve, reject) => {
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'immoswp', similarityCache);
fredy.execute().then((listing) => {
if (listing == null || listing.length === 0) {
reject('Listings is empty!');
return;
}
expect(listing).toBeInstanceOf(Array);
const notificationObj = get();
expect(notificationObj).toBeTypeOf('object');

View File

@@ -6,8 +6,9 @@
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 'vitest';
import { expect, vi } from 'vitest';
import * as provider from '../../lib/provider/immowelt.js';
import * as mockStore from '../mocks/mockStore.js';
describe('#immowelt testsuite()', () => {
it('should test immowelt provider', async () => {
@@ -17,6 +18,10 @@ describe('#immowelt testsuite()', () => {
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'immowelt', similarityCache);
const listing = await fredy.execute();
if (listing == null || listing.length === 0) {
throw new Error('Listings is empty!');
}
expect(listing).toBeInstanceOf(Array);
const notificationObj = get();
expect(notificationObj).toBeTypeOf('object');
@@ -37,4 +42,34 @@ describe('#immowelt testsuite()', () => {
expect(notify.address).not.toBe('');
});
});
describe('with provider_details enabled', () => {
beforeEach(() => {
vi.spyOn(mockStore, 'getUserSettings').mockReturnValue({ provider_details: [provider.metaInformation.id] });
vi.spyOn(mockStore, 'getKnownListingHashesForJobAndProvider').mockReturnValue([]);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should enrich listings with details', async () => {
const Fredy = await mockFredy();
provider.init(providerConfig.immowelt, [], []);
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'immowelt', {
checkAndAddEntry: () => false,
});
const listings = await fredy.execute();
expect(listings).toBeInstanceOf(Array);
listings.forEach((listing) => {
expect(listing.link).toContain('https://www.immowelt.de');
expect(listing.address).toBeTypeOf('string');
expect(listing.address).not.toBe('');
// description is enriched from the detail page; falls back gracefully if blocked
if (listing.description != null) {
expect(listing.description).toBeTypeOf('string');
}
});
});
});
});

View File

@@ -6,14 +6,15 @@
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 'vitest';
import { expect, vi } from 'vitest';
import * as provider from '../../lib/provider/kleinanzeigen.js';
import * as mockStore from '../mocks/mockStore.js';
describe('#kleinanzeigen testsuite()', () => {
it('should test kleinanzeigen provider', async () => {
const Fredy = await mockFredy();
provider.init(providerConfig.kleinanzeigen, [], []);
return await new Promise((resolve) => {
return await new Promise((resolve, reject) => {
const fredy = new Fredy(
provider.config,
null,
@@ -23,6 +24,11 @@ describe('#kleinanzeigen testsuite()', () => {
similarityCache,
);
fredy.execute().then((listing) => {
if (listing == null || listing.length === 0) {
reject('Listings is empty!');
return;
}
expect(listing).toBeInstanceOf(Array);
const notificationObj = get();
expect(notificationObj).toBeTypeOf('object');
@@ -42,4 +48,32 @@ describe('#kleinanzeigen testsuite()', () => {
});
});
});
describe('with provider_details enabled', () => {
beforeEach(() => {
vi.spyOn(mockStore, 'getUserSettings').mockReturnValue({ provider_details: [provider.metaInformation.id] });
vi.spyOn(mockStore, 'getKnownListingHashesForJobAndProvider').mockReturnValue([]);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should enrich listings with details', async () => {
const Fredy = await mockFredy();
provider.init(providerConfig.kleinanzeigen, [], []);
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'kleinanzeigen', {
checkAndAddEntry: () => false,
});
const listings = await fredy.execute();
expect(listings).toBeInstanceOf(Array);
listings.forEach((listing) => {
expect(listing.link).toContain('https://www.kleinanzeigen.de');
expect(listing.address).toBeTypeOf('string');
expect(listing.address).not.toBe('');
expect(listing.description).toBeTypeOf('string');
expect(listing.description).not.toBe('');
});
});
});
});

View File

@@ -17,6 +17,10 @@ describe('#mcMakler testsuite()', () => {
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'mcMakler', similarityCache);
const listing = await fredy.execute();
if (listing == null || listing.length === 0) {
throw new Error('Listings is empty!');
}
expect(listing).toBeInstanceOf(Array);
const notificationObj = get();
expect(notificationObj).toBeTypeOf('object');

View File

@@ -13,7 +13,7 @@ describe('#neubauKompass testsuite()', () => {
provider.init(providerConfig.neubauKompass, [], []);
it('should test neubauKompass provider', async () => {
const Fredy = await mockFredy();
return await new Promise((resolve) => {
return await new Promise((resolve, reject) => {
const fredy = new Fredy(
provider.config,
null,
@@ -23,6 +23,11 @@ describe('#neubauKompass testsuite()', () => {
similarityCache,
);
fredy.execute().then((listing) => {
if (listing == null || listing.length === 0) {
reject('Listings is empty!');
return;
}
expect(listing).toBeInstanceOf(Array);
const notificationObj = get();
expect(notificationObj.serviceName).toBe('neubauKompass');

View File

@@ -17,6 +17,10 @@ describe('#ohneMakler testsuite()', () => {
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'ohneMakler', similarityCache);
const listing = await fredy.execute();
if (listing == null || listing.length === 0) {
throw new Error('Listings is empty!');
}
expect(listing).toBeInstanceOf(Array);
const notificationObj = get();
expect(notificationObj).toBeTypeOf('object');

View File

@@ -24,6 +24,10 @@ describe('#regionalimmobilien24 testsuite()', () => {
);
const listing = await fredy.execute();
if (listing == null || listing.length === 0) {
throw new Error('Listings is empty!');
}
expect(listing).toBeInstanceOf(Array);
const notificationObj = get();
expect(notificationObj).toBeTypeOf('object');

View File

@@ -6,8 +6,9 @@
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 'vitest';
import { expect, vi } from 'vitest';
import * as provider from '../../lib/provider/sparkasse.js';
import * as mockStore from '../mocks/mockStore.js';
describe('#sparkasse testsuite()', () => {
it('should test sparkasse provider', async () => {
@@ -17,6 +18,10 @@ describe('#sparkasse testsuite()', () => {
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'sparkasse', similarityCache);
const listing = await fredy.execute();
if (listing == null || listing.length === 0) {
throw new Error('Listings is empty!');
}
expect(listing).toBeInstanceOf(Array);
const notificationObj = get();
expect(notificationObj).toBeTypeOf('object');
@@ -34,4 +39,35 @@ describe('#sparkasse testsuite()', () => {
expect(notify.address).not.toBe('');
});
});
describe('with provider_details enabled', () => {
beforeEach(() => {
vi.spyOn(mockStore, 'getUserSettings').mockReturnValue({ provider_details: [provider.metaInformation.id] });
vi.spyOn(mockStore, 'getKnownListingHashesForJobAndProvider').mockReturnValue([]);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should enrich listings with details', async () => {
const Fredy = await mockFredy();
provider.init(providerConfig.sparkasse, []);
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'sparkasse', {
checkAndAddEntry: () => false,
});
const listings = await fredy.execute();
expect(listings).toBeInstanceOf(Array);
listings.forEach((listing) => {
expect(listing.link).toContain('https://immobilien.sparkasse.de');
expect(listing.address).toBeTypeOf('string');
expect(listing.address).not.toBe('');
// description is enriched from the detail page; falls back gracefully if bot-detected
if (listing.description != null) {
expect(listing.description).toBeTypeOf('string');
expect(listing.description).not.toBe('');
}
});
});
});
});

View File

@@ -6,16 +6,22 @@
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 'vitest';
import { expect, vi } from 'vitest';
import * as provider from '../../lib/provider/wgGesucht.js';
import * as mockStore from '../mocks/mockStore.js';
describe('#wgGesucht testsuite()', () => {
provider.init(providerConfig.wgGesucht, [], []);
it('should test wgGesucht provider', async () => {
const Fredy = await mockFredy();
return await new Promise((resolve) => {
return await new Promise((resolve, reject) => {
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'wgGesucht', similarityCache);
fredy.execute().then((listing) => {
if (listing == null || listing.length === 0) {
reject('Listings is empty!');
return;
}
expect(listing).toBeInstanceOf(Array);
const notificationObj = get();
expect(notificationObj.serviceName).toBe('wgGesucht');
@@ -32,4 +38,30 @@ describe('#wgGesucht testsuite()', () => {
});
});
});
describe('with provider_details enabled', () => {
beforeEach(() => {
vi.spyOn(mockStore, 'getUserSettings').mockReturnValue({ provider_details: [provider.metaInformation.id] });
vi.spyOn(mockStore, 'getKnownListingHashesForJobAndProvider').mockReturnValue([]);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should enrich listings with details', async () => {
const Fredy = await mockFredy();
provider.init(providerConfig.wgGesucht, [], []);
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'wgGesucht', {
checkAndAddEntry: () => false,
});
const listings = await fredy.execute();
expect(listings).toBeInstanceOf(Array);
listings.forEach((listing) => {
expect(listing.link).toContain('https://www.wg-gesucht.de');
expect(listing.description).toBeTypeOf('string');
expect(listing.description).not.toBe('');
});
});
});
});

View File

@@ -13,7 +13,7 @@ describe('#wohnungsboerse testsuite()', () => {
provider.init(providerConfig.wohnungsboerse, [], []);
it('should test wohnungsboerse provider', async () => {
const Fredy = await mockFredy();
return await new Promise((resolve) => {
return await new Promise((resolve, reject) => {
const fredy = new Fredy(
provider.config,
null,
@@ -23,6 +23,11 @@ describe('#wohnungsboerse testsuite()', () => {
similarityCache,
);
fredy.execute().then((listings) => {
if (listings == null || listings.length === 0) {
reject('Listings is empty!');
return;
}
expect(listings).toBeInstanceOf(Array);
const notificationObj = get();
expect(notificationObj).toBeTypeOf('object');

View File

@@ -8,7 +8,6 @@ import React, { useEffect } from 'react';
import InsufficientPermission from './components/permission/InsufficientPermission';
import PermissionAwareRoute from './components/permission/PermissionAwareRoute';
import GeneralSettings from './views/generalSettings/GeneralSettings';
import UserSettings from './views/userSettings/UserSettings';
import JobMutation from './views/jobs/mutation/JobMutation';
import UserMutator from './views/user/mutation/UserMutator';
import { useActions, useSelector } from './services/state/store';
@@ -127,15 +126,8 @@ export default function FredyApp() {
</PermissionAwareRoute>
}
/>
<Route path="/userSettings" element={<UserSettings />} />
<Route
path="/generalSettings"
element={
<PermissionAwareRoute currentUser={currentUser}>
<GeneralSettings />
</PermissionAwareRoute>
}
/>
<Route path="/userSettings" element={<Navigate to="/generalSettings" replace />} />
<Route path="/generalSettings" element={<GeneralSettings />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
</Routes>

View File

@@ -1,12 +1,14 @@
@import './DashboardCardColors.less';
.dashboard-card {
width: 100%;
height: 140px;
margin-bottom: 16px;
transition: transform 0.2s;
background-color: rgba(36, 36, 36, 0.9);
backdrop-filter: blur(8px);
border: 1px solid var(--semi-color-border);
--pulse-color: rgba(255, 255, 255, 0.1);
transition: transform 0.2s, box-shadow 0.2s;
background-color: #181b26;
border: 1px solid #232735;
border-radius: 10px;
--pulse-color: rgba(255, 255, 255, 0.08);
position: relative;
z-index: 1;
overflow: visible;
@@ -32,6 +34,14 @@
display: flex;
align-items: center;
justify-content: center;
color: var(--card-accent, #94a3b8);
}
&__title {
color: var(--semi-color-text-2) !important;
font-size: 12px !important;
text-transform: uppercase;
letter-spacing: 0.05em;
}
&__content {
@@ -41,32 +51,51 @@
&__value {
font-weight: 700;
margin-bottom: 4px;
color: var(--semi-color-text-0);
color: var(--card-accent, var(--semi-color-text-0));
}
&__desc {
color: var(--semi-color-text-3) !important;
}
&.blue {
--pulse-color: var(--semi-color-primary);
box-shadow: 0 4px 20px -5px var(--pulse-color);
--pulse-color: @color-blue-border;
--card-accent: @color-blue-text;
background-color: @color-blue-bg;
border-color: @color-blue-border;
box-shadow: 0 2px 16px -6px @color-blue-border;
}
&.orange {
--pulse-color: var(--semi-color-warning);
box-shadow: 0 4px 20px -5px var(--pulse-color);
--pulse-color: @color-orange-border;
--card-accent: @color-orange-text;
background-color: @color-orange-bg;
border-color: @color-orange-border;
box-shadow: 0 2px 16px -6px @color-orange-border;
}
&.green {
--pulse-color: var(--semi-color-success);
box-shadow: 0 4px 20px -5px var(--pulse-color);
--pulse-color: @color-green-border;
--card-accent: @color-green-text;
background-color: @color-green-bg;
border-color: @color-green-border;
box-shadow: 0 2px 16px -6px @color-green-border;
}
&.purple {
--pulse-color: var(--semi-color-info);
box-shadow: 0 4px 20px -5px var(--pulse-color);
--pulse-color: @color-purple-border;
--card-accent: @color-purple-text;
background-color: @color-purple-bg;
border-color: @color-purple-border;
box-shadow: 0 2px 16px -6px @color-purple-border;
}
&.gray {
--pulse-color: rgba(255, 255, 255, 0.2);
box-shadow: 0 4px 20px -5px var(--pulse-color);
--pulse-color: @color-gray-border;
--card-accent: @color-gray-text;
background-color: @color-gray-bg;
border-color: @color-gray-border;
box-shadow: 0 2px 16px -6px @color-gray-border;
}
}
@@ -75,6 +104,6 @@
opacity: 0.1;
}
50% {
opacity: 0.5;
opacity: 0.4;
}
}
}

View File

@@ -1,19 +1,19 @@
@color-blue-bg: rgba(0, 123, 255, 0.24);
@color-blue-border: #1E40AFFF;
@color-blue-bg: rgba(96, 165, 250, 0.10);
@color-blue-border: #3b6ea8;
@color-blue-text: #60a5fa;
@color-orange-bg: rgba(250, 91, 5, 0.12);
@color-orange-border: #992f0c;
@color-orange-text: #FB923CFF;
@color-orange-bg: rgba(251, 146, 60, 0.10);
@color-orange-border: #c2622a;
@color-orange-text: #fb923c;
@color-green-bg: rgba(38, 250, 5, 0.12);
@color-green-border: #278832;
@color-green-text: #33f308;
@color-green-bg: rgba(52, 211, 153, 0.10);
@color-green-border: #2a8a61;
@color-green-text: #34d399;
@color-purple-bg: rgba(91, 3, 218, 0.38);
@color-purple-border: #7500c3;
@color-purple-text: #b15fff;
@color-purple-bg: rgba(167, 139, 250, 0.10);
@color-purple-border: #6d4fc2;
@color-purple-text: #a78bfa;
@color-gray-bg: rgba(110, 110, 110, 0.38);
@color-gray-border: #807f7f;
@color-gray-text: #bab9b9;
@color-gray-bg: rgba(148, 163, 184, 0.10);
@color-gray-border: #323a47;
@color-gray-text: #94a3b8;

View File

@@ -9,7 +9,6 @@ import {
Col,
Row,
Button,
Space,
Typography,
Divider,
Switch,
@@ -20,6 +19,8 @@ import {
Pagination,
Toast,
Empty,
Radio,
RadioGroup,
} from '@douyinfe/semi-ui-19';
import {
IconAlertTriangle,
@@ -31,14 +32,16 @@ import {
IconBriefcase,
IconBell,
IconSearch,
IconFilter,
IconPlusCircle,
IconArrowUp,
IconArrowDown,
IconHome,
} from '@douyinfe/semi-icons';
import { useNavigate } from 'react-router-dom';
import ListingDeletionModal from '../../ListingDeletionModal.jsx';
import { useActions, useSelector } from '../../../services/state/store.js';
import { xhrDelete, xhrPut, xhrPost } from '../../../services/xhr.js';
import debounce from 'lodash/debounce';
import { debounce } from '../../../utils';
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
import './JobGrid.less';
@@ -59,8 +62,6 @@ const JobGrid = () => {
const [sortDir, setSortDir] = useState('asc');
const [freeTextFilter, setFreeTextFilter] = useState(null);
const [activityFilter, setActivityFilter] = useState(null);
const [showFilterBar, setShowFilterBar] = useState(false);
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
const [pendingDeletion, setPendingDeletion] = useState(null); // { type: 'job'|'listings', jobId }
@@ -200,73 +201,45 @@ const JobGrid = () => {
return (
<div className="jobGrid">
<Space vertical align="start" style={{ width: '100%', marginBottom: '16px' }} spacing="medium">
<div className="jobGrid__topbar">
<Button type="primary" icon={<IconPlusCircle />} onClick={() => navigate('/jobs/new')}>
New Job
</Button>
<div className="jobGrid__searchbar" style={{ width: '100%' }}>
<Input prefix={<IconSearch />} showClear placeholder="Search" onChange={handleFilterChange} />
<Button
icon={<IconFilter />}
style={{ marginLeft: '8px' }}
onClick={() => {
setShowFilterBar(!showFilterBar);
}}
/>
</div>
</Space>
{showFilterBar && (
<div className="jobGrid__toolbar">
<Space wrap style={{ marginBottom: '1rem' }}>
<div className="jobGrid__toolbar__card">
<div>
<Text strong>Filter by:</Text>
</div>
<div style={{ display: 'flex', gap: '.3rem' }}>
<Select
placeholder="Status"
showClear
onChange={(val) => setActivityFilter(val)}
value={activityFilter}
style={{ width: 140 }}
>
<Select.Option value={true}>Active</Select.Option>
<Select.Option value={false}>Not Active</Select.Option>
</Select>
</div>
</div>
<Divider layout="vertical" />
<div className="jobGrid__toolbar__card">
<div>
<Text strong>Sort by:</Text>
</div>
<div style={{ display: 'flex', gap: '.3rem' }}>
<Select
placeholder="Sort By"
style={{ width: 160 }}
value={sortField}
onChange={(val) => setSortField(val)}
>
<Select.Option value="name">Name</Select.Option>
<Select.Option value="numberOfFoundListings">Number of Listings</Select.Option>
<Select.Option value="enabled">Status</Select.Option>
</Select>
<Input
className="jobGrid__topbar__search"
prefix={<IconSearch />}
showClear
placeholder="Search"
onChange={handleFilterChange}
/>
<Select
placeholder="Direction"
style={{ width: 120 }}
value={sortDir}
onChange={(val) => setSortDir(val)}
>
<Select.Option value="asc">Ascending</Select.Option>
<Select.Option value="desc">Descending</Select.Option>
</Select>
</div>
</div>
</Space>
</div>
)}
<RadioGroup
type="button"
buttonSize="middle"
value={activityFilter === null ? 'all' : String(activityFilter)}
onChange={(e) => {
const v = e.target.value;
setActivityFilter(v === 'all' ? null : v === 'true');
}}
>
<Radio value="all">All</Radio>
<Radio value="true">Active</Radio>
<Radio value="false">Inactive</Radio>
</RadioGroup>
<Select prefix="Sort by" style={{ width: 200 }} value={sortField} onChange={(val) => setSortField(val)}>
<Select.Option value="name">Name</Select.Option>
<Select.Option value="numberOfFoundListings">Number of Listings</Select.Option>
<Select.Option value="enabled">Status</Select.Option>
</Select>
<Button
icon={sortDir === 'asc' ? <IconArrowUp /> : <IconArrowDown />}
onClick={() => setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))}
title={sortDir === 'asc' ? 'Ascending' : 'Descending'}
/>
</div>
{(jobsData?.result || []).length === 0 && (
<Empty
@@ -278,78 +251,70 @@ const JobGrid = () => {
<Row gutter={[16, 16]}>
{(jobsData?.result || []).map((job) => (
<Col key={job.id} xs={24} sm={12} md={8} lg={6} xl={4} xxl={6}>
<Card
className="jobGrid__card"
bodyStyle={{ padding: '16px' }}
title={
<div className="jobGrid__header">
<Col key={job.id} xs={24} sm={12} md={12} lg={8} xl={8} xxl={6}>
<Card className="jobGrid__card" bodyStyle={{ padding: '16px' }}>
<div className="jobGrid__card__header">
<div className="jobGrid__card__name">
<span className={`jobGrid__card__dot${job.enabled ? ' jobGrid__card__dot--active' : ''}`} />
<Title heading={5} ellipsis={{ showTooltip: true }} className="jobGrid__title">
{job.name}
</Title>
<div style={{ display: 'flex', alignItems: 'center' }}>
{job.isOnlyShared && (
<Popover
content={getPopoverContent(
'This job has been shared with you by another user, therefor it is read-only.',
)}
>
<div>
<IconAlertTriangle style={{ color: 'rgba(var(--semi-yellow-7), 1)', marginLeft: '8px' }} />
</div>
</Popover>
)}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
{job.isOnlyShared && (
<Popover
content={getPopoverContent(
'This job has been shared with you by another user, therefor it is read-only.',
)}
>
<div>
<IconAlertTriangle style={{ color: 'rgba(var(--semi-yellow-7), 1)' }} />
</div>
</Popover>
)}
{job.running && (
<Tag color="green" variant="light" size="small">
RUNNING
</Tag>
)}
</div>
}
>
<div className="jobGrid__content">
<Space vertical align="start" spacing={4} style={{ width: '100%', marginTop: 12 }}>
<div className="jobGrid__infoItem">
<Text type="secondary" icon={<IconSearch />} size="small">
Is active:
</Text>
<Switch
onChange={(checked) => onJobStatusChanged(job.id, checked)}
style={{ marginLeft: 'auto' }}
checked={job.enabled}
disabled={job.isOnlyShared}
size="small"
/>
</div>
<div className="jobGrid__infoItem">
<Text type="secondary" icon={<IconSearch />} size="small">
Listings:
</Text>
<Tag color="blue" size="small" style={{ marginLeft: 'auto' }}>
{job.numberOfFoundListings || 0}
</Tag>
</div>
<div className="jobGrid__infoItem">
<Text type="secondary" icon={<IconBriefcase />} size="small">
Providers:
</Text>
<Tag color="cyan" size="small" style={{ marginLeft: 'auto' }}>
{job.provider.length || 0}
</Tag>
</div>
<div className="jobGrid__infoItem">
<Text type="secondary" icon={<IconBell />} size="small">
Adapters:
</Text>
<Tag color="purple" size="small" style={{ marginLeft: 'auto' }}>
{job.notificationAdapter.length || 0}
</Tag>
</div>
</Space>
</div>
<Divider margin="12px" />
<div className="jobGrid__card__stats">
<div className="jobGrid__card__stat jobGrid__card__stat--blue">
<span className="jobGrid__card__stat__number">{job.numberOfFoundListings || 0}</span>
<span className="jobGrid__card__stat__label">
<IconHome size="small" /> Listings
</span>
</div>
<div className="jobGrid__card__stat jobGrid__card__stat--orange">
<span className="jobGrid__card__stat__number">{job.provider.length || 0}</span>
<span className="jobGrid__card__stat__label">
<IconBriefcase size="small" /> Providers
</span>
</div>
<div className="jobGrid__card__stat jobGrid__card__stat--purple">
<span className="jobGrid__card__stat__number">{job.notificationAdapter.length || 0}</span>
<span className="jobGrid__card__stat__label">
<IconBell size="small" /> Adapters
</span>
</div>
</div>
<Divider margin="12px" />
<div className="jobGrid__card__footer">
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Switch
onChange={(checked) => onJobStatusChanged(job.id, checked)}
checked={job.enabled}
disabled={job.isOnlyShared}
size="small"
/>
<Text type="secondary" size="small">
Active
</Text>
</div>
<div className="jobGrid__actions">
<Popover content={getPopoverContent('Run Job')}>
<div>

View File

@@ -1,3 +1,5 @@
@import '../../cards/DashboardCardColors.less';
.jobGrid {
&__card {
height: 100%;
@@ -12,55 +14,137 @@
box-shadow: 0 0 15px -3px rgb(78 78 78 / 70%);
background-color: rgba(36, 36, 36, 1);
}
}
&__searchbar {
display: flex;
gap: .5rem;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
&__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
margin-bottom: 16px;
}
&__toolbar {
&__card {
border-radius: var(--semi-border-radius-medium);
&__name {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
&__dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
background-color: var(--semi-color-text-3);
&--active {
background-color: #21aa21;
}
}
&__stats {
display: flex;
gap: 8px;
}
&__stat {
flex: 1;
display: flex;
flex-direction: column;
gap: .3rem;
background: rgba(36, 36, 36, 0.9);
backdrop-filter: blur(8px);
padding: 0.5rem;
border: 1px solid var(--semi-color-border);
align-items: center;
background: rgba(255, 255, 255, 0.04);
border: 1px solid transparent;
border-radius: var(--semi-border-radius-small);
padding: 10px 4px 8px;
&__number {
font-size: 22px;
font-weight: 600;
color: var(--semi-color-text-0);
line-height: 1.2;
}
&__label {
font-size: 11px;
color: var(--semi-color-text-3);
display: flex;
align-items: center;
gap: 3px;
margin-top: 4px;
}
&--blue {
background: @color-blue-bg;
border-color: @color-blue-border;
.jobGrid__card__stat__number { color: @color-blue-text; }
.jobGrid__card__stat__label { color: @color-blue-text; opacity: 0.7; }
}
&--orange {
background: @color-orange-bg;
border-color: @color-orange-border;
.jobGrid__card__stat__number { color: @color-orange-text; }
.jobGrid__card__stat__label { color: @color-orange-text; opacity: 0.7; }
}
&--purple {
background: @color-purple-bg;
border-color: @color-purple-border;
.jobGrid__card__stat__number { color: @color-purple-text; }
.jobGrid__card__stat__label { color: @color-purple-text; opacity: 0.7; }
}
}
&__footer {
display: flex;
align-items: center;
justify-content: space-between;
}
}
&__header {
&__topbar {
display: flex;
flex-wrap: nowrap;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 16px;
.jobGrid__topbar__search {
flex: 1;
min-width: 0;
}
@media (max-width: 768px) {
flex-wrap: wrap;
.semi-button:first-child {
flex-shrink: 0;
}
.jobGrid__topbar__search {
flex: 1;
min-width: 160px;
}
.semi-radio-group {
flex: 1;
}
.semi-select {
flex: 1;
min-width: 100px;
width: auto !important;
}
}
}
&__title {
margin-bottom: 0 !important;
}
&__infoItem {
display: flex;
align-items: center;
width: 100%;
.semi-typography {
display: flex;
align-items: center;
gap: 4px;
}
}
&__actions {
display: flex;
justify-content: space-between;
gap: 8px;
gap: 6px;
}
&__pagination {

View File

@@ -4,21 +4,27 @@
*/
import { useState, useEffect, useMemo } from 'react';
import {
useSearchParamState,
parseNumber,
parseString,
parseNullableBoolean,
} from '../../../hooks/useSearchParamState.js';
import {
Card,
Col,
Row,
Image,
Button,
Space,
Typography,
Pagination,
Toast,
Divider,
Input,
Select,
Popover,
Empty,
Radio,
RadioGroup,
} from '@douyinfe/semi-ui-19';
import {
IconBriefcase,
@@ -30,17 +36,18 @@ import {
IconStar,
IconStarStroked,
IconSearch,
IconFilter,
IconActivity,
IconEyeOpened,
IconArrowUp,
IconArrowDown,
} from '@douyinfe/semi-icons';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useSearchParams } from 'react-router-dom';
import ListingDeletionModal from '../../ListingDeletionModal.jsx';
import no_image from '../../../assets/no_image.jpg';
import * as timeService from '../../../services/time/timeService.js';
import { xhrDelete, xhrPost } from '../../../services/xhr.js';
import { useActions, useSelector } from '../../../services/state/store.js';
import debounce from 'lodash/debounce';
import { debounce } from '../../../utils';
import './ListingsGrid.less';
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
@@ -53,19 +60,18 @@ const ListingsGrid = () => {
const jobs = useSelector((state) => state.jobsData.jobs);
const actions = useActions();
const navigate = useNavigate();
const sp = useSearchParams();
const [page, setPage] = useState(1);
const [page, setPage] = useSearchParamState(sp, 'page', 1, parseNumber);
const pageSize = 40;
const [sortField, setSortField] = useState('created_at');
const [sortDir, setSortDir] = useState('desc');
const [freeTextFilter, setFreeTextFilter] = useState(null);
const [watchListFilter, setWatchListFilter] = useState(null);
const [jobNameFilter, setJobNameFilter] = useState(null);
const [activityFilter, setActivityFilter] = useState(null);
const [providerFilter, setProviderFilter] = useState(null);
const [showFilterBar, setShowFilterBar] = useState(false);
const [sortField, setSortField] = useSearchParamState(sp, 'sort', 'created_at', parseString);
const [sortDir, setSortDir] = useSearchParamState(sp, 'dir', 'desc', parseString);
const [freeTextFilter, setFreeTextFilter] = useSearchParamState(sp, 'q', null, parseString);
const [watchListFilter, setWatchListFilter] = useSearchParamState(sp, 'watch', null, parseNullableBoolean);
const [jobNameFilter, setJobNameFilter] = useSearchParamState(sp, 'job', null, parseString);
const [activityFilter, setActivityFilter] = useSearchParamState(sp, 'active', null, parseNullableBoolean);
const [providerFilter, setProviderFilter] = useSearchParamState(sp, 'provider', null, parseString);
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
const [listingToDelete, setListingToDelete] = useState(null);
@@ -84,7 +90,14 @@ const ListingsGrid = () => {
loadData();
}, [page, sortField, sortDir, freeTextFilter, providerFilter, activityFilter, jobNameFilter, watchListFilter]);
const handleFilterChange = useMemo(() => debounce((value) => setFreeTextFilter(value), 500), []);
const handleFilterChange = useMemo(
() =>
debounce((value) => {
setFreeTextFilter(value || null);
setPage(1);
}, 500),
[],
);
useEffect(() => {
return () => {
@@ -129,107 +142,93 @@ const ListingsGrid = () => {
return (
<div className="listingsGrid">
<div className="listingsGrid__searchbar">
<Input prefix={<IconSearch />} showClear placeholder="Search" onChange={handleFilterChange} />
<Popover content="Filter / Sort Results" style={{ color: 'white', padding: '.5rem' }}>
<div>
<Button
icon={<IconFilter />}
onClick={() => {
setShowFilterBar(!showFilterBar);
}}
/>
</div>
</Popover>
<div className="listingsGrid__topbar">
<Input
className="listingsGrid__topbar__search"
prefix={<IconSearch />}
showClear
placeholder="Search"
defaultValue={freeTextFilter ?? ''}
onChange={handleFilterChange}
/>
<RadioGroup
type="button"
buttonSize="middle"
value={activityFilter === null ? 'all' : String(activityFilter)}
onChange={(e) => {
const v = e.target.value;
setActivityFilter(v === 'all' ? null : v === 'true');
setPage(1);
}}
>
<Radio value="all">All</Radio>
<Radio value="true">Active</Radio>
<Radio value="false">Inactive</Radio>
</RadioGroup>
<RadioGroup
type="button"
buttonSize="middle"
value={watchListFilter === null ? 'all' : String(watchListFilter)}
onChange={(e) => {
const v = e.target.value;
setWatchListFilter(v === 'all' ? null : v === 'true');
setPage(1);
}}
>
<Radio value="all">All</Radio>
<Radio value="true">Watched</Radio>
<Radio value="false">Unwatched</Radio>
</RadioGroup>
<Select
placeholder="Provider"
showClear
onChange={(val) => {
setProviderFilter(val);
setPage(1);
}}
value={providerFilter}
style={{ width: 130 }}
>
{providers?.map((p) => (
<Select.Option key={p.id} value={p.id}>
{p.name}
</Select.Option>
))}
</Select>
<Select
placeholder="Job"
showClear
onChange={(val) => {
setJobNameFilter(val);
setPage(1);
}}
value={jobNameFilter}
style={{ width: 130 }}
>
{jobs?.map((j) => (
<Select.Option key={j.id} value={j.id}>
{j.name}
</Select.Option>
))}
</Select>
<Select prefix="Sort by" style={{ width: 185 }} value={sortField} onChange={(val) => setSortField(val)}>
<Select.Option value="job_name">Job Name</Select.Option>
<Select.Option value="created_at">Listing Date</Select.Option>
<Select.Option value="price">Price</Select.Option>
<Select.Option value="provider">Provider</Select.Option>
</Select>
<Button
icon={sortDir === 'asc' ? <IconArrowUp /> : <IconArrowDown />}
onClick={() => setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))}
title={sortDir === 'asc' ? 'Ascending' : 'Descending'}
/>
</div>
{showFilterBar && (
<div className="listingsGrid__toolbar">
<Space wrap style={{ marginBottom: '1rem' }}>
<div className="listingsGrid__toolbar__card">
<div>
<Text strong>Filter by:</Text>
</div>
<div style={{ display: 'flex', gap: '.3rem' }}>
<Select
placeholder="Status"
showClear
onChange={(val) => setActivityFilter(val)}
value={activityFilter}
>
<Select.Option value={true}>Active</Select.Option>
<Select.Option value={false}>Not Active</Select.Option>
</Select>
<Select
placeholder="Watchlist"
showClear
onChange={(val) => setWatchListFilter(val)}
value={watchListFilter}
>
<Select.Option value={true}>Watched</Select.Option>
<Select.Option value={false}>Not Watched</Select.Option>
</Select>
<Select
placeholder="Provider"
showClear
onChange={(val) => setProviderFilter(val)}
value={providerFilter}
>
{providers?.map((p) => (
<Select.Option key={p.id} value={p.id}>
{p.name}
</Select.Option>
))}
</Select>
<Select
placeholder="Job Name"
showClear
onChange={(val) => setJobNameFilter(val)}
value={jobNameFilter}
>
{jobs?.map((j) => (
<Select.Option key={j.id} value={j.id}>
{j.name}
</Select.Option>
))}
</Select>
</div>
</div>
<Divider layout="vertical" />
<div className="listingsGrid__toolbar__card">
<div>
<Text strong>Sort by:</Text>
</div>
<div style={{ display: 'flex', gap: '.3rem' }}>
<Select
placeholder="Sort By"
style={{ width: 140 }}
value={sortField}
onChange={(val) => setSortField(val)}
>
<Select.Option value="job_name">Job Name</Select.Option>
<Select.Option value="created_at">Listing Date</Select.Option>
<Select.Option value="price">Price</Select.Option>
<Select.Option value="provider">Provider</Select.Option>
</Select>
<Select
placeholder="Direction"
style={{ width: 120 }}
value={sortDir}
onChange={(val) => setSortDir(val)}
>
<Select.Option value="asc">Ascending</Select.Option>
<Select.Option value="desc">Descending</Select.Option>
</Select>
</div>
</div>
</Space>
</div>
)}
{(listingsData?.result || []).length === 0 && (
<Empty
@@ -240,7 +239,7 @@ const ListingsGrid = () => {
)}
<Row gutter={[16, 16]}>
{(listingsData?.result || []).map((item) => (
<Col key={item.id} xs={24} sm={12} md={8} lg={6} xl={4} xxl={6}>
<Col key={item.id} xs={24} sm={12} md={12} lg={8} xl={8} xxl={6}>
<Card
className={`listingsGrid__card ${!item.is_active ? 'listingsGrid__card--inactive' : ''}`}
style={{ cursor: 'pointer' }}
@@ -280,10 +279,11 @@ const ListingsGrid = () => {
<Text strong ellipsis={{ showTooltip: true }} className="listingsGrid__title">
{cap(item.title)}
</Text>
<Space vertical align="start" spacing={2} style={{ width: '100%', marginTop: 8 }}>
<Text type="secondary" icon={<IconCart />} size="small">
{item.price}
</Text>
<div className="listingsGrid__price">
<IconCart size="small" />
{item.price}
</div>
<div className="listingsGrid__meta">
<Text
type="secondary"
icon={<IconMapPin />}
@@ -305,18 +305,17 @@ const ListingsGrid = () => {
</Text>
) : (
<Text type="tertiary" size="small" icon={<IconActivity />}>
Distance cannot be calculated, provide an address
Distance cannot be calculated
</Text>
)}
</Space>
</div>
<Divider margin=".6rem" />
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<div className="listingsGrid__actions">
<div className="listingsGrid__linkButton" onClick={(e) => e.stopPropagation()}>
<a href={item.link} target="_blank" rel="noopener noreferrer">
<IconLink />
</a>
</div>
<Button
type="secondary"
size="small"
@@ -324,7 +323,6 @@ const ListingsGrid = () => {
onClick={() => navigate(`/listings/listing/${item.id}`)}
icon={<IconEyeOpened />}
/>
<Button
title="Remove"
type="danger"

View File

@@ -1,3 +1,5 @@
@import '../../cards/DashboardCardColors.less';
.listingsGrid {
&__imageContainer {
position: relative;
@@ -5,12 +7,34 @@
overflow: hidden;
}
&__searchbar {
&__topbar {
display: flex;
gap: .5rem;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
gap: 8px;
margin-bottom: 16px;
.listingsGrid__topbar__search {
flex: 1;
min-width: 200px;
}
@media (max-width: 768px) {
.listingsGrid__topbar__search {
width: 100%;
flex: unset;
}
.semi-radio-group {
flex: 1;
}
.semi-select {
flex: 1;
min-width: 100px;
width: auto !important;
}
}
}
&__watchButton {
@@ -93,17 +117,27 @@
justify-content: center;
}
&__toolbar {
&__card {
border-radius: var(--semi-border-radius-medium);
display: flex;
flex-direction: column;
gap: .3rem;
background: rgba(36, 36, 36, 0.9);
backdrop-filter: blur(8px);
padding: 0.5rem;
border: 1px solid var(--semi-color-border);
}
&__price {
font-size: 18px;
font-weight: 700;
color: @color-green-text;
display: flex;
align-items: center;
gap: 5px;
margin: 8px 0 6px;
}
&__meta {
display: flex;
flex-direction: column;
gap: 3px;
width: 100%;
}
&__actions {
display: flex;
justify-content: space-between;
align-items: center;
}
&__setupButton {

View File

@@ -42,24 +42,21 @@ export default function Navigation({ isAdmin }) {
];
if (isAdmin) {
const settingsItems = [
{ itemKey: '/users', text: 'User Management' },
{ itemKey: '/userSettings', text: 'User Specific Settings' },
{ itemKey: '/generalSettings', text: 'General Settings' },
];
items.push({
itemKey: 'settings',
text: 'Settings',
icon: <IconSetting />,
items: settingsItems,
items: [
{ itemKey: '/users', text: 'User Management' },
{ itemKey: '/generalSettings', text: 'Settings' },
],
});
} else {
items.push({
itemKey: 'settings',
text: 'Settings',
icon: <IconSetting />,
items: [{ itemKey: '/userSettings', text: 'User Specific Settings' }],
items: [{ itemKey: '/generalSettings', text: 'Settings' }],
});
}

View File

@@ -3,5 +3,5 @@
border-radius: .9rem !important;
color: rgba(var(--semi-grey-8), 1);
background: rgb(53, 54, 60);
margin: 2rem;
margin: 0 0 1rem 0;
}

View File

@@ -0,0 +1,96 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { useCallback } from 'react';
// Preset parsers for common types
export const parseString = {
parse: (v) => v,
stringify: (v) => v,
};
export const parseNumber = {
parse: (v) => Number(v),
stringify: (v) => String(v),
};
export const parseBoolean = {
parse: (v) => v === 'true',
stringify: (v) => String(v),
};
// For state that is null | true | false
export const parseNullableBoolean = {
parse: (v) => (v === 'true' ? true : v === 'false' ? false : null),
stringify: (v) => (v === null ? null : String(v)),
};
/**
* Drop-in replacement for useState that syncs with URL search params.
* Uses replace: true so filter changes don't add browser history entries.
*
* Requires a shared [searchParams, setSearchParams] pair from a single
* useSearchParams() call in the component. This ensures multiple hooks
* in the same component don't overwrite each other's params.
*
* @param {[URLSearchParams, Function]} searchParamsPair - from useSearchParams()
* @param {string} key - URL search param key
* @param {*} defaultValue - value when param is absent
* @param {{ parse: (s: string) => *, stringify: (v: *) => string|null }} [options]
*/
// WeakMap to store pending batched updates per setSearchParams function.
// This lets multiple useSearchParamState hooks on the same component batch
// their changes into a single setSearchParams call, preventing them from
// overwriting each other.
const pendingUpdates = new WeakMap();
export function useSearchParamState([searchParams, setSearchParams], key, defaultValue, options = {}) {
const { parse = (v) => v, stringify = (v) => String(v) } = options;
const rawValue = searchParams.get(key);
const value = rawValue !== null ? parse(rawValue) : defaultValue;
const setValue = useCallback(
(newValue) => {
// Collect the change
if (!pendingUpdates.has(setSearchParams)) {
pendingUpdates.set(setSearchParams, new Map());
// Schedule a single flush at the end of the current microtask
queueMicrotask(() => {
const updates = pendingUpdates.get(setSearchParams);
pendingUpdates.delete(setSearchParams);
if (!updates || updates.size === 0) return;
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
for (const [k, entry] of updates) {
if (entry.remove) {
next.delete(k);
} else {
next.set(k, entry.serialized);
}
}
return next;
},
{ replace: true },
);
});
}
const batch = pendingUpdates.get(setSearchParams);
const serialized = stringify(newValue);
if (newValue === defaultValue || newValue === null || newValue === undefined || serialized === null) {
batch.set(key, { remove: true });
} else {
batch.set(key, { remove: false, serialized });
}
},
[key, defaultValue, stringify, setSearchParams],
);
return [value, setValue];
}

View File

@@ -207,14 +207,17 @@ export const useFredyState = create(
filter,
}) {
try {
const qryString = queryString.stringify({
page,
pageSize,
freeTextFilter,
sortfield,
sortdir,
...filter,
});
const qryString = queryString.stringify(
{
page,
pageSize,
freeTextFilter,
sortfield,
sortdir,
...filter,
},
{ skipNull: true, skipEmptyString: true },
);
const response = await xhrGet(`/api/listings/table?${qryString}`);
set((state) => ({
listingsData: { ...state.listingsData, ...response.json },
@@ -304,17 +307,17 @@ export const useFredyState = create(
throw Exception;
}
},
async setImmoscoutDetails(enabled) {
async setProviderDetails(providers) {
try {
await xhrPost('/api/user/settings/immoscout-details', { immoscout_details: enabled });
await xhrPost('/api/user/settings/provider-details', { provider_details: providers });
set((state) => ({
userSettings: {
...state.userSettings,
settings: { ...state.userSettings.settings, immoscout_details: enabled },
settings: { ...state.userSettings.settings, provider_details: providers },
},
}));
} catch (Exception) {
console.error('Error while trying to update immoscout details setting. Error:', Exception);
console.error('Error while trying to update provider details setting. Error:', Exception);
throw Exception;
}
},

17
ui/src/utils.js Normal file
View File

@@ -0,0 +1,17 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
export function debounce(fn, delay) {
let timer;
function debounced(...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
}
debounced.cancel = () => clearTimeout(timer);
return debounced;
}

View File

@@ -4,7 +4,7 @@
*/
import React from 'react';
import { Button, Col, Row, Toast } from '@douyinfe/semi-ui-19';
import { Button, Col, Row, Toast, Typography } from '@douyinfe/semi-ui-19';
import {
IconTerminal,
IconStar,
@@ -22,7 +22,6 @@ import KpiCard from '../../components/cards/KpiCard.jsx';
import PieChartCard from '../../components/cards/PieChartCard.jsx';
import './Dashboard.less';
import { SegmentPart } from '../../components/segment/SegmentPart.jsx';
import { xhrPost } from '../../services/xhr.js';
import { format } from '../../services/time/timeService.js';
@@ -35,129 +34,119 @@ export default function Dashboard() {
const kpis = dashboard?.kpis || { totalJobs: 0, totalListings: 0, providersUsed: 0 };
const pieData = dashboard?.pie || [];
const { Text } = Typography;
return (
<div className="dashboard">
<Text className="dashboard__section-label">General</Text>
<Row gutter={[16, 16]} className="dashboard__row">
<Col span={12} xs={24} sm={24} md={24} lg={24} xl={12}>
<SegmentPart name="General" Icon={IconTerminal}>
<Row gutter={[16, 16]} className="dashboard__row">
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
<KpiCard
title="Search Interval"
value={`${dashboard?.general?.interval} min`}
icon={<IconClock />}
description="Time interval for job execution"
/>
</Col>
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
<KpiCard
title="Last Search"
valueFontSize="14px"
value={
dashboard?.general?.lastRun == null || dashboard?.general?.lastRun === 0
? '---'
: format(dashboard?.general?.lastRun)
}
icon={<IconDoubleChevronLeft />}
description="Last execution timestamp"
/>
</Col>
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
<KpiCard
title="Next Search"
value={
dashboard?.general?.nextRun == null || dashboard?.general?.nextRun === 0
? '---'
: format(dashboard?.general?.nextRun)
}
valueFontSize="14px"
icon={<IconDoubleChevronRight />}
description="Next execution timestamp"
/>
</Col>
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
<KpiCard title="Search Now" icon={<IconSearch />} description="Run a search now">
<Button
size="small"
style={{ marginTop: '.2rem' }}
icon={<IconPlayCircle />}
aria-label="Start now"
onClick={async () => {
try {
await xhrPost('/api/jobs/startAll', null);
Toast.success('Successfully triggered Fredy search.');
} catch {
Toast.error('Failed to trigger search');
}
}}
>
Search now
</Button>
</KpiCard>
</Col>
</Row>
</SegmentPart>
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
<KpiCard
title="Search Interval"
value={`${dashboard?.general?.interval} min`}
icon={<IconClock />}
description="Time interval for job execution"
/>
</Col>
<Col span={12} xs={24} sm={24} md={24} lg={24} xl={12}>
<SegmentPart name="Overview" Icon={IconStar}>
<Row gutter={[16, 16]} className="dashboard__row">
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
<KpiCard
title="Jobs"
color="blue"
value={!kpis.totalJobs ? '---' : kpis.totalJobs}
icon={<IconTerminal />}
description="Total number of jobs"
/>
</Col>
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
<KpiCard
title="Listings"
color="orange"
value={!kpis.totalListings ? '---' : kpis.totalListings}
icon={<IconStarStroked />}
description="Total listings found"
/>
</Col>
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
<KpiCard
title="Active Listings"
color="green"
value={!kpis.numberOfActiveListings ? '---' : kpis.numberOfActiveListings}
icon={<IconStar />}
description="Total active listings"
/>
</Col>
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
<KpiCard
title="Avg. Price"
color="purple"
value={`${
!kpis.avgPriceOfListings
? '---'
: new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(kpis.avgPriceOfListings)
}`}
icon={<IconNoteMoney />}
description="Avg. Price of listings"
/>
</Col>
</Row>
</SegmentPart>
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
<KpiCard
title="Last Search"
valueFontSize="14px"
value={
dashboard?.general?.lastRun == null || dashboard?.general?.lastRun === 0
? '---'
: format(dashboard?.general?.lastRun)
}
icon={<IconDoubleChevronLeft />}
description="Last execution timestamp"
/>
</Col>
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
<KpiCard
title="Next Search"
value={
dashboard?.general?.nextRun == null || dashboard?.general?.nextRun === 0
? '---'
: format(dashboard?.general?.nextRun)
}
valueFontSize="14px"
icon={<IconDoubleChevronRight />}
description="Next execution timestamp"
/>
</Col>
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
<KpiCard title="Search Now" icon={<IconSearch />} description="Run a search now">
<Button
size="small"
style={{ marginTop: '.2rem' }}
icon={<IconPlayCircle />}
aria-label="Start now"
onClick={async () => {
try {
await xhrPost('/api/jobs/startAll', null);
Toast.success('Successfully triggered Fredy search.');
} catch {
Toast.error('Failed to trigger search');
}
}}
>
Search now
</Button>
</KpiCard>
</Col>
</Row>
<SegmentPart
name="Provider Insights"
Icon={IconStar}
helpText="Percentage of found listings over all providers"
className="dashboard__provider-insights"
>
<Text className="dashboard__section-label">Overview</Text>
<Row gutter={[16, 16]} className="dashboard__row">
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
<KpiCard
title="Jobs"
color="blue"
value={!kpis.totalJobs ? '---' : kpis.totalJobs}
icon={<IconTerminal />}
description="Total number of jobs"
/>
</Col>
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
<KpiCard
title="Listings"
color="orange"
value={!kpis.totalListings ? '---' : kpis.totalListings}
icon={<IconStarStroked />}
description="Total listings found"
/>
</Col>
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
<KpiCard
title="Active Listings"
color="green"
value={!kpis.numberOfActiveListings ? '---' : kpis.numberOfActiveListings}
icon={<IconStar />}
description="Total active listings"
/>
</Col>
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
<KpiCard
title="Avg. Price"
color="purple"
value={`${
!kpis.avgPriceOfListings
? '---'
: new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(kpis.avgPriceOfListings)
}`}
icon={<IconNoteMoney />}
description="Avg. Price of listings"
/>
</Col>
</Row>
<Text className="dashboard__section-label">Provider Insights</Text>
<div className="dashboard__pie-wrapper">
<PieChartCard data={pieData} />
</SegmentPart>
</div>
</div>
);
}

View File

@@ -3,31 +3,32 @@
flex-direction: column;
flex: 1;
&__row {
margin-bottom: 24px;
flex-wrap: wrap;
.semi-col {
margin-bottom: 0; // Handled by Row gutter
}
&__section-label {
display: block;
font-size: 11px !important;
font-weight: 600 !important;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #5a6478 !important;
margin-bottom: 10px;
margin-top: 4px;
}
&__provider-insights {
&__row {
margin-bottom: 8px;
flex-wrap: wrap;
}
&__pie-wrapper {
background: #23242a;
border: 1px solid #37404e;
border-radius: 10px;
padding: 24px;
max-height: 320px;
flex: 1;
display: flex;
flex-direction: column;
margin: 0 !important;
.semi-card-body {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
max-height: 300px;
> * {
flex: 1;
}
}
justify-content: center;
}
}

View File

@@ -3,29 +3,33 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React from 'react';
import React, { useEffect, useState, useMemo } from 'react';
import { useActions, useSelector } from '../../services/state/store';
import { useActions, useSelector, useIsLoading } from '../../services/state/store';
import { Divider, TimePicker, Button, Checkbox, Input, Modal } from '@douyinfe/semi-ui-19';
import {
Tabs,
TabPane,
TimePicker,
Button,
Checkbox,
Input,
Modal,
AutoComplete,
Select,
Banner,
} from '@douyinfe/semi-ui-19';
import { InputNumber } from '@douyinfe/semi-ui-19';
import { xhrPost } from '../../services/xhr';
import { xhrPost, xhrGet } from '../../services/xhr';
import { Toast } from '@douyinfe/semi-ui-19';
import { SegmentPart } from '../../components/segment/SegmentPart';
import { Banner, Toast } from '@douyinfe/semi-ui-19';
import {
downloadBackup as downloadBackupZip,
precheckRestore as clientPrecheckRestore,
restore as clientRestore,
} from '../../services/backupRestoreClient';
import {
IconSave,
IconCalendar,
IconRefresh,
IconSignal,
IconLineChartStroked,
IconSearch,
IconFolder,
} from '@douyinfe/semi-icons';
import { IconSave, IconRefresh, IconSignal, IconHome, IconFolder } from '@douyinfe/semi-icons';
import { debounce } from '../../utils';
import './GeneralSettings.less';
function formatFromTimestamp(ts) {
@@ -63,6 +67,15 @@ const GeneralSettings = function GeneralSettings() {
const [restoreBusy, setRestoreBusy] = React.useState(false);
const [selectedRestoreFile, setSelectedRestoreFile] = React.useState(null);
// User settings state
const homeAddress = useSelector((state) => state.userSettings.settings.home_address);
const providerDetails = useSelector((state) => state.userSettings.settings.provider_details);
const allProviders = useSelector((state) => state.provider);
const [address, setAddress] = useState(homeAddress?.address || '');
const [coords, setCoords] = useState(homeAddress?.coords || null);
const saving = useIsLoading(actions.userSettings.setHomeAddress);
const [dataSource, setDataSource] = useState([]);
React.useEffect(() => {
async function init() {
await actions.generalSettings.getGeneralSettings();
@@ -86,6 +99,11 @@ const GeneralSettings = function GeneralSettings() {
init();
}, [settings]);
useEffect(() => {
setAddress(homeAddress?.address || '');
setCoords(homeAddress?.coords || null);
}, [homeAddress]);
const nullOrEmpty = (val) => val == null || val.length === 0;
const handleStore = async () => {
@@ -177,7 +195,6 @@ const GeneralSettings = function GeneralSettings() {
if (!file) return;
setSelectedRestoreFile(file);
await precheckRestore(file);
// reset the input to allow same file re-select
ev.target.value = '';
},
[precheckRestore],
@@ -189,180 +206,281 @@ const GeneralSettings = function GeneralSettings() {
}
}, []);
const handleSaveUserSettings = async () => {
try {
const responseJson = await actions.userSettings.setHomeAddress(address);
setCoords(responseJson.coords);
await actions.userSettings.getUserSettings();
Toast.success('Settings saved. Distance calculations are running in the background.');
} catch (error) {
Toast.error(error.json?.error || 'Error while saving settings');
}
};
const debouncedSearch = useMemo(
() =>
debounce((value) => {
xhrGet(`/api/user/settings/autocomplete?q=${encodeURIComponent(value)}`)
.then((response) => {
if (response.status === 200) {
setDataSource(response.json);
}
})
.catch(() => {});
}, 300),
[],
);
const searchAddress = (value) => {
if (!value) {
setDataSource([]);
return;
}
debouncedSearch(value);
};
return (
<div>
<div className="generalSettings">
{!loading && (
<React.Fragment>
<div>
<SegmentPart
name="Interval"
helpText="Interval in minutes for running queries against the configured services. Do NOT go under 5 minutes as with a lower interval, your instance might be detected as a bot."
Icon={IconRefresh}
<>
<Tabs type="line">
<TabPane
tab={
<span>
<IconSignal size="small" style={{ marginRight: 6 }} />
System
</span>
}
itemKey="system"
>
<InputNumber
min={5}
max={1440}
placeholder="Interval in minutes"
value={interval}
formatter={(value) => `${value}`.replace(/\D/g, '')}
onChange={(value) => setInterval(value)}
suffix={'minutes'}
/>
</SegmentPart>
<Divider margin="1rem" />
<SegmentPart
name="Backup & Restore"
helpText="Download a zipped backup of your database or restore it from a backup zip."
Icon={IconSave}
>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
<Button theme="solid" icon={<IconSave />} onClick={handleDownloadBackup}>
Download backup
</Button>
<input
type="file"
accept=".zip,application/zip"
ref={fileInputRef}
style={{ display: 'none' }}
onChange={handleSelectRestoreFile}
/>
<Button onClick={handleOpenFilePicker} theme="light" icon={<IconFolder />}>
Restore from zip
</Button>
<div className="generalSettings__tab-content">
<SegmentPart name="Port" helpText="The port on which Fredy is running.">
<InputNumber
min={0}
max={99999}
placeholder="Port"
value={port}
formatter={(value) => `${value}`.replace(/\D/g, '')}
onChange={(value) => setPort(value)}
style={{ maxWidth: 160 }}
/>
</SegmentPart>
<SegmentPart
name="SQLite Database Path"
helpText="The directory where Fredy stores its SQLite database files."
>
<Banner
fullMode={false}
type="warning"
closeIcon={null}
style={{ marginBottom: '12px' }}
description="Changing this path may result in data loss. Restart Fredy immediately after saving."
/>
<Input
type="text"
placeholder="Database folder path"
value={sqlitePath}
onChange={(value) => setSqlitePath(value)}
/>
</SegmentPart>
<SegmentPart
name="Analytics"
helpText="Anonymous usage data to help improve Fredy — provider names, adapter names, OS, Node version, and architecture."
>
<Checkbox checked={analyticsEnabled} onChange={(e) => setAnalyticsEnabled(e.target.checked)}>
Enable analytics
</Checkbox>
</SegmentPart>
<SegmentPart
name="Demo Mode"
helpText="In demo mode, Fredy will not search for real estates and all data resets to defaults at midnight."
>
<Checkbox checked={demoMode} onChange={(e) => setDemoMode(e.target.checked)}>
Enable demo mode
</Checkbox>
</SegmentPart>
<div className="generalSettings__save-row">
<Button type="primary" theme="solid" onClick={handleStore} icon={<IconSave />}>
Save
</Button>
</div>
</div>
</SegmentPart>
<Divider margin="1rem" />
<SegmentPart name="Port" helpText="Port on which Fredy is running." Icon={IconSignal}>
<InputNumber
min={0}
max={99999}
placeholder="Port"
value={port}
formatter={(value) => `${value}`.replace(/\D/g, '')}
onChange={(value) => setPort(value)}
/>
</SegmentPart>
<Divider margin="1rem" />
<SegmentPart
name="SQLite Database path"
helpText="The directory where Fredy stores its SQLite database files."
Icon={IconFolder}
>
<Banner
fullMode={false}
type="warning"
closeIcon={null}
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>Warning</div>}
style={{ marginBottom: '1rem' }}
description={
<div>
Changing the path later may result in data loss.
<br />
You <b>must</b> restart Fredy immediately after changing this setting!
</div>
}
/>
</TabPane>
<Input
type="text"
placeholder="Select folder"
value={sqlitePath}
onChange={(value) => {
setSqlitePath(value);
}}
/>
</SegmentPart>
<Divider margin="1rem" />
<SegmentPart
name="Working hours"
helpText="During these hours, Fredy will search for new apartments. If nothing is configured, Fredy will search around the clock."
Icon={IconCalendar}
<TabPane
tab={
<span>
<IconRefresh size="small" style={{ marginRight: 6 }} />
Execution
</span>
}
itemKey="execution"
>
<div className="generalSettings__timePickerContainer">
<TimePicker
format={'HH:mm'}
insetLabel="From"
value={formatFromTBackend(workingHourFrom)}
placeholder=""
onChange={(val) => {
setWorkingHourFrom(val == null ? null : formatFromTimestamp(val));
}}
/>
<TimePicker
format={'HH:mm'}
insetLabel="Until"
value={formatFromTBackend(workingHourTo)}
placeholder=""
onChange={(val) => {
setWorkingHourTo(val == null ? null : formatFromTimestamp(val));
}}
/>
<div className="generalSettings__tab-content">
<SegmentPart
name="Search Interval"
helpText="Interval in minutes for running queries against configured services. Do not go below 5 minutes to avoid being detected as a bot."
>
<InputNumber
min={5}
max={1440}
placeholder="Interval in minutes"
value={interval}
formatter={(value) => `${value}`.replace(/\D/g, '')}
onChange={(value) => setInterval(value)}
suffix={'minutes'}
style={{ maxWidth: 200 }}
/>
</SegmentPart>
<SegmentPart
name="Working Hours"
helpText="Fredy will only search for listings during these hours. Leave empty to search around the clock."
>
<div className="generalSettings__timePickerContainer">
<TimePicker
format={'HH:mm'}
insetLabel="From"
value={formatFromTBackend(workingHourFrom)}
placeholder=""
onChange={(val) => {
setWorkingHourFrom(val == null ? null : formatFromTimestamp(val));
}}
/>
<TimePicker
format={'HH:mm'}
insetLabel="Until"
value={formatFromTBackend(workingHourTo)}
placeholder=""
onChange={(val) => {
setWorkingHourTo(val == null ? null : formatFromTimestamp(val));
}}
/>
</div>
</SegmentPart>
<div className="generalSettings__save-row">
<Button type="primary" theme="solid" onClick={handleStore} icon={<IconSave />}>
Save
</Button>
</div>
</div>
</SegmentPart>
<Divider margin="1rem" />
</TabPane>
<SegmentPart name="Analytics" helpText="Insights into the usage of Fredy." Icon={IconLineChartStroked}>
<Banner
fullMode={false}
type="info"
closeIcon={null}
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>Explanation</div>}
style={{ marginBottom: '1rem' }}
description={
<div>
Analytics are disabled by default. If you choose to enable them, we will begin tracking the
following:
<br />
<ul>
<li>Name of active provider (e.g. Immoscout)</li>
<li>Name of active adapter (e.g. Console)</li>
<li>language</li>
<li>os</li>
<li>node version</li>
<li>arch</li>
</ul>
The data is sent anonymously and helps me understand which providers or adapters are being used the
most. In the end it helps me to improve fredy.
<TabPane
tab={
<span>
<IconHome size="small" style={{ marginRight: 6 }} />
User Settings
</span>
}
itemKey="userSettings"
>
<div className="generalSettings__tab-content">
<SegmentPart
name="Home Address"
helpText="Used to calculate distances between your location and each listing. Updating this recalculates distances for all active listings."
>
<AutoComplete
data={dataSource}
value={address}
showClear
onChange={(v) => setAddress(v)}
onSearch={searchAddress}
placeholder="Enter your home address"
style={{ width: '100%' }}
/>
{coords && coords.lat === -1 && (
<Banner
type="danger"
description="Address found but could not be geocoded accurately."
closeIcon={null}
style={{ marginTop: 8 }}
/>
)}
</SegmentPart>
<SegmentPart
name="Provider Details"
helpText="Fetch additional details (description, attributes, agent info) for listings. Needs an extra API call per listing."
>
<Banner
type="warning"
description="Enabling this significantly increases API requests to providers that have implemented this feature, raising the chance of rate limiting or blocking. Use at your own risk."
closeIcon={null}
style={{ marginBottom: 12 }}
/>
<Select
multiple
style={{ width: '100%' }}
value={Array.isArray(providerDetails) ? providerDetails : []}
optionList={(allProviders ?? []).map((p) => ({ label: p.name, value: p.id }))}
placeholder="Select providers to fetch details from..."
onChange={async (selected) => {
try {
await actions.userSettings.setProviderDetails(selected);
Toast.success('Provider details setting updated.');
} catch {
Toast.error('Failed to update setting.');
}
}}
/>
</SegmentPart>
<div className="generalSettings__save-row">
<Button
icon={<IconSave />}
theme="solid"
type="primary"
onClick={handleSaveUserSettings}
loading={saving}
>
Save
</Button>
</div>
</div>
</TabPane>
<TabPane
tab={
<span>
<IconFolder size="small" style={{ marginRight: 6 }} />
Backup & Restore
</span>
}
itemKey="backup"
>
<div className="generalSettings__tab-content">
<SegmentPart
name="Backup & Restore"
helpText="Download a zipped backup of your database or restore from a backup zip."
>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
<Button theme="solid" icon={<IconSave />} onClick={handleDownloadBackup}>
Download Backup
</Button>
<input
type="file"
accept=".zip,application/zip"
ref={fileInputRef}
style={{ display: 'none' }}
onChange={handleSelectRestoreFile}
/>
<Button onClick={handleOpenFilePicker} theme="light" icon={<IconFolder />}>
Restore from Zip
</Button>
</div>
}
/>
<Checkbox checked={analyticsEnabled} onChange={(e) => setAnalyticsEnabled(e.target.checked)}>
{' '}
Enabled
</Checkbox>
</SegmentPart>
<Divider margin="1rem" />
<SegmentPart name="Demo Mode" helpText="If enabled, Fredy runs in demo mode." Icon={IconSearch}>
<Banner
fullMode={false}
type="info"
closeIcon={null}
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>Explanation</div>}
style={{ marginBottom: '1rem' }}
description={
<div>
In demo mode, Fredy will not (really) search for any real estates. Fredy is in a lockdown mode. Also
all database files will be set back to the default values at midnight.
</div>
}
/>
<Checkbox checked={demoMode} onChange={(e) => setDemoMode(e.target.checked)}>
{' '}
Enabled
</Checkbox>
</SegmentPart>
<Divider margin="1rem" />
<Button type="primary" theme="solid" onClick={handleStore} icon={<IconSave />}>
Save
</Button>
</div>
</React.Fragment>
</SegmentPart>
</div>
</TabPane>
</Tabs>
</>
)}
{restoreModalVisible && (
<Modal
title="Restore database"

View File

@@ -1,12 +1,17 @@
.generalSettings {
&__tab-content {
padding: 20px 0;
max-width: 860px;
}
&__timePickerContainer {
display: flex;
align-items: baseline;
gap: 1rem;
flex-wrap: wrap;
}
&__help {
font-size: 11px;
margin-left: 1rem;
&__save-row {
margin-top: 1.5rem;
}
}

View File

@@ -158,11 +158,11 @@ export default function NotificationAdapterMutator({
{uiElement.type === 'boolean' ? (
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<Switch
checked={uiElement.value || false}
onChange={(checked) => {
setValue(selectedAdapter, uiElement, key, checked);
}}
/>
checked={uiElement.value || false}
onChange={(checked) => {
setValue(selectedAdapter, uiElement, key, checked);
}}
/>
{uiElement.label}
</div>
) : (
@@ -173,6 +173,7 @@ export default function NotificationAdapterMutator({
initValue={uiElement.value ?? ''}
placeholder={uiElement.label}
label={uiElement.label}
extraText={uiElement.description}
onChange={(value) => {
setValue(selectedAdapter, uiElement, key, value);
}}

View File

@@ -4,24 +4,26 @@
*/
import { useEffect, useRef, useState } from 'react';
import { parseBoolean, parseNumber, parseString, useSearchParamState } from '../../hooks/useSearchParamState.js';
import { renderToString } from 'react-dom/server';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import { useSelector, useActions } from '../../services/state/store.js';
import { useActions, useSelector } from '../../services/state/store.js';
import { distanceMeters, generateCircleCoords, getBoundsFromCenter, getBoundsFromCoords } from './mapUtils.js';
import { Select, Space, Typography, Button, Popover, Divider, Switch, Banner, Toast } from '@douyinfe/semi-ui-19';
import { IconFilter, IconLink } from '@douyinfe/semi-icons';
import { IconDelete, IconEyeOpened } from '@douyinfe/semi-icons';
import { Banner, Select, Switch, Toast, Typography } from '@douyinfe/semi-ui-19';
import { IconDelete, IconEyeOpened, IconLink } from '@douyinfe/semi-icons';
import no_image from '../../assets/no_image.jpg';
import RangeSlider from 'react-range-slider-input';
import _RangeSlider from 'react-range-slider-input';
import 'react-range-slider-input/dist/style.css';
import './Map.less';
import { xhrDelete } from '../../services/xhr.js';
import { Link, useNavigate } from 'react-router-dom';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import ListingDeletionModal from '../../components/ListingDeletionModal.jsx';
import Map from '../../components/map/Map.jsx';
const RangeSlider = _RangeSlider?.default ?? _RangeSlider;
const { Text } = Typography;
export default function MapView() {
@@ -31,16 +33,21 @@ export default function MapView() {
const homeMarker = useRef(null);
const actions = useActions();
const navigate = useNavigate();
const sp = useSearchParams();
const [searchParams, setSearchParams] = sp;
const listings = useSelector((state) => state.listingsData.mapListings);
const homeAddress = useSelector((state) => state.userSettings.settings.home_address);
const [style, setStyle] = useState('STANDARD');
const [show3dBuildings, setShow3dBuildings] = useState(false);
const jobs = useSelector((state) => state.jobsData.jobs);
const [jobId, setJobId] = useState(null);
const [priceRange, setPriceRange] = useState([0, 0]);
const [showFilterBar, setShowFilterBar] = useState(false);
const [distanceFilter, setDistanceFilter] = useState(0);
const [jobId, setJobId] = useSearchParamState(sp, 'job', null, parseString);
const [distanceFilter, setDistanceFilter] = useSearchParamState(sp, 'distance', 0, parseNumber);
const [style] = useSearchParamState(sp, 'style', 'STANDARD', parseString);
const [show3dBuildings, setShow3dBuildings] = useSearchParamState(sp, 'buildings', false, parseBoolean);
// Price range: stored as priceMin/priceMax URL params; default max derived from loaded listings
const urlPriceMin = searchParams.has('priceMin') ? Number(searchParams.get('priceMin')) : null;
const urlPriceMax = searchParams.has('priceMax') ? Number(searchParams.get('priceMax')) : null;
const [priceRange, setPriceRange] = useState([urlPriceMin ?? 0, urlPriceMax ?? 0]);
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
const [listingToDelete, setListingToDelete] = useState(null);
@@ -59,14 +66,17 @@ export default function MapView() {
};
useEffect(() => {
setPriceRange([0, getMaxPrice()]);
// Only reset to full range when no URL override is set
if (urlPriceMax === null) {
setPriceRange([0, getMaxPrice()]);
}
}, [listings]);
const getMaxPrice = () => {
return listings.reduce((max, item) => {
return listings.reduce((acc, item) => {
const price = Number(item.price);
return Number.isFinite(price) && price > max ? price : max;
}, -Infinity);
return Number.isFinite(price) && price > acc ? price : acc;
}, 0);
};
const filterListings = () => {
@@ -92,10 +102,8 @@ export default function MapView() {
};
}, [navigate]);
// Get map instance reference after MapComponent renders
useEffect(() => {
if (mapContainer.current && !map.current) {
// Wait for MapComponent to initialize the map
const checkMapReady = () => {
if (mapContainer.current?.map) {
map.current = mapContainer.current.map;
@@ -111,11 +119,45 @@ export default function MapView() {
map.current = mapInstance;
};
const setMapStyle = (value) => {
setStyle(value);
if (value === 'SATELLITE') {
setShow3dBuildings(false);
}
const handleMapStyle = (value) => {
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
if (value === 'STANDARD') {
next.delete('style');
} else {
next.set('style', value);
}
if (value === 'SATELLITE') {
next.delete('buildings');
}
return next;
},
{ replace: true },
);
};
const handlePriceRange = (val) => {
const maxPrice = getMaxPrice();
if (maxPrice <= 0) return; // skip until listings are loaded
setPriceRange(val);
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
if (val[0] === 0) {
next.delete('priceMin');
} else {
next.set('priceMin', String(val[0]));
}
if (val[1] === 0 || val[1] >= maxPrice) {
next.delete('priceMax');
} else {
next.set('priceMax', String(val[1]));
}
return next;
},
{ replace: true },
);
};
const fetchListings = async () => {
@@ -132,8 +174,6 @@ export default function MapView() {
if (!map.current) return;
if (homeAddress?.coords) {
// We only want to zoom/fly when distanceFilter OR homeAddress actually change,
// not on every render. useEffect dependency array handles this.
if (distanceFilter > 0) {
const bounds = getBoundsFromCenter([homeAddress.coords.lng, homeAddress.coords.lat], distanceFilter);
@@ -290,7 +330,7 @@ export default function MapView() {
const popup = new maplibregl.Popup({ offset: 25 }).setHTML(popupContent);
let color = '#3FB1CE'; // Default blue-ish
let color = '#3FB1CE';
if (distanceFilter > 0 && homeAddress?.coords) {
const dist = distanceMeters(
homeAddress.coords.lat,
@@ -315,114 +355,17 @@ export default function MapView() {
return (
<div className="map-view-container">
<div className="listingsGrid__searchbar map-filter-bar">
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexGrow: 1 }}>
<Text strong>Map View</Text>
<Select placeholder="Style" style={{ width: 120 }} value={style} onChange={(val) => setMapStyle(val)}>
<Select.Option value="STANDARD">Standard</Select.Option>
<Select.Option value="SATELLITE">Satellite</Select.Option>
</Select>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginLeft: '1rem' }}>
<Text strong>3D Buildings</Text>
<Switch size="small" checked={show3dBuildings} onChange={(v) => setShow3dBuildings(v)} />
</div>
</div>
<Popover content="Filter Results" style={{ color: 'white', padding: '.5rem' }}>
<div>
<Button
icon={<IconFilter />}
onClick={() => {
setShowFilterBar(!showFilterBar);
}}
/>
</div>
</Popover>
</div>
{showFilterBar && (
<div className="listingsGrid__toolbar">
<Space wrap style={{ marginBottom: '1rem' }}>
<div className="listingsGrid__toolbar__card">
<div>
<Text strong>Filter by:</Text>
</div>
<div style={{ display: 'flex', gap: '.3rem', alignItems: 'center' }}>
<Select
placeholder="Job"
showClear
style={{ width: 150 }}
onChange={(val) => {
setJobId(val);
}}
value={jobId}
>
{jobs?.map((j) => (
<Select.Option key={j.id} value={j.id}>
{j.name}
</Select.Option>
))}
</Select>
</div>
</div>
<Divider layout="vertical" />
<div className="listingsGrid__toolbar__card">
<div>
<Text strong>Distance:</Text>
</div>
<div style={{ display: 'flex', gap: '.3rem', alignItems: 'center' }}>
<Select
placeholder="Distance"
style={{ width: 100 }}
onChange={(val) => {
setDistanceFilter(val);
}}
value={distanceFilter}
>
<Select.Option value={0}>---</Select.Option>
<Select.Option value={5}>5 km</Select.Option>
<Select.Option value={10}>10 km</Select.Option>
<Select.Option value={15}>15 km</Select.Option>
<Select.Option value={20}>20 km</Select.Option>
<Select.Option value={25}>25 km</Select.Option>
</Select>
</div>
</div>
<Divider layout="vertical" />
<div className="listingsGrid__toolbar__card">
<div>
<Text strong>Price Range ():</Text>
</div>
<div style={{ width: 250, padding: '0 10px' }}>
<div className="map__rangesliderLabels">
<span>{priceRange[0]} </span>
<span>{priceRange[1]} </span>
</div>
<RangeSlider
min={0}
max={getMaxPrice()}
step={100}
value={priceRange}
onInput={(val) => {
setPriceRange(val);
}}
tipFormatter={(val) => `${val}`}
/>
</div>
</div>
</Space>
</div>
)}
{!homeAddress && (
<Banner
fullMode={true}
type="warning"
bordered
closeIcon={null}
style={{ marginBottom: '8px' }}
description={
<span>
You have not set your home address yet. Please do so in the <Link to="/userSettings">user settings</Link>{' '}
to use the distance filter.
No home address set. Configure it in <Link to="/userSettings">user settings</Link> to use the distance
filter.
</span>
}
/>
@@ -433,10 +376,97 @@ export default function MapView() {
type="info"
bordered
closeIcon={null}
description="Keep in mind, only listings with proper adresses are being shown on this map."
style={{ marginBottom: '8px' }}
description="Only listings with valid addresses are shown on this map."
/>
<Map mapContainerRef={mapContainer} style={style} show3dBuildings={show3dBuildings} onMapReady={handleMapReady} />
<div className="map-view-container__map-wrapper">
<Map
mapContainerRef={mapContainer}
style={style}
show3dBuildings={show3dBuildings}
onMapReady={handleMapReady}
/>
{/* Floating filter panel */}
<div className="map-view-container__floating-panel">
<div className="map-view-container__panel-row">
<Text size="small" strong style={{ color: '#8892a4' }}>
Job
</Text>
<Select
placeholder="All jobs"
showClear
size="small"
onChange={(val) => setJobId(val)}
value={jobId}
style={{ width: 160 }}
>
{jobs?.map((j) => (
<Select.Option key={j.id} value={j.id}>
{j.name}
</Select.Option>
))}
</Select>
</div>
<div className="map-view-container__panel-row">
<Text size="small" strong style={{ color: '#8892a4' }}>
Distance
</Text>
<Select
placeholder="None"
size="small"
onChange={(val) => setDistanceFilter(val)}
value={distanceFilter}
style={{ width: 100 }}
>
<Select.Option value={0}>None</Select.Option>
<Select.Option value={5}>5 km</Select.Option>
<Select.Option value={10}>10 km</Select.Option>
<Select.Option value={15}>15 km</Select.Option>
<Select.Option value={20}>20 km</Select.Option>
<Select.Option value={25}>25 km</Select.Option>
</Select>
</div>
<div className="map-view-container__panel-row">
<Text size="small" strong style={{ color: '#8892a4' }}>
Price ()
</Text>
<div className="map-view-container__price-slider">
<div className="map__rangesliderLabels">
<span>{priceRange[0]}</span>
<span>{priceRange[1]}</span>
</div>
<RangeSlider min={0} max={getMaxPrice()} step={100} value={priceRange} onInput={handlePriceRange} />
</div>
</div>
<div className="map-view-container__panel-row">
<Text size="small" strong style={{ color: '#8892a4' }}>
Style
</Text>
<Select size="small" value={style} onChange={(val) => handleMapStyle(val)} style={{ width: 110 }}>
<Select.Option value="STANDARD">Standard</Select.Option>
<Select.Option value="SATELLITE">Satellite</Select.Option>
</Select>
</div>
<div className="map-view-container__panel-row">
<Text size="small" strong style={{ color: '#8892a4' }}>
3D Buildings
</Text>
<Switch
size="small"
checked={show3dBuildings}
onChange={(v) => setShow3dBuildings(v)}
disabled={style === 'SATELLITE'}
/>
</div>
</div>
</div>
<ListingDeletionModal
visible={deleteModalVisible}
onConfirm={confirmListingDeletion}

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
@@ -9,18 +9,48 @@
height: 100%;
padding: 0;
box-sizing: border-box;
}
.map-filter-bar {
margin-bottom: 1rem;
&__map-wrapper {
position: relative;
flex: 1;
min-height: 0;
}
&__floating-panel {
position: absolute;
top: 12px;
right: 12px;
z-index: 10;
background: rgba(13, 15, 20, 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid #262a3a;
border-radius: 10px;
padding: 14px 16px;
min-width: 220px;
display: flex;
flex-direction: column;
gap: 12px;
}
&__panel-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
&__price-slider {
width: 140px;
}
}
.map-container {
flex-grow: 1;
width: 100%;
height: 100%;
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--semi-color-border);
border: 1px solid #262a3a;
}
.map-popup-content {
@@ -126,7 +156,7 @@
}
}
/* Override MapLibre default popup styles to match application theme */
/* Override MapLibre default popup styles */
.maplibregl-popup-content {
background-color: var(--semi-color-bg-1) !important;
color: var(--semi-color-text-0) !important;
@@ -140,21 +170,26 @@
}
.map {
&__rangesliderLabels{
color: white;
&__rangesliderLabels {
color: #94a3b8;
display: flex;
justify-content: space-between;
margin-bottom: .3rem;
font-size: .7rem;
}
}
.range-slider .range-slider__thumb {
position: absolute;
z-index: 3;
top: 50%;
width: 16px;
height: 16px;
width: 14px;
height: 14px;
transform: translate(-50%, -50%);
border-radius: 50%;
background: #2196f3;
}
background: #0ab5b3;
}
.range-slider .range-slider__range {
background: #0ab5b3;
}

View File

@@ -10,7 +10,7 @@ import Logo from '../../components/logo/Logo';
import { xhrPost } from '../../services/xhr';
import { useNavigate } from 'react-router-dom';
import { useActions, useSelector } from '../../services/state/store';
import { Input, Button, Banner, Toast } from '@douyinfe/semi-ui-19';
import { Input, Button, Banner } from '@douyinfe/semi-ui-19';
import './login.less';
import { IconUser, IconLock } from '@douyinfe/semi-icons';
@@ -45,12 +45,10 @@ export default function Login() {
});
/* eslint-disable no-unused-vars */
} catch (ignored) {
Toast.error('Login unsuccessful');
setError('Login unsuccessful. Please check your username and password.');
return;
}
Toast.success('Login successful!');
await actions.user.getCurrentUser();
navigate('/dashboard');
};

View File

@@ -1,124 +0,0 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { useEffect, useState, useMemo } from 'react';
import { Divider, Button, AutoComplete, Toast, Banner, Switch } from '@douyinfe/semi-ui-19';
import { IconSave, IconHome, IconSearch } from '@douyinfe/semi-icons';
import { useSelector, useActions, useIsLoading } from '../../services/state/store';
import { xhrGet } from '../../services/xhr';
import { SegmentPart } from '../../components/segment/SegmentPart';
import debounce from 'lodash/debounce';
const UserSettings = () => {
const actions = useActions();
const homeAddress = useSelector((state) => state.userSettings.settings.home_address);
const immoscoutDetails = useSelector((state) => state.userSettings.settings.immoscout_details);
const [address, setAddress] = useState(homeAddress?.address || '');
const [coords, setCoords] = useState(homeAddress?.coords || null);
const saving = useIsLoading(actions.userSettings.setHomeAddress);
const [dataSource, setDataSource] = useState([]);
useEffect(() => {
setAddress(homeAddress?.address || '');
setCoords(homeAddress?.coords || null);
}, [homeAddress]);
const handleSave = async () => {
try {
const responseJson = await actions.userSettings.setHomeAddress(address);
setCoords(responseJson.coords);
await actions.userSettings.getUserSettings();
Toast.success(
'Settings saved successfully. We will now start calculating distances for you. This may take a while and runs in the background.',
);
} catch (error) {
Toast.error(error.json?.error || 'Error while saving settings');
}
};
const debouncedSearch = useMemo(
() =>
debounce((value) => {
xhrGet(`/api/user/settings/autocomplete?q=${encodeURIComponent(value)}`)
.then((response) => {
if (response.status === 200) {
setDataSource(response.json);
}
})
.catch(() => {
// Silently fail for autocomplete
});
}, 300),
[],
);
const searchAddress = (value) => {
if (!value) {
setDataSource([]);
return;
}
debouncedSearch(value);
};
return (
<div className="user-settings">
<SegmentPart
name="Distance claculation"
Icon={IconHome}
helpText="The address you enter is used to calculate the distance between your chosen location and each listing. The distance is computed using an approximate mathematical method and is intended to give you a rough indication of commute time. If you update your address, we will recalculate the distance for all active listings."
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', maxWidth: '600px' }}>
<AutoComplete
data={dataSource}
value={address}
showClear
onChange={(v) => setAddress(v)}
onSearch={searchAddress}
placeholder="Enter your home address"
style={{ width: '100%' }}
/>
{coords && coords.lat === -1 && (
<Banner type="danger" description="Address found but could not be geocoded accurately." closeIcon={null} />
)}
</div>
</SegmentPart>
<Divider />
<SegmentPart
name="ImmoScout Details"
Icon={IconSearch}
helpText="When enabled, Fredy will fetch additional details (description, attributes, agent info) for each listing from ImmoScout. This provides richer notifications but makes an extra API call per listing."
>
<Banner
type="warning"
description="Enabling this feature significantly increases the number of API requests to ImmoScout. This raises the likelihood of being detected and rate-limited or blocked. Use at your own risk."
closeIcon={null}
style={{ marginBottom: '12px', maxWidth: '600px' }}
/>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<Switch
checked={!!immoscoutDetails}
onChange={async (checked) => {
try {
await actions.userSettings.setImmoscoutDetails(checked);
Toast.success('ImmoScout details setting updated.');
} catch {
Toast.error('Failed to update setting.');
}
}}
/>
<span>Fetch detailed ImmoScout listings</span>
</div>
</SegmentPart>
<Divider />
<div style={{ marginTop: '20px' }}>
<Button icon={<IconSave />} theme="solid" type="primary" onClick={handleSave} loading={saving}>
Save Settings
</Button>
</div>
</div>
);
};
export default UserSettings;

View File

@@ -12,6 +12,18 @@ export default defineConfig({
chunkSizeWarningLimit: 9999999,
outDir: './ui/public',
emptyOutDir: true,
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules/maplibre-gl')) {
return 'maplibre-gl';
}
},
},
},
},
optimizeDeps: {
include: ['maplibre-gl'],
},
plugins: [react()],
server: {

667
yarn.lock
View File

@@ -1039,6 +1039,14 @@
scroll-into-view-if-needed "^2.2.24"
utility-types "^3.10.0"
"@emnapi/core@1.9.1":
version "1.9.1"
resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.9.1.tgz#2143069c744ca2442074f8078462e51edd63c7bd"
integrity sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==
dependencies:
"@emnapi/wasi-threads" "1.2.0"
tslib "^2.4.0"
"@emnapi/core@^1.7.1":
version "1.9.0"
resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.9.0.tgz#4a54213b208fcf288cce25076c74e0f7613e6100"
@@ -1047,6 +1055,13 @@
"@emnapi/wasi-threads" "1.2.0"
tslib "^2.4.0"
"@emnapi/runtime@1.9.1":
version "1.9.1"
resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.9.1.tgz#115ff2a0d589865be6bd8e9d701e499c473f2a8d"
integrity sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==
dependencies:
tslib "^2.4.0"
"@emnapi/runtime@^1.7.1":
version "1.9.0"
resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.9.0.tgz#91c54a6e77c36154c125e873409472e2b70efd5b"
@@ -1073,33 +1088,26 @@
resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz#bccdf615bcf7b6e8db830ec0b8d21c9a25de597b"
integrity sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==
"@eslint/config-array@^0.23.3":
version "0.23.3"
resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.23.3.tgz#3f4a93dd546169c09130cbd10f2415b13a20a219"
integrity sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==
"@eslint/config-array@^0.23.4":
version "0.23.4"
resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.23.4.tgz#b4e160e668694011b355bfe9a89eb61a0eb641c4"
integrity sha512-lf19F24LSMfF8weXvW5QEtnLqW70u7kgit5e9PSx0MsHAFclGd1T9ynvWEMDT1w5J4Qt54tomGeAhdoAku1Xow==
dependencies:
"@eslint/object-schema" "^3.0.3"
"@eslint/object-schema" "^3.0.4"
debug "^4.3.1"
minimatch "^10.2.4"
"@eslint/config-helpers@^0.5.2":
version "0.5.2"
resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.5.2.tgz#314c7b03d02a371ad8c0a7f6821d5a8a8437ba9d"
integrity sha512-a5MxrdDXEvqnIq+LisyCX6tQMPF/dSJpCfBgBauY+pNZ28yCtSsTvyTYrMhaI+LK26bVyCJfJkT0u8KIj2i1dQ==
"@eslint/config-helpers@^0.5.4":
version "0.5.4"
resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.5.4.tgz#0b16c091dd16766f27e41f09bd264e3585a45652"
integrity sha512-jJhqiY3wPMlWWO3370M86CPJ7pt8GmEwSLglMfQhjXal07RCvhmU0as4IuUEW5SJeunfItiEetHmSxCCe9lDBg==
dependencies:
"@eslint/core" "^1.1.0"
"@eslint/core" "^1.2.0"
"@eslint/core@^1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@eslint/core/-/core-1.1.0.tgz#51f5cd970e216fbdae6721ac84491f57f965836d"
integrity sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw==
dependencies:
"@types/json-schema" "^7.0.15"
"@eslint/core@^1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@eslint/core/-/core-1.1.1.tgz#450f3d2be2d463ccd51119544092256b4e88df32"
integrity sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==
"@eslint/core@^1.2.0":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@eslint/core/-/core-1.2.0.tgz#3f741da0119058ad2a567a6f215677b5557a19e9"
integrity sha512-8FTGbNzTvmSlc4cZBaShkC6YvFMG0riksYWRFKXztqVdXaQbcZLXlFbSpC05s70sGEsXAw0qwhx69JiW7hQS7A==
dependencies:
"@types/json-schema" "^7.0.15"
@@ -1108,17 +1116,17 @@
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-10.0.1.tgz#1e8a876f50117af8ab67e47d5ad94d38d6622583"
integrity sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==
"@eslint/object-schema@^3.0.3":
version "3.0.3"
resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-3.0.3.tgz#5bf671e52e382e4adc47a9906f2699374637db6b"
integrity sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==
"@eslint/object-schema@^3.0.4":
version "3.0.4"
resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-3.0.4.tgz#8ce3aff08f6ca7c3bd9e1cec34530fc7fb44546a"
integrity sha512-55lO/7+Yp0ISKRP0PsPtNTeNGapXaO085aELZmWCVc5SH3jfrqpuU6YgOdIxMS99ZHkQN1cXKE+cdIqwww9ptw==
"@eslint/plugin-kit@^0.6.1":
version "0.6.1"
resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz#eb9e6689b56ce8bc1855bb33090e63f3fc115e8e"
integrity sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==
"@eslint/plugin-kit@^0.7.0":
version "0.7.0"
resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.7.0.tgz#7442f663da4d61091d2af0b30c8a6b50949fb26d"
integrity sha512-ejvBr8MQCbVsWNZnCwDXjUKq40MDmHalq7cJ6e9s/qzTUFIIo/afzt1Vui9T97FM/V/pN4YsFVoed5NIa96RDg==
dependencies:
"@eslint/core" "^1.1.1"
"@eslint/core" "^1.2.0"
levn "^0.4.1"
"@floating-ui/core@^1.7.3":
@@ -1276,17 +1284,17 @@
resolved "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-5.0.4.tgz"
integrity sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==
"@maplibre/geojson-vt@^6.0.2":
version "6.0.3"
resolved "https://registry.yarnpkg.com/@maplibre/geojson-vt/-/geojson-vt-6.0.3.tgz#1167caa068468b39989e8be272e3200831de7bab"
integrity sha512-tJ8df2SAIacER7pWTiSlDjIULBBAfZnzAURvWb1d8kVzx/pmSJcG0L2p0DTAB6nEu8Lmsx5zAc8JFDcs2DTwaw==
"@maplibre/geojson-vt@^6.0.4":
version "6.0.4"
resolved "https://registry.yarnpkg.com/@maplibre/geojson-vt/-/geojson-vt-6.0.4.tgz#f028fa633594c067b4c24030c1c282c0dd6cc835"
integrity sha512-HYv3POhMRCdhP3UPPATM/hfcy6/WuVIf5FKboH8u/ZuFMTnAIcSVlq5nfOqroLokd925w2QtE7YwquFOIacwVQ==
dependencies:
kdbush "^4.0.2"
"@maplibre/maplibre-gl-style-spec@^24.7.0":
version "24.7.0"
resolved "https://registry.yarnpkg.com/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.7.0.tgz#46e1109303393d15545eb97eb333991c5663f75d"
integrity sha512-Ed7rcKYU5iELfablg9Mj+TVCsXsPBgdMyXPRAxb2v7oWg9YJnpQdZ5msDs1LESu/mtXy3Z48Vdppv2t/x5kAhw==
"@maplibre/maplibre-gl-style-spec@^24.8.1":
version "24.8.1"
resolved "https://registry.yarnpkg.com/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.8.1.tgz#8d4d48591750529ce586f9ee5f836ed97870bce5"
integrity sha512-zxa92qF96ZNojLxeAjnaRpjVCy+swoUNJvDhtpC90k7u5F0TMr4GmvNqMKvYrMoPB8d7gRSXbMG1hBbmgESIsw==
dependencies:
"@mapbox/jsonlint-lines-primitives" "~2.0.2"
"@mapbox/unitbezier" "^0.0.1"
@@ -1296,10 +1304,10 @@
rw "^1.3.3"
tinyqueue "^3.0.0"
"@maplibre/mlt@^1.1.7":
version "1.1.7"
resolved "https://registry.yarnpkg.com/@maplibre/mlt/-/mlt-1.1.7.tgz#cb8d6ede486f5e48a33dd1f373fa5d908ce8062f"
integrity sha512-HZSsXrgn2V6T3o0qklMwKERfKaAxjO8shmiFnVygCtXTg4SPKWVX+U99RkvxUfCsjYBEcT4ltor8lSlBSCca7Q==
"@maplibre/mlt@^1.1.8":
version "1.1.8"
resolved "https://registry.yarnpkg.com/@maplibre/mlt/-/mlt-1.1.8.tgz#ad1f7169197e5c64eace4f61c168dcd202076e03"
integrity sha512-8vtfYGidr1rNkv5IwIoU2lfe3Oy+Wa8HluzQYcQi9cveU9K3pweAal/poQj4GJ0K/EW4bTQp2wVAs09g2yDRZg==
dependencies:
"@mapbox/point-geometry" "^1.1.0"
@@ -1347,10 +1355,10 @@
unist-util-visit "^5.0.0"
vfile "^6.0.0"
"@modelcontextprotocol/sdk@^1.27.1":
version "1.27.1"
resolved "https://registry.yarnpkg.com/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz#a602cf823bf8a68e13e7112f50aeb02b09fb83b9"
integrity sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==
"@modelcontextprotocol/sdk@^1.29.0":
version "1.29.0"
resolved "https://registry.yarnpkg.com/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz#79786d8b525e269de850ac82b1f1f757f3915f44"
integrity sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==
dependencies:
"@hono/node-server" "^1.19.9"
ajv "^8.17.1"
@@ -1379,6 +1387,13 @@
"@emnapi/runtime" "^1.7.1"
"@tybys/wasm-util" "^0.10.1"
"@napi-rs/wasm-runtime@^1.1.2":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz#e25454b4d44cfabd21d1bc801705359870e33ecc"
integrity sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==
dependencies:
"@tybys/wasm-util" "^0.10.1"
"@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1":
version "5.1.1-v1"
resolved "https://registry.yarnpkg.com/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz#dbf733a965ca47b1973177dc0bb6c889edcfb129"
@@ -1386,15 +1401,15 @@
dependencies:
eslint-scope "5.1.1"
"@oxc-project/runtime@0.115.0":
version "0.115.0"
resolved "https://registry.yarnpkg.com/@oxc-project/runtime/-/runtime-0.115.0.tgz#5e8350088964e1d8e0c73cfccfc1d71ca2e2f4a2"
integrity sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ==
"@oxc-project/types@=0.122.0":
version "0.122.0"
resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.122.0.tgz#2f4e77a3b183c87b2a326affd703ef71ba836601"
integrity sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==
"@oxc-project/types@=0.115.0":
version "0.115.0"
resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.115.0.tgz#92a599543529bce45f8f2da77f40a124d63349dc"
integrity sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==
"@oxc-project/types@=0.123.0":
version "0.123.0"
resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.123.0.tgz#a0bbc8f0cec16270df203cbad290bde3ed0289ad"
integrity sha512-YtECP/y8Mj1lSHiUWGSRzy/C6teUKlS87dEfuVKT09LgQbUsBW1rNg+MiJ4buGu3yuADV60gbIvo9/HplA56Ew==
"@puppeteer/browsers@2.13.0":
version "2.13.0"
@@ -1414,93 +1429,177 @@
resolved "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz"
integrity sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==
"@rolldown/binding-android-arm64@1.0.0-rc.9":
version "1.0.0-rc.9"
resolved "https://registry.yarnpkg.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz#4bbd28868564948c2bf04b3ca117a6828f95626c"
integrity sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==
"@rolldown/binding-android-arm64@1.0.0-rc.12":
version "1.0.0-rc.12"
resolved "https://registry.yarnpkg.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz#4e6af08b89da02596cc5da4b105082b68673ffec"
integrity sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==
"@rolldown/binding-darwin-arm64@1.0.0-rc.9":
version "1.0.0-rc.9"
resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.9.tgz#80864a6997404f264cc7a216cad221fe6148705d"
integrity sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ==
"@rolldown/binding-android-arm64@1.0.0-rc.13":
version "1.0.0-rc.13"
resolved "https://registry.yarnpkg.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.13.tgz#0862753ca7cad78350240cd68fc79150606b42c4"
integrity sha512-5ZiiecKH2DXAVJTNN13gNMUcCDg4Jy8ZjbXEsPnqa248wgOVeYRX0iqXXD5Jz4bI9BFHgKsI2qmyJynstbmr+g==
"@rolldown/binding-darwin-x64@1.0.0-rc.9":
version "1.0.0-rc.9"
resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.9.tgz#747b698878b6f44d817f87e9e3cb197b16076d2a"
integrity sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg==
"@rolldown/binding-darwin-arm64@1.0.0-rc.12":
version "1.0.0-rc.12"
resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz#a06890f4c9b48ff0fc97edbedfc762bef7cffd73"
integrity sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==
"@rolldown/binding-freebsd-x64@1.0.0-rc.9":
version "1.0.0-rc.9"
resolved "https://registry.yarnpkg.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.9.tgz#35c29d7c83aa75429c74d7d1ee9c7d3e61f4552c"
integrity sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q==
"@rolldown/binding-darwin-arm64@1.0.0-rc.13":
version "1.0.0-rc.13"
resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.13.tgz#01879544538cdfdc35fd6086f21563d29a193ec1"
integrity sha512-tz/v/8G77seu8zAB3A5sK3UFoOl06zcshEzhUO62sAEtrEuW/H1CcyoupOrD+NbQJytYgA4CppXPzlrmp4JZKA==
"@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.9":
version "1.0.0-rc.9"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.9.tgz#36d2bcbcf07f17f18fb2df727a62f16e5295c816"
integrity sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ==
"@rolldown/binding-darwin-x64@1.0.0-rc.12":
version "1.0.0-rc.12"
resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz#eddf6aa3ed3509171fe21711f1e8ec8e0fd7ec49"
integrity sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==
"@rolldown/binding-linux-arm64-gnu@1.0.0-rc.9":
version "1.0.0-rc.9"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.9.tgz#5b03c11f2b661a275f2d7628e4f456783e1b9f63"
integrity sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg==
"@rolldown/binding-darwin-x64@1.0.0-rc.13":
version "1.0.0-rc.13"
resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.13.tgz#da0c47323964b17dfe7997e4e9770837942346b1"
integrity sha512-8DakphqOz8JrMYWTJmWA+vDJxut6LijZ8Xcdc4flOlAhU7PNVwo2MaWBF9iXjJAPo5rC/IxEFZDhJ3GC7NHvug==
"@rolldown/binding-linux-arm64-musl@1.0.0-rc.9":
version "1.0.0-rc.9"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.9.tgz#d3cbd1b1760d34b5789af89f4bcc09a1446d3eb5"
integrity sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==
"@rolldown/binding-freebsd-x64@1.0.0-rc.12":
version "1.0.0-rc.12"
resolved "https://registry.yarnpkg.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz#2102dfed19fd1f1b53435fcaaf0bc61129a266a3"
integrity sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==
"@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.9":
version "1.0.0-rc.9"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.9.tgz#8e971e7f066b2c0876e20c9f6174d645f31efb84"
integrity sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==
"@rolldown/binding-freebsd-x64@1.0.0-rc.13":
version "1.0.0-rc.13"
resolved "https://registry.yarnpkg.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.13.tgz#436161bf753b50ecd001dfc0e94fe10794c4c3c6"
integrity sha512-4wBQFfjDuXYN/SVI8inBF3Aa+isq40rc6VMFbk5jcpolUBTe5cYnMsHZ51nFWsx3PVyyNN3vgoESki0Hmr/4BA==
"@rolldown/binding-linux-s390x-gnu@1.0.0-rc.9":
version "1.0.0-rc.9"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.9.tgz#e7283523780741f07a4441c7c8af5b2550faadf2"
integrity sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==
"@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12":
version "1.0.0-rc.12"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz#b2c13f40e990fd1e1935492850536c768c961a0f"
integrity sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==
"@rolldown/binding-linux-x64-gnu@1.0.0-rc.9":
version "1.0.0-rc.9"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.9.tgz#da2302e079bb5f3a98edf75608621e94f1fb550e"
integrity sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==
"@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.13":
version "1.0.0-rc.13"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.13.tgz#9962f225411bb6bde8efdb330c33cd3d5ae79a8c"
integrity sha512-JW/e4yPIXLms+jmnbwwy5LA/LxVwZUWLN8xug+V200wzaVi5TEGIWQlh8o91gWYFxW609euI98OCCemmWGuPrw==
"@rolldown/binding-linux-x64-musl@1.0.0-rc.9":
version "1.0.0-rc.9"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.9.tgz#3f27a620d56b93644fd1b6fad58fc2dbe93d5d71"
integrity sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==
"@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12":
version "1.0.0-rc.12"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz#32ca9f77c1e76b2913b3d53d2029dc171c0532d6"
integrity sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==
"@rolldown/binding-openharmony-arm64@1.0.0-rc.9":
version "1.0.0-rc.9"
resolved "https://registry.yarnpkg.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.9.tgz#9c307777157d029aaf8db1a09221b9275dbe5547"
integrity sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==
"@rolldown/binding-linux-arm64-gnu@1.0.0-rc.13":
version "1.0.0-rc.13"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.13.tgz#2c707fdc988d225c60704e7ec23ea57258609689"
integrity sha512-ZfKWpXiUymDnavepCaM6KG/uGydJ4l2nBmMxg60Ci4CbeefpqjPWpfaZM7PThOhk2dssqBAcwLc6rAyr0uTdXg==
"@rolldown/binding-wasm32-wasi@1.0.0-rc.9":
version "1.0.0-rc.9"
resolved "https://registry.yarnpkg.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.9.tgz#c3a82bef0ddd644efa74c050c26223f29f55039c"
integrity sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g==
"@rolldown/binding-linux-arm64-musl@1.0.0-rc.12":
version "1.0.0-rc.12"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz#f4337ddd52f0ed3ada2105b59ee1b757a2c4858c"
integrity sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==
"@rolldown/binding-linux-arm64-musl@1.0.0-rc.13":
version "1.0.0-rc.13"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.13.tgz#cd96d50ed1556dff541b4dd3845038ad0cf066cd"
integrity sha512-bmRg3O6Z0gq9yodKKWCIpnlH051sEfdVwt+6m5UDffAQMUUqU0xjnQqqAUm+Gu7ofAAly9DqiQDtKu2nPDEABA==
"@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12":
version "1.0.0-rc.12"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz#22fdd14cb00ee8208c28a39bab7f28860ec6705d"
integrity sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==
"@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.13":
version "1.0.0-rc.13"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.13.tgz#984ddcdb40345ece2dce0e667350bffe20700e23"
integrity sha512-8Wtnbw4k7pMYN9B/mOEAsQ8HOiq7AZ31Ig4M9BKn2So4xRaFEhtCSa4ZJaOutOWq50zpgR4N5+L/opnlaCx8wQ==
"@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12":
version "1.0.0-rc.12"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz#838215096d1de6d3d509e0410801cb7cda8161ff"
integrity sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==
"@rolldown/binding-linux-s390x-gnu@1.0.0-rc.13":
version "1.0.0-rc.13"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.13.tgz#76c75572fd3ef01cd0c2d68943e007fc8af6da2d"
integrity sha512-D/0Nlo8mQuxSMohNJUF2lDXWRsFDsHldfRRgD9bRgktj+EndGPj4DOV37LqDKPYS+osdyhZEH7fTakTAEcW7qg==
"@rolldown/binding-linux-x64-gnu@1.0.0-rc.12":
version "1.0.0-rc.12"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz#f7d71d97f6bd43198596b26dc2cb364586e12673"
integrity sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==
"@rolldown/binding-linux-x64-gnu@1.0.0-rc.13":
version "1.0.0-rc.13"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.13.tgz#2e244b9c5bb98054a5d0d8c2df297ffaa86cae07"
integrity sha512-eRrPvat2YaVQcwwKi/JzOP6MKf1WRnOCr+VaI3cTWz3ZoLcP/654z90lVCJ4dAuMEpPdke0n+qyAqXDZdIC4rA==
"@rolldown/binding-linux-x64-musl@1.0.0-rc.12":
version "1.0.0-rc.12"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz#a2ca737f01b0ad620c4c404ca176ea3e3ad804c3"
integrity sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==
"@rolldown/binding-linux-x64-musl@1.0.0-rc.13":
version "1.0.0-rc.13"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.13.tgz#a9cd96eb02f83167e33772f0a8dafd3ff67d4283"
integrity sha512-PsdONiFRp8hR8KgVjTWjZ9s7uA3uueWL0t74/cKHfM4dR5zXYv4AjB8BvA+QDToqxAFg4ZkcVEqeu5F7inoz5w==
"@rolldown/binding-openharmony-arm64@1.0.0-rc.12":
version "1.0.0-rc.12"
resolved "https://registry.yarnpkg.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz#f66317e29eafcc300bed7af8dddac26ab3b1bf82"
integrity sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==
"@rolldown/binding-openharmony-arm64@1.0.0-rc.13":
version "1.0.0-rc.13"
resolved "https://registry.yarnpkg.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.13.tgz#54a471d5620c2d7a84ab989a30a976b2133c5650"
integrity sha512-hCNXgC5dI3TVOLrPT++PKFNZ+1EtS0mLQwfXXXSUD/+rGlB65gZDwN/IDuxLpQP4x8RYYHqGomlUXzpO8aVI2w==
"@rolldown/binding-wasm32-wasi@1.0.0-rc.12":
version "1.0.0-rc.12"
resolved "https://registry.yarnpkg.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz#8825523fdffa1f1dc4683be9650ffaa9e4a77f04"
integrity sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==
dependencies:
"@napi-rs/wasm-runtime" "^1.1.1"
"@rolldown/binding-win32-arm64-msvc@1.0.0-rc.9":
version "1.0.0-rc.9"
resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.9.tgz#27e23cbd53b7095d0b66191ef999327b4684a6cf"
integrity sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA==
"@rolldown/binding-wasm32-wasi@1.0.0-rc.13":
version "1.0.0-rc.13"
resolved "https://registry.yarnpkg.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.13.tgz#7396c7f5904607e074fe7559a95bea656b5c42e7"
integrity sha512-viLS5C5et8NFtLWw9Sw3M/w4vvnVkbWkO7wSNh3C+7G1+uCkGpr6PcjNDSFcNtmXY/4trjPBqUfcOL+P3sWy/g==
dependencies:
"@emnapi/core" "1.9.1"
"@emnapi/runtime" "1.9.1"
"@napi-rs/wasm-runtime" "^1.1.2"
"@rolldown/binding-win32-x64-msvc@1.0.0-rc.9":
version "1.0.0-rc.9"
resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.9.tgz#96046309142b398c9c2a9a0a052e7355535e69c8"
integrity sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ==
"@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12":
version "1.0.0-rc.12"
resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz#4f3a17e3d68a58309c27c0930b0f7986ccabef47"
integrity sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==
"@rolldown/binding-win32-arm64-msvc@1.0.0-rc.13":
version "1.0.0-rc.13"
resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.13.tgz#7d4287f66c34cf050f4358a4941c146c4d798e24"
integrity sha512-Fqa3Tlt1xL4wzmAYxGNFV36Hb+VfPc9PYU+E25DAnswXv3ODDu/yyWjQDbXMo5AGWkQVjLgQExuVu8I/UaZhPQ==
"@rolldown/binding-win32-x64-msvc@1.0.0-rc.12":
version "1.0.0-rc.12"
resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz#d762765d5660598a96b570b513f535c151272985"
integrity sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==
"@rolldown/binding-win32-x64-msvc@1.0.0-rc.13":
version "1.0.0-rc.13"
resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.13.tgz#e5e0d00e4494e7dc51527ba0644ab5a9049e6925"
integrity sha512-/pLI5kPkGEi44TDlnbio3St/5gUFeN51YWNAk/Gnv6mEQBOahRBh52qVFVBpmrnU01n2yysvBML9Ynu7K4kGAQ==
"@rolldown/pluginutils@1.0.0-rc.12":
version "1.0.0-rc.12"
resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz#74163aec62fa51cee18d62709483963dceb3f6dc"
integrity sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==
"@rolldown/pluginutils@1.0.0-rc.13":
version "1.0.0-rc.13"
resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz#093a01af0cde13552f058544fcadf12e9b522c3b"
integrity sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==
"@rolldown/pluginutils@1.0.0-rc.7":
version "1.0.0-rc.7"
resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz#0414869467f0e471a6515d4f506c85fde867e022"
integrity sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==
"@rolldown/pluginutils@1.0.0-rc.9":
version "1.0.0-rc.9"
resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.9.tgz#ddb28c13602aea5a5edf03532c28bbfc37c4b5e0"
integrity sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==
"@sendgrid/client@^8.1.5":
version "8.1.5"
resolved "https://registry.npmjs.org/@sendgrid/client/-/client-8.1.5.tgz"
@@ -2009,65 +2108,65 @@
dependencies:
"@rolldown/pluginutils" "1.0.0-rc.7"
"@vitest/expect@4.1.0":
version "4.1.0"
resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-4.1.0.tgz#2f6c7d19cfbe778bfb42d73f77663ec22163fcbb"
integrity sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==
"@vitest/expect@4.1.3":
version "4.1.3"
resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-4.1.3.tgz#2c631d9add8e6696443243ac1a487c6ccdc2d1cc"
integrity sha512-CW8Q9KMtXDGHj0vCsqui0M5KqRsu0zm0GNDW7Gd3U7nZ2RFpPKSCpeCXoT+/+5zr1TNlsoQRDEz+LzZUyq6gnQ==
dependencies:
"@standard-schema/spec" "^1.1.0"
"@types/chai" "^5.2.2"
"@vitest/spy" "4.1.0"
"@vitest/utils" "4.1.0"
"@vitest/spy" "4.1.3"
"@vitest/utils" "4.1.3"
chai "^6.2.2"
tinyrainbow "^3.0.3"
tinyrainbow "^3.1.0"
"@vitest/mocker@4.1.0":
version "4.1.0"
resolved "https://registry.yarnpkg.com/@vitest/mocker/-/mocker-4.1.0.tgz#2aabf6079ad472f89a212d322f7d5da7ad628a0e"
integrity sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==
"@vitest/mocker@4.1.3":
version "4.1.3"
resolved "https://registry.yarnpkg.com/@vitest/mocker/-/mocker-4.1.3.tgz#78ec418d7970c2039ff8bc9f333c126c58d0c7fe"
integrity sha512-XN3TrycitDQSzGRnec/YWgoofkYRhouyVQj4YNsJ5r/STCUFqMrP4+oxEv3e7ZbLi4og5kIHrZwekDJgw6hcjw==
dependencies:
"@vitest/spy" "4.1.0"
"@vitest/spy" "4.1.3"
estree-walker "^3.0.3"
magic-string "^0.30.21"
"@vitest/pretty-format@4.1.0":
version "4.1.0"
resolved "https://registry.yarnpkg.com/@vitest/pretty-format/-/pretty-format-4.1.0.tgz#b6ccf2868130a647d24af3696d58c09a95eb83c1"
integrity sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==
"@vitest/pretty-format@4.1.3":
version "4.1.3"
resolved "https://registry.yarnpkg.com/@vitest/pretty-format/-/pretty-format-4.1.3.tgz#779626282923040244f7a38584550549c0b19f52"
integrity sha512-hYqqwuMbpkkBodpRh4k4cQSOELxXky1NfMmQvOfKvV8zQHz8x8Dla+2wzElkMkBvSAJX5TRGHJAQvK0TcOafwg==
dependencies:
tinyrainbow "^3.0.3"
tinyrainbow "^3.1.0"
"@vitest/runner@4.1.0":
version "4.1.0"
resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-4.1.0.tgz#4e12c0f086eb3a4ae3fae84d9d68b22d02942cbf"
integrity sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==
"@vitest/runner@4.1.3":
version "4.1.3"
resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-4.1.3.tgz#e083b6de9f4251d7e1440522981c88fc015342a3"
integrity sha512-VwgOz5MmT0KhlUj40h02LWDpUBVpflZ/b7xZFA25F29AJzIrE+SMuwzFf0b7t4EXdwRNX61C3B6auIXQTR3ttA==
dependencies:
"@vitest/utils" "4.1.0"
"@vitest/utils" "4.1.3"
pathe "^2.0.3"
"@vitest/snapshot@4.1.0":
version "4.1.0"
resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-4.1.0.tgz#67372979da692ccf5dfa4a3bb603f683c0640202"
integrity sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==
"@vitest/snapshot@4.1.3":
version "4.1.3"
resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-4.1.3.tgz#0c7090aaa2e5b443ede3e7cb1b8381d83dc8da82"
integrity sha512-9l+k/J9KG5wPJDX9BcFFzhhwNjwkRb8RsnYhaT1vPY7OufxmQFc9sZzScRCPTiETzl37mrIWVY9zxzmdVeJwDQ==
dependencies:
"@vitest/pretty-format" "4.1.0"
"@vitest/utils" "4.1.0"
"@vitest/pretty-format" "4.1.3"
"@vitest/utils" "4.1.3"
magic-string "^0.30.21"
pathe "^2.0.3"
"@vitest/spy@4.1.0":
version "4.1.0"
resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-4.1.0.tgz#b9143a63cca83de34ac1777c733f8561b73fa9ba"
integrity sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==
"@vitest/spy@4.1.3":
version "4.1.3"
resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-4.1.3.tgz#f1537c5be2a1682ff47b3a1fea09ad73539fab53"
integrity sha512-ujj5Uwxagg4XUIfAUyRQxAg631BP6e9joRiN99mr48Bg9fRs+5mdUElhOoZ6rP5mBr8Bs3lmrREnkrQWkrsTCw==
"@vitest/utils@4.1.0":
version "4.1.0"
resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-4.1.0.tgz#2baf26a2a28c4aabe336315dc59722df2372c38d"
integrity sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==
"@vitest/utils@4.1.3":
version "4.1.3"
resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-4.1.3.tgz#f0ef911ce7a41ccb84229d51f2a6ccd148141ddf"
integrity sha512-Pc/Oexse/khOWsGB+w3q4yzA4te7W4gpZZAvk+fr8qXfTURZUMj5i7kuxsNK5mP/dEB6ao3jfr0rs17fHhbHdw==
dependencies:
"@vitest/pretty-format" "4.1.0"
"@vitest/pretty-format" "4.1.3"
convert-source-map "^2.0.0"
tinyrainbow "^3.0.3"
tinyrainbow "^3.1.0"
accepts@^2.0.0:
version "2.0.0"
@@ -2092,10 +2191,10 @@ acorn@^8.16.0:
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.16.0.tgz#4ce79c89be40afe7afe8f3adb902a1f1ce9ac08a"
integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==
adm-zip@^0.5.16:
version "0.5.16"
resolved "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz"
integrity sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==
adm-zip@^0.5.17:
version "0.5.17"
resolved "https://registry.yarnpkg.com/adm-zip/-/adm-zip-0.5.17.tgz#5c0b65f37aeec5c2a94995c024f931f62e4bbc5a"
integrity sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==
agent-base@^7.1.0, agent-base@^7.1.2:
version "7.1.4"
@@ -3415,17 +3514,17 @@ eslint-visitor-keys@^5.0.1:
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz#9e3c9489697824d2d4ce3a8ad12628f91e9f59be"
integrity sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==
eslint@10.0.3:
version "10.0.3"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.0.3.tgz#360a7de7f2706eb8a32caa17ca983f0089efe694"
integrity sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==
eslint@10.2.0:
version "10.2.0"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.2.0.tgz#711c80d32fc3fdd3a575bb93977df43887c3ec8e"
integrity sha512-+L0vBFYGIpSNIt/KWTpFonPrqYvgKw1eUI5Vn7mEogrQcWtWYtNQ7dNqC+px/J0idT3BAkiWrhfS7k+Tum8TUA==
dependencies:
"@eslint-community/eslint-utils" "^4.8.0"
"@eslint-community/regexpp" "^4.12.2"
"@eslint/config-array" "^0.23.3"
"@eslint/config-helpers" "^0.5.2"
"@eslint/core" "^1.1.1"
"@eslint/plugin-kit" "^0.6.1"
"@eslint/config-array" "^0.23.4"
"@eslint/config-helpers" "^0.5.4"
"@eslint/core" "^1.2.0"
"@eslint/plugin-kit" "^0.7.0"
"@humanfs/node" "^0.16.6"
"@humanwhocodes/module-importer" "^1.0.1"
"@humanwhocodes/retry" "^0.4.2"
@@ -3436,7 +3535,7 @@ eslint@10.0.3:
escape-string-regexp "^4.0.0"
eslint-scope "^9.1.2"
eslint-visitor-keys "^5.0.1"
espree "^11.1.1"
espree "^11.2.0"
esquery "^1.7.0"
esutils "^2.0.2"
fast-deep-equal "^3.1.3"
@@ -3451,7 +3550,7 @@ eslint@10.0.3:
natural-compare "^1.4.0"
optionator "^0.9.3"
espree@^11.1.1:
espree@^11.2.0:
version "11.2.0"
resolved "https://registry.yarnpkg.com/espree/-/espree-11.2.0.tgz#01d5e47dc332aaba3059008362454a8cc34ccaa5"
integrity sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==
@@ -3997,10 +4096,10 @@ graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0:
resolved "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz"
integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==
handlebars@4.7.8:
version "4.7.8"
resolved "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz"
integrity sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==
handlebars@4.7.9:
version "4.7.9"
resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.9.tgz#6f139082ab58dc4e5a0e51efe7db5ae890d56a0f"
integrity sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==
dependencies:
minimist "^1.2.5"
neo-async "^2.6.2"
@@ -4836,7 +4935,7 @@ lodash.debounce@^4.0.8:
resolved "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz"
integrity sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==
lodash@4.17.23, lodash@^4.17.21:
lodash@^4.17.21:
version "4.17.23"
resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz"
integrity sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==
@@ -4901,10 +5000,10 @@ make-dir@^2.1.0:
pify "^4.0.1"
semver "^5.6.0"
maplibre-gl@^5.20.1:
version "5.20.1"
resolved "https://registry.yarnpkg.com/maplibre-gl/-/maplibre-gl-5.20.1.tgz#6903c4085fd383bf2167edcb2b7fcc5f6dfbf4b1"
integrity sha512-57YIgfRct+rrk78ldoWRuLWRnXV/1vM2Rk0QYfEDQmsXdpgbACwvGoREIOZtyDIaq/GJK/ORYEriaAdVZuNfvw==
maplibre-gl@^5.22.0:
version "5.22.0"
resolved "https://registry.yarnpkg.com/maplibre-gl/-/maplibre-gl-5.22.0.tgz#b61a7f3add4e8e85077a5b585009dc7868b1c6fe"
integrity sha512-nc8YA+YSEioMZg5W0cb6Cf3wQ8aJge66dsttyBgpOArOnlmFJO1Kc5G32kYVPeUYhLpBja83T99uanmJvYAIyQ==
dependencies:
"@mapbox/jsonlint-lines-primitives" "^2.0.2"
"@mapbox/point-geometry" "^1.1.0"
@@ -4912,9 +5011,9 @@ maplibre-gl@^5.20.1:
"@mapbox/unitbezier" "^0.0.1"
"@mapbox/vector-tile" "^2.0.4"
"@mapbox/whoots-js" "^3.1.0"
"@maplibre/geojson-vt" "^6.0.2"
"@maplibre/maplibre-gl-style-spec" "^24.7.0"
"@maplibre/mlt" "^1.1.7"
"@maplibre/geojson-vt" "^6.0.4"
"@maplibre/maplibre-gl-style-spec" "^24.8.1"
"@maplibre/mlt" "^1.1.8"
"@maplibre/vt-pbf" "^4.3.0"
"@types/geojson" "^7946.0.16"
earcut "^3.0.2"
@@ -5721,6 +5820,11 @@ node-releases@^2.0.27:
resolved "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz"
integrity sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==
nodemailer@^8.0.5:
version "8.0.5"
resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-8.0.5.tgz#2076fb2b5c1ccfe1c88f6e1aa47c0229ea642e0c"
integrity sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==
nodemon@^3.1.14:
version "3.1.14"
resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-3.1.14.tgz#8487ca379c515301d221ec007f27f24ecafa2b51"
@@ -6030,6 +6134,11 @@ picomatch@^4.0.3:
resolved "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz"
integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==
picomatch@^4.0.4:
version "4.0.4"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589"
integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==
pify@^4.0.1:
version "4.0.1"
resolved "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz"
@@ -6052,10 +6161,10 @@ possible-typed-array-names@^1.0.0:
resolved "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz"
integrity sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==
postal-mime@2.7.3:
version "2.7.3"
resolved "https://registry.yarnpkg.com/postal-mime/-/postal-mime-2.7.3.tgz#358d92192656a262568ffc7a441a713131aa1272"
integrity sha512-MjhXadAJaWgYzevi46+3kLak8y6gbg0ku14O1gO/LNOuay8dO+1PtcSGvAdgDR0DoIsSaiIA8y/Ddw6MnrO0Tw==
postal-mime@2.7.4:
version "2.7.4"
resolved "https://registry.yarnpkg.com/postal-mime/-/postal-mime-2.7.4.tgz#3718d1f188357ed86f906f1db8d4ca455efa4927"
integrity sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==
postcss@^8.5.8:
version "8.5.8"
@@ -6337,10 +6446,10 @@ punycode@^2.1.0:
resolved "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz"
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
puppeteer-core@24.39.1:
version "24.39.1"
resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-24.39.1.tgz#7d155cc4b38b32ccb29b862e52b1f87e39704d6f"
integrity sha512-AMqQIKoEhPS6CilDzw0Gd1brLri3emkC+1N2J6ZCCuY1Cglo56M63S0jOeBZDQlemOiRd686MYVMl9ELJBzN3A==
puppeteer-core@24.40.0:
version "24.40.0"
resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-24.40.0.tgz#1f389cd9432cb077f703ca2cb6758490cdccbc7e"
integrity sha512-MWL3XbUCfVgGR0gRsidzT6oKJT2QydPLhMITU6HoVWiiv4gkb6gJi3pcdAa8q4HwjBTbqISOWVP4aJiiyUJvag==
dependencies:
"@puppeteer/browsers" "2.13.0"
chromium-bidi "14.0.0"
@@ -6397,16 +6506,16 @@ puppeteer-extra@^3.3.6:
debug "^4.1.1"
deepmerge "^4.2.2"
puppeteer@^24.39.1:
version "24.39.1"
resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-24.39.1.tgz#5ca7db71508a35d00d4a3b23a9c117ea2022efc3"
integrity sha512-68Zc9QpcVvfxp2C+3UL88TyUogEAn5tSylXidbEuEXvhiqK1+v65zeBU5ubinAgEHMGr3dcSYqvYrGtdzsPI3w==
puppeteer@^24.40.0:
version "24.40.0"
resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-24.40.0.tgz#6df6aeee9dabf29bed3bb2be5c209d00518d4a79"
integrity sha512-IxQbDq93XHVVLWHrAkFP7F7iHvb9o0mgfsSIMlhHb+JM+JjM1V4v4MNSQfcRWJopx9dsNOr9adYv0U5fm9BJBQ==
dependencies:
"@puppeteer/browsers" "2.13.0"
chromium-bidi "14.0.0"
cosmiconfig "^9.0.0"
devtools-protocol "0.0.1581282"
puppeteer-core "24.39.1"
puppeteer-core "24.40.0"
typed-query-selector "^2.12.1"
qs@^6.14.0:
@@ -6487,10 +6596,10 @@ react-is@^16.13.1:
resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz"
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
react-range-slider-input@^3.3.2:
version "3.3.2"
resolved "https://registry.npmjs.org/react-range-slider-input/-/react-range-slider-input-3.3.2.tgz"
integrity sha512-CGyD/6Vlc7qakSW+92WAKrp333Xo9W+udW62xvf6dSwqEj7LFSY75udcbNRtCQhuXW1O7o71yC4AC/CC0etqSg==
react-range-slider-input@^3.3.5:
version "3.3.5"
resolved "https://registry.yarnpkg.com/react-range-slider-input/-/react-range-slider-input-3.3.5.tgz#3eebab9b249f7de9eb61510151cc383739c9f284"
integrity sha512-HkGjaq+q7u42K5WzFPD67duugPyD2m7EWVCJm139EqfR9AgSdEUxSFgRj6SW+nzGB5y8/6Jw75euxbeiuTT0cQ==
dependencies:
clsx "^1.1.1"
core-js "^3.22.4"
@@ -6503,17 +6612,17 @@ react-resizable@^3.0.5:
prop-types "15.x"
react-draggable "^4.0.3"
react-router-dom@7.13.1:
version "7.13.1"
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.13.1.tgz#74c045acc333ca94612b889cd1b1e1ee9534dead"
integrity sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==
react-router-dom@7.14.0:
version "7.14.0"
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.14.0.tgz#9d2df92ec9ce47e696808dc2a0e0a0c794ab278a"
integrity sha512-2G3ajSVSZMEtmTjIklRWlNvo8wICEpLihfD/0YMDxbWK2UyP5EGfnoIn9AIQGnF3G/FX0MRbHXdFcD+rL1ZreQ==
dependencies:
react-router "7.13.1"
react-router "7.14.0"
react-router@7.13.1:
version "7.13.1"
resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.13.1.tgz#5e2b3ebafd6c78d9775e135474bf5060645077f7"
integrity sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==
react-router@7.14.0:
version "7.14.0"
resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.14.0.tgz#33169c9ac03b298bb51aad13e038ba548c79a862"
integrity sha512-m/xR9N4LQLmAS0ZhkY2nkPA1N7gQ5TUVa5n8TgANuDTARbn1gt+zLPXEm7W0XDTbrQ2AJSJKhoa6yx1D8BcpxQ==
dependencies:
cookie "^1.0.1"
set-cookie-parser "^2.6.0"
@@ -6730,13 +6839,13 @@ require-from-string@^2.0.2:
resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909"
integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==
resend@^6.9.4:
version "6.9.4"
resolved "https://registry.yarnpkg.com/resend/-/resend-6.9.4.tgz#2d5a08e294b1dd1985531a9c51e7e6a48caf1549"
integrity sha512-/M3dsJzu5OgozqVsA4Psd/1L7EdePgOIIxClas453GOQYFG3VHc2ZyCHZFlvqsc9aZCCd2BJRRqZgWC8D9c7/g==
resend@^6.10.0:
version "6.10.0"
resolved "https://registry.yarnpkg.com/resend/-/resend-6.10.0.tgz#fc4e012268a31bb9575d5028c0a8fc0506fed582"
integrity sha512-i7CwZpYj4Oho1RxsTpLcCUkO08+HiL4NXrm6jLJ2WzJ89UGI8eROSieLONJA3hnUrf1OYnCyfq5F6POnHUMv1Q==
dependencies:
postal-mime "2.7.3"
svix "1.86.0"
postal-mime "2.7.4"
svix "1.88.0"
resolve-from@^4.0.0:
version "4.0.0"
@@ -6800,29 +6909,53 @@ robust-predicates@^3.0.2:
resolved "https://registry.yarnpkg.com/robust-predicates/-/robust-predicates-3.0.2.tgz#d5b28528c4824d20fc48df1928d41d9efa1ad771"
integrity sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==
rolldown@1.0.0-rc.9:
version "1.0.0-rc.9"
resolved "https://registry.yarnpkg.com/rolldown/-/rolldown-1.0.0-rc.9.tgz#5a0d3e194f2bcc7a134870b174042fcaed463689"
integrity sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==
rolldown@1.0.0-rc.12:
version "1.0.0-rc.12"
resolved "https://registry.yarnpkg.com/rolldown/-/rolldown-1.0.0-rc.12.tgz#e226fa74a4c21c71a13f8e44f778f81d58853ad5"
integrity sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==
dependencies:
"@oxc-project/types" "=0.115.0"
"@rolldown/pluginutils" "1.0.0-rc.9"
"@oxc-project/types" "=0.122.0"
"@rolldown/pluginutils" "1.0.0-rc.12"
optionalDependencies:
"@rolldown/binding-android-arm64" "1.0.0-rc.9"
"@rolldown/binding-darwin-arm64" "1.0.0-rc.9"
"@rolldown/binding-darwin-x64" "1.0.0-rc.9"
"@rolldown/binding-freebsd-x64" "1.0.0-rc.9"
"@rolldown/binding-linux-arm-gnueabihf" "1.0.0-rc.9"
"@rolldown/binding-linux-arm64-gnu" "1.0.0-rc.9"
"@rolldown/binding-linux-arm64-musl" "1.0.0-rc.9"
"@rolldown/binding-linux-ppc64-gnu" "1.0.0-rc.9"
"@rolldown/binding-linux-s390x-gnu" "1.0.0-rc.9"
"@rolldown/binding-linux-x64-gnu" "1.0.0-rc.9"
"@rolldown/binding-linux-x64-musl" "1.0.0-rc.9"
"@rolldown/binding-openharmony-arm64" "1.0.0-rc.9"
"@rolldown/binding-wasm32-wasi" "1.0.0-rc.9"
"@rolldown/binding-win32-arm64-msvc" "1.0.0-rc.9"
"@rolldown/binding-win32-x64-msvc" "1.0.0-rc.9"
"@rolldown/binding-android-arm64" "1.0.0-rc.12"
"@rolldown/binding-darwin-arm64" "1.0.0-rc.12"
"@rolldown/binding-darwin-x64" "1.0.0-rc.12"
"@rolldown/binding-freebsd-x64" "1.0.0-rc.12"
"@rolldown/binding-linux-arm-gnueabihf" "1.0.0-rc.12"
"@rolldown/binding-linux-arm64-gnu" "1.0.0-rc.12"
"@rolldown/binding-linux-arm64-musl" "1.0.0-rc.12"
"@rolldown/binding-linux-ppc64-gnu" "1.0.0-rc.12"
"@rolldown/binding-linux-s390x-gnu" "1.0.0-rc.12"
"@rolldown/binding-linux-x64-gnu" "1.0.0-rc.12"
"@rolldown/binding-linux-x64-musl" "1.0.0-rc.12"
"@rolldown/binding-openharmony-arm64" "1.0.0-rc.12"
"@rolldown/binding-wasm32-wasi" "1.0.0-rc.12"
"@rolldown/binding-win32-arm64-msvc" "1.0.0-rc.12"
"@rolldown/binding-win32-x64-msvc" "1.0.0-rc.12"
rolldown@1.0.0-rc.13:
version "1.0.0-rc.13"
resolved "https://registry.yarnpkg.com/rolldown/-/rolldown-1.0.0-rc.13.tgz#9670bddcc08f10f2809f1c9b58b101e9139f155c"
integrity sha512-bvVj8YJmf0rq4pSFmH7laLa6pYrhghv3PRzrCdRAr23g66zOKVJ4wkvFtgohtPLWmthgg8/rkaqRHrpUEh0Zbw==
dependencies:
"@oxc-project/types" "=0.123.0"
"@rolldown/pluginutils" "1.0.0-rc.13"
optionalDependencies:
"@rolldown/binding-android-arm64" "1.0.0-rc.13"
"@rolldown/binding-darwin-arm64" "1.0.0-rc.13"
"@rolldown/binding-darwin-x64" "1.0.0-rc.13"
"@rolldown/binding-freebsd-x64" "1.0.0-rc.13"
"@rolldown/binding-linux-arm-gnueabihf" "1.0.0-rc.13"
"@rolldown/binding-linux-arm64-gnu" "1.0.0-rc.13"
"@rolldown/binding-linux-arm64-musl" "1.0.0-rc.13"
"@rolldown/binding-linux-ppc64-gnu" "1.0.0-rc.13"
"@rolldown/binding-linux-s390x-gnu" "1.0.0-rc.13"
"@rolldown/binding-linux-x64-gnu" "1.0.0-rc.13"
"@rolldown/binding-linux-x64-musl" "1.0.0-rc.13"
"@rolldown/binding-openharmony-arm64" "1.0.0-rc.13"
"@rolldown/binding-wasm32-wasi" "1.0.0-rc.13"
"@rolldown/binding-win32-arm64-msvc" "1.0.0-rc.13"
"@rolldown/binding-win32-x64-msvc" "1.0.0-rc.13"
rope-sequence@^1.3.0:
version "1.3.4"
@@ -7371,10 +7504,10 @@ supports-preserve-symlinks-flag@^1.0.0:
resolved "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz"
integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
svix@1.86.0:
version "1.86.0"
resolved "https://registry.yarnpkg.com/svix/-/svix-1.86.0.tgz#f56818d2e45d1ca0d2e7fc50598eda6695a8cb09"
integrity sha512-/HTvXwjLJe1l/MsLXAO1ddCYxElJk4eNR4DzOjDOEmGrPN/3BtBE8perGwMAaJ2sT5T172VkBYzmHcjUfM1JRQ==
svix@1.88.0:
version "1.88.0"
resolved "https://registry.yarnpkg.com/svix/-/svix-1.88.0.tgz#2d8b952c7c62c84a1b223f5697fd627b8b2784e2"
integrity sha512-vm/JrrUd3bVyBE+3L33TIyVSs8gS5fYx7lrISvKlDJXTYX1ACH4REX8P1tHxsSKoZi/rvifM1t0XRc5Vc45THw==
dependencies:
standardwebhooks "1.0.0"
uuid "^10.0.0"
@@ -7455,7 +7588,7 @@ tinyqueue@^3.0.0:
resolved "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz"
integrity sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==
tinyrainbow@^3.0.3:
tinyrainbow@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/tinyrainbow/-/tinyrainbow-3.1.0.tgz#1d8a623893f95cf0a2ddb9e5d11150e191409421"
integrity sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==
@@ -7764,32 +7897,44 @@ vfile@^6.0.0:
"@types/unist" "^3.0.0"
vfile-message "^4.0.0"
vite@8.0.0, "vite@^6.0.0 || ^7.0.0 || ^8.0.0-0":
version "8.0.0"
resolved "https://registry.yarnpkg.com/vite/-/vite-8.0.0.tgz#d749f9bf5be196635982bc16ec0c6faf2b31f3a4"
integrity sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==
vite@8.0.7:
version "8.0.7"
resolved "https://registry.yarnpkg.com/vite/-/vite-8.0.7.tgz#e3028877022e04bcfb67180738f256108256aa13"
integrity sha512-P1PbweD+2/udplnThz3btF4cf6AgPky7kk23RtHUkJIU5BIxwPprhRGmOAHs6FTI7UiGbTNrgNP6jSYD6JaRnw==
dependencies:
"@oxc-project/runtime" "0.115.0"
lightningcss "^1.32.0"
picomatch "^4.0.3"
picomatch "^4.0.4"
postcss "^8.5.8"
rolldown "1.0.0-rc.9"
rolldown "1.0.0-rc.13"
tinyglobby "^0.2.15"
optionalDependencies:
fsevents "~2.3.3"
vitest@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/vitest/-/vitest-4.1.0.tgz#b598abbe83f0c9e93d18cf3c5f23c75a525f8e82"
integrity sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==
"vite@^6.0.0 || ^7.0.0 || ^8.0.0":
version "8.0.3"
resolved "https://registry.yarnpkg.com/vite/-/vite-8.0.3.tgz#036d9e3b077ff57b128660b3e3a5d2d12bac9b42"
integrity sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==
dependencies:
"@vitest/expect" "4.1.0"
"@vitest/mocker" "4.1.0"
"@vitest/pretty-format" "4.1.0"
"@vitest/runner" "4.1.0"
"@vitest/snapshot" "4.1.0"
"@vitest/spy" "4.1.0"
"@vitest/utils" "4.1.0"
lightningcss "^1.32.0"
picomatch "^4.0.4"
postcss "^8.5.8"
rolldown "1.0.0-rc.12"
tinyglobby "^0.2.15"
optionalDependencies:
fsevents "~2.3.3"
vitest@^4.1.3:
version "4.1.3"
resolved "https://registry.yarnpkg.com/vitest/-/vitest-4.1.3.tgz#170d392242fc652a130d5bdb60957291ca4eb9df"
integrity sha512-DBc4Tx0MPNsqb9isoyOq00lHftVx/KIU44QOm2q59npZyLUkENn8TMFsuzuO+4U2FUa9rgbbPt3udrP25GcjXw==
dependencies:
"@vitest/expect" "4.1.3"
"@vitest/mocker" "4.1.3"
"@vitest/pretty-format" "4.1.3"
"@vitest/runner" "4.1.3"
"@vitest/snapshot" "4.1.3"
"@vitest/spy" "4.1.3"
"@vitest/utils" "4.1.3"
es-module-lexer "^2.0.0"
expect-type "^1.3.0"
magic-string "^0.30.21"
@@ -7800,8 +7945,8 @@ vitest@^4.1.0:
tinybench "^2.9.0"
tinyexec "^1.0.2"
tinyglobby "^0.2.15"
tinyrainbow "^3.0.3"
vite "^6.0.0 || ^7.0.0 || ^8.0.0-0"
tinyrainbow "^3.1.0"
vite "^6.0.0 || ^7.0.0 || ^8.0.0"
why-is-node-running "^2.3.0"
w3c-keyname@^2.2.0: