mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c039f0026 | ||
|
|
a1289acf15 | ||
|
|
8501fc7266 | ||
|
|
4960846cd7 | ||
|
|
3ed17f4442 | ||
|
|
b531a7b77a | ||
|
|
3523057221 |
1
.github/workflows/docker.yml
vendored
1
.github/workflows/docker.yml
vendored
@@ -62,6 +62,7 @@ jobs:
|
|||||||
- name: Test container with docker compose
|
- name: Test container with docker compose
|
||||||
run: |
|
run: |
|
||||||
echo "Starting container with docker compose..."
|
echo "Starting container with docker compose..."
|
||||||
|
mkdir -p ./db ./conf && chmod 777 ./db ./conf
|
||||||
docker compose up --build -d
|
docker compose up --build -d
|
||||||
echo "Waiting for container to be ready (60 seconds for start_period)..."
|
echo "Waiting for container to be ready (60 seconds for start_period)..."
|
||||||
sleep 60
|
sleep 60
|
||||||
|
|||||||
81
Dockerfile
81
Dockerfile
@@ -1,70 +1,55 @@
|
|||||||
# ================================
|
FROM node:22-slim
|
||||||
# Stage 1: Build stage
|
|
||||||
# ================================
|
|
||||||
FROM node:22-alpine AS builder
|
|
||||||
|
|
||||||
WORKDIR /build
|
ARG TARGETARCH
|
||||||
|
|
||||||
# Install build dependencies needed for native modules (better-sqlite3)
|
# System deps for Chrome for Testing + build tools for native modules (better-sqlite3)
|
||||||
RUN apk add --no-cache python3 make g++
|
# 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 ./
|
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 \
|
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 index.html vite.config.js ./
|
||||||
COPY ui ./ui
|
COPY ui ./ui
|
||||||
COPY lib ./lib
|
COPY lib ./lib
|
||||||
|
|
||||||
# Build frontend assets
|
|
||||||
RUN yarn build:frontend
|
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.js ./
|
||||||
COPY index.html ./
|
|
||||||
COPY lib ./lib
|
|
||||||
|
|
||||||
# Prepare runtime directories and symlinks for data and config
|
RUN ln -s /db /fredy/db \
|
||||||
RUN mkdir -p /db /conf \
|
|
||||||
&& chown 1000:1000 /db /conf \
|
|
||||||
&& chmod 777 /db /conf \
|
|
||||||
&& ln -s /db /fredy/db \
|
|
||||||
&& ln -s /conf /fredy/conf
|
&& ln -s /conf /fredy/conf
|
||||||
|
|
||||||
EXPOSE 9998
|
EXPOSE 9998
|
||||||
VOLUME /db
|
VOLUME /db
|
||||||
VOLUME /conf
|
VOLUME /conf
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:9998/ || exit 1
|
||||||
|
|
||||||
CMD ["node", "index.js"]
|
CMD ["node", "index.js"]
|
||||||
|
|||||||
@@ -7,12 +7,72 @@ if [ "$(docker ps -aq -f name=fredy)" ]; then
|
|||||||
docker rm fredy || true
|
docker rm fredy || true
|
||||||
fi
|
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
|
# 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
|
# Run container with volumes and port mapping
|
||||||
docker run -d --name fredy \
|
if [ -n "$PLATFORM" ]; then
|
||||||
-v fredy_conf:/conf \
|
docker run -d --name fredy --platform "$PLATFORM" -v fredy_conf:/conf -v fredy_db:/db -p 9998:9998 fredy:local
|
||||||
-v fredy_db:/db \
|
else
|
||||||
-p 9998:9998 \
|
docker run -d --name fredy -v fredy_conf:/conf -v fredy_db:/db -p 9998:9998 fredy:local
|
||||||
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."
|
||||||
|
|||||||
@@ -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:
|
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).
|
> **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)
|
## 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:
|
The HTTP transport is automatically available when Fredy is running. It uses the MCP Streamable HTTP protocol at:
|
||||||
|
|||||||
112
lib/notification/adapter/smtp.js
Normal file
112
lib/notification/adapter/smtp.js
Normal 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.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
22
lib/notification/adapter/smtp.md
Normal file
22
lib/notification/adapter/smtp.md
Normal 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
|
||||||
@@ -47,12 +47,17 @@ export async function launchBrowser(url, options) {
|
|||||||
removeUserDataDir = true;
|
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({
|
const browser = await puppeteer.launch({
|
||||||
headless: options?.puppeteerHeadless ?? true,
|
headless: options?.puppeteerHeadless ?? true,
|
||||||
args: launchArgs,
|
args: launchArgs,
|
||||||
timeout: options?.puppeteerTimeout || 45_000,
|
timeout: options?.puppeteerTimeout || 45_000,
|
||||||
userDataDir,
|
userDataDir,
|
||||||
executablePath: options?.executablePath,
|
executablePath,
|
||||||
});
|
});
|
||||||
|
|
||||||
browser.__fredy_userDataDir = userDataDir;
|
browser.__fredy_userDataDir = userDataDir;
|
||||||
|
|||||||
@@ -29,12 +29,12 @@
|
|||||||
*/
|
*/
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { pathToFileURL } from 'url';
|
import { pathToFileURL, fileURLToPath } from 'url';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import SqliteConnection from '../SqliteConnection.js';
|
import SqliteConnection from '../SqliteConnection.js';
|
||||||
import logger from '../../logger.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).
|
* Absolute path to the migrations directory (lib/services/storage/migrations/sql).
|
||||||
* @type {string}
|
* @type {string}
|
||||||
|
|||||||
11
package.json
11
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "fredy",
|
"name": "fredy",
|
||||||
"version": "20.0.5",
|
"version": "20.1.0",
|
||||||
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
@@ -77,14 +77,15 @@
|
|||||||
"cookie-session": "2.1.1",
|
"cookie-session": "2.1.1",
|
||||||
"handlebars": "4.7.8",
|
"handlebars": "4.7.8",
|
||||||
"lodash": "4.17.23",
|
"lodash": "4.17.23",
|
||||||
"maplibre-gl": "^5.20.1",
|
"maplibre-gl": "^5.21.0",
|
||||||
"nanoid": "5.1.7",
|
"nanoid": "5.1.7",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"node-fetch": "3.3.2",
|
"node-fetch": "3.3.2",
|
||||||
"node-mailjet": "6.0.11",
|
"node-mailjet": "6.0.11",
|
||||||
|
"nodemailer": "^8.0.3",
|
||||||
"p-throttle": "^8.1.0",
|
"p-throttle": "^8.1.0",
|
||||||
"package-up": "^5.0.0",
|
"package-up": "^5.0.0",
|
||||||
"puppeteer": "^24.39.1",
|
"puppeteer": "^24.40.0",
|
||||||
"puppeteer-extra": "^3.3.6",
|
"puppeteer-extra": "^3.3.6",
|
||||||
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
||||||
"query-string": "9.3.1",
|
"query-string": "9.3.1",
|
||||||
@@ -99,7 +100,7 @@
|
|||||||
"semver": "^7.7.4",
|
"semver": "^7.7.4",
|
||||||
"serve-static": "2.2.1",
|
"serve-static": "2.2.1",
|
||||||
"slack": "11.0.2",
|
"slack": "11.0.2",
|
||||||
"vite": "8.0.0",
|
"vite": "8.0.1",
|
||||||
"x-var": "^3.0.1",
|
"x-var": "^3.0.1",
|
||||||
"zustand": "^5.0.12"
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
@@ -110,7 +111,7 @@
|
|||||||
"@babel/preset-react": "7.28.5",
|
"@babel/preset-react": "7.28.5",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"chalk": "^5.6.2",
|
"chalk": "^5.6.2",
|
||||||
"eslint": "10.0.3",
|
"eslint": "10.1.0",
|
||||||
"eslint-config-prettier": "10.1.8",
|
"eslint-config-prettier": "10.1.8",
|
||||||
"eslint-plugin-react": "7.37.5",
|
"eslint-plugin-react": "7.37.5",
|
||||||
"globals": "^17.4.0",
|
"globals": "^17.4.0",
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import React, { useEffect } from 'react';
|
|||||||
import InsufficientPermission from './components/permission/InsufficientPermission';
|
import InsufficientPermission from './components/permission/InsufficientPermission';
|
||||||
import PermissionAwareRoute from './components/permission/PermissionAwareRoute';
|
import PermissionAwareRoute from './components/permission/PermissionAwareRoute';
|
||||||
import GeneralSettings from './views/generalSettings/GeneralSettings';
|
import GeneralSettings from './views/generalSettings/GeneralSettings';
|
||||||
import UserSettings from './views/userSettings/UserSettings';
|
|
||||||
import JobMutation from './views/jobs/mutation/JobMutation';
|
import JobMutation from './views/jobs/mutation/JobMutation';
|
||||||
import UserMutator from './views/user/mutation/UserMutator';
|
import UserMutator from './views/user/mutation/UserMutator';
|
||||||
import { useActions, useSelector } from './services/state/store';
|
import { useActions, useSelector } from './services/state/store';
|
||||||
@@ -127,15 +126,8 @@ export default function FredyApp() {
|
|||||||
</PermissionAwareRoute>
|
</PermissionAwareRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route path="/userSettings" element={<UserSettings />} />
|
<Route path="/userSettings" element={<Navigate to="/generalSettings" replace />} />
|
||||||
<Route
|
<Route path="/generalSettings" element={<GeneralSettings />} />
|
||||||
path="/generalSettings"
|
|
||||||
element={
|
|
||||||
<PermissionAwareRoute currentUser={currentUser}>
|
|
||||||
<GeneralSettings />
|
|
||||||
</PermissionAwareRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
|
@import './DashboardCardColors.less';
|
||||||
|
|
||||||
.dashboard-card {
|
.dashboard-card {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 140px;
|
height: 140px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
transition: transform 0.2s;
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
background-color: rgba(36, 36, 36, 0.9);
|
background-color: #181b26;
|
||||||
backdrop-filter: blur(8px);
|
border: 1px solid #232735;
|
||||||
border: 1px solid var(--semi-color-border);
|
border-radius: 10px;
|
||||||
--pulse-color: rgba(255, 255, 255, 0.1);
|
--pulse-color: rgba(255, 255, 255, 0.08);
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
@@ -32,6 +34,14 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: 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 {
|
&__content {
|
||||||
@@ -41,32 +51,51 @@
|
|||||||
&__value {
|
&__value {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin-bottom: 4px;
|
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 {
|
&.blue {
|
||||||
--pulse-color: var(--semi-color-primary);
|
--pulse-color: @color-blue-border;
|
||||||
box-shadow: 0 4px 20px -5px var(--pulse-color);
|
--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 {
|
&.orange {
|
||||||
--pulse-color: var(--semi-color-warning);
|
--pulse-color: @color-orange-border;
|
||||||
box-shadow: 0 4px 20px -5px var(--pulse-color);
|
--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 {
|
&.green {
|
||||||
--pulse-color: var(--semi-color-success);
|
--pulse-color: @color-green-border;
|
||||||
box-shadow: 0 4px 20px -5px var(--pulse-color);
|
--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 {
|
&.purple {
|
||||||
--pulse-color: var(--semi-color-info);
|
--pulse-color: @color-purple-border;
|
||||||
box-shadow: 0 4px 20px -5px var(--pulse-color);
|
--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 {
|
&.gray {
|
||||||
--pulse-color: rgba(255, 255, 255, 0.2);
|
--pulse-color: @color-gray-border;
|
||||||
box-shadow: 0 4px 20px -5px var(--pulse-color);
|
--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;
|
opacity: 0.1;
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
opacity: 0.5;
|
opacity: 0.4;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,19 +1,19 @@
|
|||||||
@color-blue-bg: rgba(0, 123, 255, 0.24);
|
@color-blue-bg: rgba(96, 165, 250, 0.10);
|
||||||
@color-blue-border: #1E40AFFF;
|
@color-blue-border: #3b6ea8;
|
||||||
@color-blue-text: #60a5fa;
|
@color-blue-text: #60a5fa;
|
||||||
|
|
||||||
@color-orange-bg: rgba(250, 91, 5, 0.12);
|
@color-orange-bg: rgba(251, 146, 60, 0.10);
|
||||||
@color-orange-border: #992f0c;
|
@color-orange-border: #c2622a;
|
||||||
@color-orange-text: #FB923CFF;
|
@color-orange-text: #fb923c;
|
||||||
|
|
||||||
@color-green-bg: rgba(38, 250, 5, 0.12);
|
@color-green-bg: rgba(52, 211, 153, 0.10);
|
||||||
@color-green-border: #278832;
|
@color-green-border: #2a8a61;
|
||||||
@color-green-text: #33f308;
|
@color-green-text: #34d399;
|
||||||
|
|
||||||
@color-purple-bg: rgba(91, 3, 218, 0.38);
|
@color-purple-bg: rgba(167, 139, 250, 0.10);
|
||||||
@color-purple-border: #7500c3;
|
@color-purple-border: #6d4fc2;
|
||||||
@color-purple-text: #b15fff;
|
@color-purple-text: #a78bfa;
|
||||||
|
|
||||||
@color-gray-bg: rgba(110, 110, 110, 0.38);
|
@color-gray-bg: rgba(148, 163, 184, 0.10);
|
||||||
@color-gray-border: #807f7f;
|
@color-gray-border: #323a47;
|
||||||
@color-gray-text: #bab9b9;
|
@color-gray-text: #94a3b8;
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
Col,
|
Col,
|
||||||
Row,
|
Row,
|
||||||
Button,
|
Button,
|
||||||
Space,
|
|
||||||
Typography,
|
Typography,
|
||||||
Divider,
|
Divider,
|
||||||
Switch,
|
Switch,
|
||||||
@@ -20,6 +19,8 @@ import {
|
|||||||
Pagination,
|
Pagination,
|
||||||
Toast,
|
Toast,
|
||||||
Empty,
|
Empty,
|
||||||
|
Radio,
|
||||||
|
RadioGroup,
|
||||||
} from '@douyinfe/semi-ui-19';
|
} from '@douyinfe/semi-ui-19';
|
||||||
import {
|
import {
|
||||||
IconAlertTriangle,
|
IconAlertTriangle,
|
||||||
@@ -31,8 +32,10 @@ import {
|
|||||||
IconBriefcase,
|
IconBriefcase,
|
||||||
IconBell,
|
IconBell,
|
||||||
IconSearch,
|
IconSearch,
|
||||||
IconFilter,
|
|
||||||
IconPlusCircle,
|
IconPlusCircle,
|
||||||
|
IconArrowUp,
|
||||||
|
IconArrowDown,
|
||||||
|
IconHome,
|
||||||
} from '@douyinfe/semi-icons';
|
} from '@douyinfe/semi-icons';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import ListingDeletionModal from '../../ListingDeletionModal.jsx';
|
import ListingDeletionModal from '../../ListingDeletionModal.jsx';
|
||||||
@@ -59,8 +62,6 @@ const JobGrid = () => {
|
|||||||
const [sortDir, setSortDir] = useState('asc');
|
const [sortDir, setSortDir] = useState('asc');
|
||||||
const [freeTextFilter, setFreeTextFilter] = useState(null);
|
const [freeTextFilter, setFreeTextFilter] = useState(null);
|
||||||
const [activityFilter, setActivityFilter] = useState(null);
|
const [activityFilter, setActivityFilter] = useState(null);
|
||||||
const [showFilterBar, setShowFilterBar] = useState(false);
|
|
||||||
|
|
||||||
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
||||||
const [pendingDeletion, setPendingDeletion] = useState(null); // { type: 'job'|'listings', jobId }
|
const [pendingDeletion, setPendingDeletion] = useState(null); // { type: 'job'|'listings', jobId }
|
||||||
|
|
||||||
@@ -200,73 +201,45 @@ const JobGrid = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="jobGrid">
|
<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')}>
|
<Button type="primary" icon={<IconPlusCircle />} onClick={() => navigate('/jobs/new')}>
|
||||||
New Job
|
New Job
|
||||||
</Button>
|
</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 && (
|
<Input
|
||||||
<div className="jobGrid__toolbar">
|
className="jobGrid__topbar__search"
|
||||||
<Space wrap style={{ marginBottom: '1rem' }}>
|
prefix={<IconSearch />}
|
||||||
<div className="jobGrid__toolbar__card">
|
showClear
|
||||||
<div>
|
placeholder="Search"
|
||||||
<Text strong>Filter by:</Text>
|
onChange={handleFilterChange}
|
||||||
</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>
|
|
||||||
|
|
||||||
<Select
|
<RadioGroup
|
||||||
placeholder="Direction"
|
type="button"
|
||||||
style={{ width: 120 }}
|
buttonSize="middle"
|
||||||
value={sortDir}
|
value={activityFilter === null ? 'all' : String(activityFilter)}
|
||||||
onChange={(val) => setSortDir(val)}
|
onChange={(e) => {
|
||||||
>
|
const v = e.target.value;
|
||||||
<Select.Option value="asc">Ascending</Select.Option>
|
setActivityFilter(v === 'all' ? null : v === 'true');
|
||||||
<Select.Option value="desc">Descending</Select.Option>
|
}}
|
||||||
</Select>
|
>
|
||||||
</div>
|
<Radio value="all">All</Radio>
|
||||||
</div>
|
<Radio value="true">Active</Radio>
|
||||||
</Space>
|
<Radio value="false">Inactive</Radio>
|
||||||
</div>
|
</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 && (
|
{(jobsData?.result || []).length === 0 && (
|
||||||
<Empty
|
<Empty
|
||||||
@@ -278,78 +251,70 @@ const JobGrid = () => {
|
|||||||
|
|
||||||
<Row gutter={[16, 16]}>
|
<Row gutter={[16, 16]}>
|
||||||
{(jobsData?.result || []).map((job) => (
|
{(jobsData?.result || []).map((job) => (
|
||||||
<Col key={job.id} xs={24} sm={12} md={8} lg={6} xl={4} xxl={6}>
|
<Col key={job.id} xs={24} sm={12} md={12} lg={8} xl={8} xxl={6}>
|
||||||
<Card
|
<Card className="jobGrid__card" bodyStyle={{ padding: '16px' }}>
|
||||||
className="jobGrid__card"
|
<div className="jobGrid__card__header">
|
||||||
bodyStyle={{ padding: '16px' }}
|
<div className="jobGrid__card__name">
|
||||||
title={
|
<span className={`jobGrid__card__dot${job.enabled ? ' jobGrid__card__dot--active' : ''}`} />
|
||||||
<div className="jobGrid__header">
|
|
||||||
<Title heading={5} ellipsis={{ showTooltip: true }} className="jobGrid__title">
|
<Title heading={5} ellipsis={{ showTooltip: true }} className="jobGrid__title">
|
||||||
{job.name}
|
{job.name}
|
||||||
</Title>
|
</Title>
|
||||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
</div>
|
||||||
{job.isOnlyShared && (
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
|
||||||
<Popover
|
{job.isOnlyShared && (
|
||||||
content={getPopoverContent(
|
<Popover
|
||||||
'This job has been shared with you by another user, therefor it is read-only.',
|
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>
|
||||||
</div>
|
<IconAlertTriangle style={{ color: 'rgba(var(--semi-yellow-7), 1)' }} />
|
||||||
</Popover>
|
</div>
|
||||||
)}
|
</Popover>
|
||||||
</div>
|
)}
|
||||||
{job.running && (
|
{job.running && (
|
||||||
<Tag color="green" variant="light" size="small">
|
<Tag color="green" variant="light" size="small">
|
||||||
RUNNING
|
RUNNING
|
||||||
</Tag>
|
</Tag>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
}
|
</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>
|
|
||||||
|
|
||||||
<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">
|
<div className="jobGrid__actions">
|
||||||
<Popover content={getPopoverContent('Run Job')}>
|
<Popover content={getPopoverContent('Run Job')}>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
@import '../../cards/DashboardCardColors.less';
|
||||||
|
|
||||||
.jobGrid {
|
.jobGrid {
|
||||||
&__card {
|
&__card {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -12,55 +14,137 @@
|
|||||||
box-shadow: 0 0 15px -3px rgb(78 78 78 / 70%);
|
box-shadow: 0 0 15px -3px rgb(78 78 78 / 70%);
|
||||||
background-color: rgba(36, 36, 36, 1);
|
background-color: rgba(36, 36, 36, 1);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
&__searchbar {
|
&__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: .5rem;
|
align-items: flex-start;
|
||||||
align-items: center;
|
justify-content: space-between;
|
||||||
justify-content: space-between;
|
gap: 8px;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__toolbar {
|
&__name {
|
||||||
&__card {
|
display: flex;
|
||||||
border-radius: var(--semi-border-radius-medium);
|
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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: .3rem;
|
align-items: center;
|
||||||
background: rgba(36, 36, 36, 0.9);
|
background: rgba(255, 255, 255, 0.04);
|
||||||
backdrop-filter: blur(8px);
|
border: 1px solid transparent;
|
||||||
padding: 0.5rem;
|
border-radius: var(--semi-border-radius-small);
|
||||||
border: 1px solid var(--semi-color-border);
|
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;
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
align-items: center;
|
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 {
|
&__title {
|
||||||
margin-bottom: 0 !important;
|
margin-bottom: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__infoItem {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
.semi-typography {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__actions {
|
&__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
gap: 6px;
|
||||||
gap: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__pagination {
|
&__pagination {
|
||||||
|
|||||||
@@ -10,15 +10,15 @@ import {
|
|||||||
Row,
|
Row,
|
||||||
Image,
|
Image,
|
||||||
Button,
|
Button,
|
||||||
Space,
|
|
||||||
Typography,
|
Typography,
|
||||||
Pagination,
|
Pagination,
|
||||||
Toast,
|
Toast,
|
||||||
Divider,
|
Divider,
|
||||||
Input,
|
Input,
|
||||||
Select,
|
Select,
|
||||||
Popover,
|
|
||||||
Empty,
|
Empty,
|
||||||
|
Radio,
|
||||||
|
RadioGroup,
|
||||||
} from '@douyinfe/semi-ui-19';
|
} from '@douyinfe/semi-ui-19';
|
||||||
import {
|
import {
|
||||||
IconBriefcase,
|
IconBriefcase,
|
||||||
@@ -30,9 +30,10 @@ import {
|
|||||||
IconStar,
|
IconStar,
|
||||||
IconStarStroked,
|
IconStarStroked,
|
||||||
IconSearch,
|
IconSearch,
|
||||||
IconFilter,
|
|
||||||
IconActivity,
|
IconActivity,
|
||||||
IconEyeOpened,
|
IconEyeOpened,
|
||||||
|
IconArrowUp,
|
||||||
|
IconArrowDown,
|
||||||
} from '@douyinfe/semi-icons';
|
} from '@douyinfe/semi-icons';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import ListingDeletionModal from '../../ListingDeletionModal.jsx';
|
import ListingDeletionModal from '../../ListingDeletionModal.jsx';
|
||||||
@@ -64,8 +65,6 @@ const ListingsGrid = () => {
|
|||||||
const [jobNameFilter, setJobNameFilter] = useState(null);
|
const [jobNameFilter, setJobNameFilter] = useState(null);
|
||||||
const [activityFilter, setActivityFilter] = useState(null);
|
const [activityFilter, setActivityFilter] = useState(null);
|
||||||
const [providerFilter, setProviderFilter] = useState(null);
|
const [providerFilter, setProviderFilter] = useState(null);
|
||||||
const [showFilterBar, setShowFilterBar] = useState(false);
|
|
||||||
|
|
||||||
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
||||||
const [listingToDelete, setListingToDelete] = useState(null);
|
const [listingToDelete, setListingToDelete] = useState(null);
|
||||||
|
|
||||||
@@ -129,107 +128,84 @@ const ListingsGrid = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="listingsGrid">
|
<div className="listingsGrid">
|
||||||
<div className="listingsGrid__searchbar">
|
<div className="listingsGrid__topbar">
|
||||||
<Input prefix={<IconSearch />} showClear placeholder="Search" onChange={handleFilterChange} />
|
<Input
|
||||||
<Popover content="Filter / Sort Results" style={{ color: 'white', padding: '.5rem' }}>
|
className="listingsGrid__topbar__search"
|
||||||
<div>
|
prefix={<IconSearch />}
|
||||||
<Button
|
showClear
|
||||||
icon={<IconFilter />}
|
placeholder="Search"
|
||||||
onClick={() => {
|
onChange={handleFilterChange}
|
||||||
setShowFilterBar(!showFilterBar);
|
/>
|
||||||
}}
|
|
||||||
/>
|
<RadioGroup
|
||||||
</div>
|
type="button"
|
||||||
</Popover>
|
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>
|
||||||
|
|
||||||
|
<RadioGroup
|
||||||
|
type="button"
|
||||||
|
buttonSize="middle"
|
||||||
|
value={watchListFilter === null ? 'all' : String(watchListFilter)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
setWatchListFilter(v === 'all' ? null : v === 'true');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Radio value="all">All</Radio>
|
||||||
|
<Radio value="true">Watched</Radio>
|
||||||
|
<Radio value="false">Unwatched</Radio>
|
||||||
|
</RadioGroup>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
placeholder="Provider"
|
||||||
|
showClear
|
||||||
|
onChange={(val) => setProviderFilter(val)}
|
||||||
|
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)}
|
||||||
|
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>
|
</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 && (
|
{(listingsData?.result || []).length === 0 && (
|
||||||
<Empty
|
<Empty
|
||||||
@@ -240,7 +216,7 @@ const ListingsGrid = () => {
|
|||||||
)}
|
)}
|
||||||
<Row gutter={[16, 16]}>
|
<Row gutter={[16, 16]}>
|
||||||
{(listingsData?.result || []).map((item) => (
|
{(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
|
<Card
|
||||||
className={`listingsGrid__card ${!item.is_active ? 'listingsGrid__card--inactive' : ''}`}
|
className={`listingsGrid__card ${!item.is_active ? 'listingsGrid__card--inactive' : ''}`}
|
||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
@@ -280,10 +256,11 @@ const ListingsGrid = () => {
|
|||||||
<Text strong ellipsis={{ showTooltip: true }} className="listingsGrid__title">
|
<Text strong ellipsis={{ showTooltip: true }} className="listingsGrid__title">
|
||||||
{cap(item.title)}
|
{cap(item.title)}
|
||||||
</Text>
|
</Text>
|
||||||
<Space vertical align="start" spacing={2} style={{ width: '100%', marginTop: 8 }}>
|
<div className="listingsGrid__price">
|
||||||
<Text type="secondary" icon={<IconCart />} size="small">
|
<IconCart size="small" />
|
||||||
{item.price} €
|
{item.price} €
|
||||||
</Text>
|
</div>
|
||||||
|
<div className="listingsGrid__meta">
|
||||||
<Text
|
<Text
|
||||||
type="secondary"
|
type="secondary"
|
||||||
icon={<IconMapPin />}
|
icon={<IconMapPin />}
|
||||||
@@ -305,18 +282,17 @@ const ListingsGrid = () => {
|
|||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<Text type="tertiary" size="small" icon={<IconActivity />}>
|
<Text type="tertiary" size="small" icon={<IconActivity />}>
|
||||||
Distance cannot be calculated, provide an address
|
Distance cannot be calculated
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</div>
|
||||||
<Divider margin=".6rem" />
|
<Divider margin=".6rem" />
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
<div className="listingsGrid__actions">
|
||||||
<div className="listingsGrid__linkButton" onClick={(e) => e.stopPropagation()}>
|
<div className="listingsGrid__linkButton" onClick={(e) => e.stopPropagation()}>
|
||||||
<a href={item.link} target="_blank" rel="noopener noreferrer">
|
<a href={item.link} target="_blank" rel="noopener noreferrer">
|
||||||
<IconLink />
|
<IconLink />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="secondary"
|
type="secondary"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -324,7 +300,6 @@ const ListingsGrid = () => {
|
|||||||
onClick={() => navigate(`/listings/listing/${item.id}`)}
|
onClick={() => navigate(`/listings/listing/${item.id}`)}
|
||||||
icon={<IconEyeOpened />}
|
icon={<IconEyeOpened />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
title="Remove"
|
title="Remove"
|
||||||
type="danger"
|
type="danger"
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
@import '../../cards/DashboardCardColors.less';
|
||||||
|
|
||||||
.listingsGrid {
|
.listingsGrid {
|
||||||
&__imageContainer {
|
&__imageContainer {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -5,12 +7,34 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__searchbar {
|
&__topbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: .5rem;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
gap: 8px;
|
||||||
margin-bottom: 1rem;
|
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 {
|
&__watchButton {
|
||||||
@@ -93,17 +117,27 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__toolbar {
|
&__price {
|
||||||
&__card {
|
font-size: 18px;
|
||||||
border-radius: var(--semi-border-radius-medium);
|
font-weight: 700;
|
||||||
display: flex;
|
color: @color-green-text;
|
||||||
flex-direction: column;
|
display: flex;
|
||||||
gap: .3rem;
|
align-items: center;
|
||||||
background: rgba(36, 36, 36, 0.9);
|
gap: 5px;
|
||||||
backdrop-filter: blur(8px);
|
margin: 8px 0 6px;
|
||||||
padding: 0.5rem;
|
}
|
||||||
border: 1px solid var(--semi-color-border);
|
|
||||||
}
|
&__meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__setupButton {
|
&__setupButton {
|
||||||
|
|||||||
@@ -42,24 +42,21 @@ export default function Navigation({ isAdmin }) {
|
|||||||
];
|
];
|
||||||
|
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
const settingsItems = [
|
|
||||||
{ itemKey: '/users', text: 'User Management' },
|
|
||||||
{ itemKey: '/userSettings', text: 'User Specific Settings' },
|
|
||||||
{ itemKey: '/generalSettings', text: 'General Settings' },
|
|
||||||
];
|
|
||||||
|
|
||||||
items.push({
|
items.push({
|
||||||
itemKey: 'settings',
|
itemKey: 'settings',
|
||||||
text: 'Settings',
|
text: 'Settings',
|
||||||
icon: <IconSetting />,
|
icon: <IconSetting />,
|
||||||
items: settingsItems,
|
items: [
|
||||||
|
{ itemKey: '/users', text: 'User Management' },
|
||||||
|
{ itemKey: '/generalSettings', text: 'Settings' },
|
||||||
|
],
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
items.push({
|
items.push({
|
||||||
itemKey: 'settings',
|
itemKey: 'settings',
|
||||||
text: 'Settings',
|
text: 'Settings',
|
||||||
icon: <IconSetting />,
|
icon: <IconSetting />,
|
||||||
items: [{ itemKey: '/userSettings', text: 'User Specific Settings' }],
|
items: [{ itemKey: '/generalSettings', text: 'Settings' }],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,5 +3,5 @@
|
|||||||
border-radius: .9rem !important;
|
border-radius: .9rem !important;
|
||||||
color: rgba(var(--semi-grey-8), 1);
|
color: rgba(var(--semi-grey-8), 1);
|
||||||
background: rgb(53, 54, 60);
|
background: rgb(53, 54, 60);
|
||||||
margin: 2rem;
|
margin: 0 0 1rem 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
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 {
|
import {
|
||||||
IconTerminal,
|
IconTerminal,
|
||||||
IconStar,
|
IconStar,
|
||||||
@@ -22,7 +22,6 @@ import KpiCard from '../../components/cards/KpiCard.jsx';
|
|||||||
import PieChartCard from '../../components/cards/PieChartCard.jsx';
|
import PieChartCard from '../../components/cards/PieChartCard.jsx';
|
||||||
|
|
||||||
import './Dashboard.less';
|
import './Dashboard.less';
|
||||||
import { SegmentPart } from '../../components/segment/SegmentPart.jsx';
|
|
||||||
import { xhrPost } from '../../services/xhr.js';
|
import { xhrPost } from '../../services/xhr.js';
|
||||||
import { format } from '../../services/time/timeService.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 kpis = dashboard?.kpis || { totalJobs: 0, totalListings: 0, providersUsed: 0 };
|
||||||
const pieData = dashboard?.pie || [];
|
const pieData = dashboard?.pie || [];
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dashboard">
|
<div className="dashboard">
|
||||||
|
<Text className="dashboard__section-label">General</Text>
|
||||||
<Row gutter={[16, 16]} className="dashboard__row">
|
<Row gutter={[16, 16]} className="dashboard__row">
|
||||||
<Col span={12} xs={24} sm={24} md={24} lg={24} xl={12}>
|
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
|
||||||
<SegmentPart name="General" Icon={IconTerminal}>
|
<KpiCard
|
||||||
<Row gutter={[16, 16]} className="dashboard__row">
|
title="Search Interval"
|
||||||
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
|
value={`${dashboard?.general?.interval} min`}
|
||||||
<KpiCard
|
icon={<IconClock />}
|
||||||
title="Search Interval"
|
description="Time interval for job execution"
|
||||||
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>
|
</Col>
|
||||||
<Col span={12} xs={24} sm={24} md={24} lg={24} xl={12}>
|
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
|
||||||
<SegmentPart name="Overview" Icon={IconStar}>
|
<KpiCard
|
||||||
<Row gutter={[16, 16]} className="dashboard__row">
|
title="Last Search"
|
||||||
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
|
valueFontSize="14px"
|
||||||
<KpiCard
|
value={
|
||||||
title="Jobs"
|
dashboard?.general?.lastRun == null || dashboard?.general?.lastRun === 0
|
||||||
color="blue"
|
? '---'
|
||||||
value={!kpis.totalJobs ? '---' : kpis.totalJobs}
|
: format(dashboard?.general?.lastRun)
|
||||||
icon={<IconTerminal />}
|
}
|
||||||
description="Total number of jobs"
|
icon={<IconDoubleChevronLeft />}
|
||||||
/>
|
description="Last execution timestamp"
|
||||||
</Col>
|
/>
|
||||||
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
|
</Col>
|
||||||
<KpiCard
|
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
|
||||||
title="Listings"
|
<KpiCard
|
||||||
color="orange"
|
title="Next Search"
|
||||||
value={!kpis.totalListings ? '---' : kpis.totalListings}
|
value={
|
||||||
icon={<IconStarStroked />}
|
dashboard?.general?.nextRun == null || dashboard?.general?.nextRun === 0
|
||||||
description="Total listings found"
|
? '---'
|
||||||
/>
|
: format(dashboard?.general?.nextRun)
|
||||||
</Col>
|
}
|
||||||
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
|
valueFontSize="14px"
|
||||||
<KpiCard
|
icon={<IconDoubleChevronRight />}
|
||||||
title="Active Listings"
|
description="Next execution timestamp"
|
||||||
color="green"
|
/>
|
||||||
value={!kpis.numberOfActiveListings ? '---' : kpis.numberOfActiveListings}
|
</Col>
|
||||||
icon={<IconStar />}
|
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
|
||||||
description="Total active listings"
|
<KpiCard title="Search Now" icon={<IconSearch />} description="Run a search now">
|
||||||
/>
|
<Button
|
||||||
</Col>
|
size="small"
|
||||||
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
|
style={{ marginTop: '.2rem' }}
|
||||||
<KpiCard
|
icon={<IconPlayCircle />}
|
||||||
title="Avg. Price"
|
aria-label="Start now"
|
||||||
color="purple"
|
onClick={async () => {
|
||||||
value={`${
|
try {
|
||||||
!kpis.avgPriceOfListings
|
await xhrPost('/api/jobs/startAll', null);
|
||||||
? '---'
|
Toast.success('Successfully triggered Fredy search.');
|
||||||
: new Intl.NumberFormat('de-DE', {
|
} catch {
|
||||||
style: 'currency',
|
Toast.error('Failed to trigger search');
|
||||||
currency: 'EUR',
|
}
|
||||||
}).format(kpis.avgPriceOfListings)
|
}}
|
||||||
}`}
|
>
|
||||||
icon={<IconNoteMoney />}
|
Search now
|
||||||
description="Avg. Price of listings"
|
</Button>
|
||||||
/>
|
</KpiCard>
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
</SegmentPart>
|
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<SegmentPart
|
<Text className="dashboard__section-label">Overview</Text>
|
||||||
name="Provider Insights"
|
<Row gutter={[16, 16]} className="dashboard__row">
|
||||||
Icon={IconStar}
|
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
|
||||||
helpText="Percentage of found listings over all providers"
|
<KpiCard
|
||||||
className="dashboard__provider-insights"
|
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} />
|
<PieChartCard data={pieData} />
|
||||||
</SegmentPart>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,31 +3,32 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
||||||
&__row {
|
&__section-label {
|
||||||
margin-bottom: 24px;
|
display: block;
|
||||||
flex-wrap: wrap;
|
font-size: 11px !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
.semi-col {
|
text-transform: uppercase;
|
||||||
margin-bottom: 0; // Handled by Row gutter
|
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;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
margin: 0 !important;
|
justify-content: center;
|
||||||
|
|
||||||
.semi-card-body {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
max-height: 300px;
|
|
||||||
|
|
||||||
> * {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,31 +3,38 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* 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,
|
||||||
|
Typography,
|
||||||
|
AutoComplete,
|
||||||
|
Switch,
|
||||||
|
Banner,
|
||||||
|
} from '@douyinfe/semi-ui-19';
|
||||||
import { InputNumber } 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 { SegmentPart } from '../../components/segment/SegmentPart';
|
||||||
import { Banner, Toast } from '@douyinfe/semi-ui-19';
|
|
||||||
import {
|
import {
|
||||||
downloadBackup as downloadBackupZip,
|
downloadBackup as downloadBackupZip,
|
||||||
precheckRestore as clientPrecheckRestore,
|
precheckRestore as clientPrecheckRestore,
|
||||||
restore as clientRestore,
|
restore as clientRestore,
|
||||||
} from '../../services/backupRestoreClient';
|
} from '../../services/backupRestoreClient';
|
||||||
import {
|
import { IconSave, IconRefresh, IconSignal, IconHome, IconFolder } from '@douyinfe/semi-icons';
|
||||||
IconSave,
|
import debounce from 'lodash/debounce';
|
||||||
IconCalendar,
|
|
||||||
IconRefresh,
|
|
||||||
IconSignal,
|
|
||||||
IconLineChartStroked,
|
|
||||||
IconSearch,
|
|
||||||
IconFolder,
|
|
||||||
} from '@douyinfe/semi-icons';
|
|
||||||
import './GeneralSettings.less';
|
import './GeneralSettings.less';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
function formatFromTimestamp(ts) {
|
function formatFromTimestamp(ts) {
|
||||||
const date = new Date(ts);
|
const date = new Date(ts);
|
||||||
return `${date.getHours()}:${date.getMinutes() > 9 ? date.getMinutes() : '0' + date.getMinutes()}`;
|
return `${date.getHours()}:${date.getMinutes() > 9 ? date.getMinutes() : '0' + date.getMinutes()}`;
|
||||||
@@ -63,6 +70,14 @@ const GeneralSettings = function GeneralSettings() {
|
|||||||
const [restoreBusy, setRestoreBusy] = React.useState(false);
|
const [restoreBusy, setRestoreBusy] = React.useState(false);
|
||||||
const [selectedRestoreFile, setSelectedRestoreFile] = React.useState(null);
|
const [selectedRestoreFile, setSelectedRestoreFile] = React.useState(null);
|
||||||
|
|
||||||
|
// User settings state
|
||||||
|
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([]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
async function init() {
|
async function init() {
|
||||||
await actions.generalSettings.getGeneralSettings();
|
await actions.generalSettings.getGeneralSettings();
|
||||||
@@ -86,6 +101,11 @@ const GeneralSettings = function GeneralSettings() {
|
|||||||
init();
|
init();
|
||||||
}, [settings]);
|
}, [settings]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setAddress(homeAddress?.address || '');
|
||||||
|
setCoords(homeAddress?.coords || null);
|
||||||
|
}, [homeAddress]);
|
||||||
|
|
||||||
const nullOrEmpty = (val) => val == null || val.length === 0;
|
const nullOrEmpty = (val) => val == null || val.length === 0;
|
||||||
|
|
||||||
const handleStore = async () => {
|
const handleStore = async () => {
|
||||||
@@ -177,7 +197,6 @@ const GeneralSettings = function GeneralSettings() {
|
|||||||
if (!file) return;
|
if (!file) return;
|
||||||
setSelectedRestoreFile(file);
|
setSelectedRestoreFile(file);
|
||||||
await precheckRestore(file);
|
await precheckRestore(file);
|
||||||
// reset the input to allow same file re-select
|
|
||||||
ev.target.value = '';
|
ev.target.value = '';
|
||||||
},
|
},
|
||||||
[precheckRestore],
|
[precheckRestore],
|
||||||
@@ -189,180 +208,280 @@ 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 (
|
return (
|
||||||
<div>
|
<div className="generalSettings">
|
||||||
{!loading && (
|
{!loading && (
|
||||||
<React.Fragment>
|
<>
|
||||||
<div>
|
<Tabs type="line">
|
||||||
<SegmentPart
|
<TabPane
|
||||||
name="Interval"
|
tab={
|
||||||
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."
|
<span>
|
||||||
Icon={IconRefresh}
|
<IconSignal size="small" style={{ marginRight: 6 }} />
|
||||||
|
System
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
itemKey="system"
|
||||||
>
|
>
|
||||||
<InputNumber
|
<div className="generalSettings__tab-content">
|
||||||
min={5}
|
<SegmentPart name="Port" helpText="The port on which Fredy is running.">
|
||||||
max={1440}
|
<InputNumber
|
||||||
placeholder="Interval in minutes"
|
min={0}
|
||||||
value={interval}
|
max={99999}
|
||||||
formatter={(value) => `${value}`.replace(/\D/g, '')}
|
placeholder="Port"
|
||||||
onChange={(value) => setInterval(value)}
|
value={port}
|
||||||
suffix={'minutes'}
|
formatter={(value) => `${value}`.replace(/\D/g, '')}
|
||||||
/>
|
onChange={(value) => setPort(value)}
|
||||||
</SegmentPart>
|
style={{ maxWidth: 160 }}
|
||||||
<Divider margin="1rem" />
|
/>
|
||||||
<SegmentPart
|
</SegmentPart>
|
||||||
name="Backup & Restore"
|
|
||||||
helpText="Download a zipped backup of your database or restore it from a backup zip."
|
<SegmentPart
|
||||||
Icon={IconSave}
|
name="SQLite Database Path"
|
||||||
>
|
helpText="The directory where Fredy stores its SQLite database files."
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
>
|
||||||
<Button theme="solid" icon={<IconSave />} onClick={handleDownloadBackup}>
|
<Banner
|
||||||
Download backup
|
fullMode={false}
|
||||||
</Button>
|
type="warning"
|
||||||
<input
|
closeIcon={null}
|
||||||
type="file"
|
style={{ marginBottom: '12px' }}
|
||||||
accept=".zip,application/zip"
|
description="Changing this path may result in data loss. Restart Fredy immediately after saving."
|
||||||
ref={fileInputRef}
|
/>
|
||||||
style={{ display: 'none' }}
|
<Input
|
||||||
onChange={handleSelectRestoreFile}
|
type="text"
|
||||||
/>
|
placeholder="Database folder path"
|
||||||
<Button onClick={handleOpenFilePicker} theme="light" icon={<IconFolder />}>
|
value={sqlitePath}
|
||||||
Restore from zip
|
onChange={(value) => setSqlitePath(value)}
|
||||||
</Button>
|
/>
|
||||||
|
</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>
|
</div>
|
||||||
</SegmentPart>
|
</TabPane>
|
||||||
<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>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
<TabPane
|
||||||
type="text"
|
tab={
|
||||||
placeholder="Select folder"
|
<span>
|
||||||
value={sqlitePath}
|
<IconRefresh size="small" style={{ marginRight: 6 }} />
|
||||||
onChange={(value) => {
|
Execution
|
||||||
setSqlitePath(value);
|
</span>
|
||||||
}}
|
}
|
||||||
/>
|
itemKey="execution"
|
||||||
</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}
|
|
||||||
>
|
>
|
||||||
<div className="generalSettings__timePickerContainer">
|
<div className="generalSettings__tab-content">
|
||||||
<TimePicker
|
<SegmentPart
|
||||||
format={'HH:mm'}
|
name="Search Interval"
|
||||||
insetLabel="From"
|
helpText="Interval in minutes for running queries against configured services. Do not go below 5 minutes to avoid being detected as a bot."
|
||||||
value={formatFromTBackend(workingHourFrom)}
|
>
|
||||||
placeholder=""
|
<InputNumber
|
||||||
onChange={(val) => {
|
min={5}
|
||||||
setWorkingHourFrom(val == null ? null : formatFromTimestamp(val));
|
max={1440}
|
||||||
}}
|
placeholder="Interval in minutes"
|
||||||
/>
|
value={interval}
|
||||||
<TimePicker
|
formatter={(value) => `${value}`.replace(/\D/g, '')}
|
||||||
format={'HH:mm'}
|
onChange={(value) => setInterval(value)}
|
||||||
insetLabel="Until"
|
suffix={'minutes'}
|
||||||
value={formatFromTBackend(workingHourTo)}
|
style={{ maxWidth: 200 }}
|
||||||
placeholder=""
|
/>
|
||||||
onChange={(val) => {
|
</SegmentPart>
|
||||||
setWorkingHourTo(val == null ? null : formatFromTimestamp(val));
|
|
||||||
}}
|
<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>
|
</div>
|
||||||
</SegmentPart>
|
</TabPane>
|
||||||
<Divider margin="1rem" />
|
|
||||||
|
|
||||||
<SegmentPart name="Analytics" helpText="Insights into the usage of Fredy." Icon={IconLineChartStroked}>
|
<TabPane
|
||||||
<Banner
|
tab={
|
||||||
fullMode={false}
|
<span>
|
||||||
type="info"
|
<IconFolder size="small" style={{ marginRight: 6 }} />
|
||||||
closeIcon={null}
|
Backup & Restore
|
||||||
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>Explanation</div>}
|
</span>
|
||||||
style={{ marginBottom: '1rem' }}
|
}
|
||||||
description={
|
itemKey="backup"
|
||||||
<div>
|
>
|
||||||
Analytics are disabled by default. If you choose to enable them, we will begin tracking the
|
<div className="generalSettings__tab-content">
|
||||||
following:
|
<SegmentPart
|
||||||
<br />
|
name="Backup & Restore"
|
||||||
<ul>
|
helpText="Download a zipped backup of your database or restore from a backup zip."
|
||||||
<li>Name of active provider (e.g. Immoscout)</li>
|
>
|
||||||
<li>Name of active adapter (e.g. Console)</li>
|
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
<li>language</li>
|
<Button theme="solid" icon={<IconSave />} onClick={handleDownloadBackup}>
|
||||||
<li>os</li>
|
Download Backup
|
||||||
<li>node version</li>
|
</Button>
|
||||||
<li>arch</li>
|
<input
|
||||||
</ul>
|
type="file"
|
||||||
The data is sent anonymously and helps me understand which providers or adapters are being used the
|
accept=".zip,application/zip"
|
||||||
most. In the end it helps me to improve fredy.
|
ref={fileInputRef}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={handleSelectRestoreFile}
|
||||||
|
/>
|
||||||
|
<Button onClick={handleOpenFilePicker} theme="light" icon={<IconFolder />}>
|
||||||
|
Restore from Zip
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
}
|
</SegmentPart>
|
||||||
/>
|
</div>
|
||||||
|
</TabPane>
|
||||||
|
|
||||||
<Checkbox checked={analyticsEnabled} onChange={(e) => setAnalyticsEnabled(e.target.checked)}>
|
<TabPane
|
||||||
{' '}
|
tab={
|
||||||
Enabled
|
<span>
|
||||||
</Checkbox>
|
<IconHome size="small" style={{ marginRight: 6 }} />
|
||||||
</SegmentPart>
|
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>
|
||||||
|
|
||||||
<Divider margin="1rem" />
|
<SegmentPart
|
||||||
|
name="ImmoScout Details"
|
||||||
<SegmentPart name="Demo Mode" helpText="If enabled, Fredy runs in demo mode." Icon={IconSearch}>
|
helpText="Fetch additional details (description, attributes, agent info) for ImmoScout listings. Makes an extra API call per listing."
|
||||||
<Banner
|
>
|
||||||
fullMode={false}
|
<Banner
|
||||||
type="info"
|
type="warning"
|
||||||
closeIcon={null}
|
description="Enabling this significantly increases API requests to ImmoScout, raising the chance of rate limiting or blocking. Use at your own risk."
|
||||||
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>Explanation</div>}
|
closeIcon={null}
|
||||||
style={{ marginBottom: '1rem' }}
|
style={{ marginBottom: 12 }}
|
||||||
description={
|
/>
|
||||||
<div>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
In demo mode, Fredy will not (really) search for any real estates. Fredy is in a lockdown mode. Also
|
<Switch
|
||||||
all database files will be set back to the default values at midnight.
|
checked={!!immoscoutDetails}
|
||||||
|
onChange={async (checked) => {
|
||||||
|
try {
|
||||||
|
await actions.userSettings.setImmoscoutDetails(checked);
|
||||||
|
Toast.success('ImmoScout details setting updated.');
|
||||||
|
} catch {
|
||||||
|
Toast.error('Failed to update setting.');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text>Fetch detailed ImmoScout listings</Text>
|
||||||
</div>
|
</div>
|
||||||
}
|
</SegmentPart>
|
||||||
/>
|
|
||||||
|
|
||||||
<Checkbox checked={demoMode} onChange={(e) => setDemoMode(e.target.checked)}>
|
<div className="generalSettings__save-row">
|
||||||
{' '}
|
<Button
|
||||||
Enabled
|
icon={<IconSave />}
|
||||||
</Checkbox>
|
theme="solid"
|
||||||
</SegmentPart>
|
type="primary"
|
||||||
|
onClick={handleSaveUserSettings}
|
||||||
<Divider margin="1rem" />
|
loading={saving}
|
||||||
<Button type="primary" theme="solid" onClick={handleStore} icon={<IconSave />}>
|
>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</React.Fragment>
|
</div>
|
||||||
|
</TabPane>
|
||||||
|
</Tabs>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{restoreModalVisible && (
|
{restoreModalVisible && (
|
||||||
<Modal
|
<Modal
|
||||||
title="Restore database"
|
title="Restore database"
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
.generalSettings {
|
.generalSettings {
|
||||||
|
&__tab-content {
|
||||||
|
padding: 20px 0;
|
||||||
|
max-width: 860px;
|
||||||
|
}
|
||||||
|
|
||||||
&__timePickerContainer {
|
&__timePickerContainer {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__help {
|
&__save-row {
|
||||||
font-size: 11px;
|
margin-top: 1.5rem;
|
||||||
margin-left: 1rem;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,11 +158,11 @@ export default function NotificationAdapterMutator({
|
|||||||
{uiElement.type === 'boolean' ? (
|
{uiElement.type === 'boolean' ? (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||||
<Switch
|
<Switch
|
||||||
checked={uiElement.value || false}
|
checked={uiElement.value || false}
|
||||||
onChange={(checked) => {
|
onChange={(checked) => {
|
||||||
setValue(selectedAdapter, uiElement, key, checked);
|
setValue(selectedAdapter, uiElement, key, checked);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{uiElement.label}
|
{uiElement.label}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -173,6 +173,7 @@ export default function NotificationAdapterMutator({
|
|||||||
initValue={uiElement.value ?? ''}
|
initValue={uiElement.value ?? ''}
|
||||||
placeholder={uiElement.label}
|
placeholder={uiElement.label}
|
||||||
label={uiElement.label}
|
label={uiElement.label}
|
||||||
|
extraText={uiElement.description}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
setValue(selectedAdapter, uiElement, key, value);
|
setValue(selectedAdapter, uiElement, key, value);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -9,12 +9,13 @@ import maplibregl from 'maplibre-gl';
|
|||||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||||
import { useSelector, useActions } from '../../services/state/store.js';
|
import { useSelector, useActions } from '../../services/state/store.js';
|
||||||
import { distanceMeters, generateCircleCoords, getBoundsFromCenter, getBoundsFromCoords } from './mapUtils.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 { Select, Typography, Switch, Banner, Toast } from '@douyinfe/semi-ui-19';
|
||||||
import { IconFilter, IconLink } from '@douyinfe/semi-icons';
|
import { IconLink } from '@douyinfe/semi-icons';
|
||||||
import { IconDelete, IconEyeOpened } from '@douyinfe/semi-icons';
|
import { IconDelete, IconEyeOpened } from '@douyinfe/semi-icons';
|
||||||
|
|
||||||
import no_image from '../../assets/no_image.jpg';
|
import no_image from '../../assets/no_image.jpg';
|
||||||
import RangeSlider from 'react-range-slider-input';
|
import _RangeSlider from 'react-range-slider-input';
|
||||||
|
const RangeSlider = _RangeSlider?.default ?? _RangeSlider;
|
||||||
import 'react-range-slider-input/dist/style.css';
|
import 'react-range-slider-input/dist/style.css';
|
||||||
import './Map.less';
|
import './Map.less';
|
||||||
import { xhrDelete } from '../../services/xhr.js';
|
import { xhrDelete } from '../../services/xhr.js';
|
||||||
@@ -39,7 +40,6 @@ export default function MapView() {
|
|||||||
const jobs = useSelector((state) => state.jobsData.jobs);
|
const jobs = useSelector((state) => state.jobsData.jobs);
|
||||||
const [jobId, setJobId] = useState(null);
|
const [jobId, setJobId] = useState(null);
|
||||||
const [priceRange, setPriceRange] = useState([0, 0]);
|
const [priceRange, setPriceRange] = useState([0, 0]);
|
||||||
const [showFilterBar, setShowFilterBar] = useState(false);
|
|
||||||
const [distanceFilter, setDistanceFilter] = useState(0);
|
const [distanceFilter, setDistanceFilter] = useState(0);
|
||||||
|
|
||||||
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
||||||
@@ -92,10 +92,8 @@ export default function MapView() {
|
|||||||
};
|
};
|
||||||
}, [navigate]);
|
}, [navigate]);
|
||||||
|
|
||||||
// Get map instance reference after MapComponent renders
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mapContainer.current && !map.current) {
|
if (mapContainer.current && !map.current) {
|
||||||
// Wait for MapComponent to initialize the map
|
|
||||||
const checkMapReady = () => {
|
const checkMapReady = () => {
|
||||||
if (mapContainer.current?.map) {
|
if (mapContainer.current?.map) {
|
||||||
map.current = mapContainer.current.map;
|
map.current = mapContainer.current.map;
|
||||||
@@ -132,8 +130,6 @@ export default function MapView() {
|
|||||||
if (!map.current) return;
|
if (!map.current) return;
|
||||||
|
|
||||||
if (homeAddress?.coords) {
|
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) {
|
if (distanceFilter > 0) {
|
||||||
const bounds = getBoundsFromCenter([homeAddress.coords.lng, homeAddress.coords.lat], distanceFilter);
|
const bounds = getBoundsFromCenter([homeAddress.coords.lng, homeAddress.coords.lat], distanceFilter);
|
||||||
|
|
||||||
@@ -290,7 +286,7 @@ export default function MapView() {
|
|||||||
|
|
||||||
const popup = new maplibregl.Popup({ offset: 25 }).setHTML(popupContent);
|
const popup = new maplibregl.Popup({ offset: 25 }).setHTML(popupContent);
|
||||||
|
|
||||||
let color = '#3FB1CE'; // Default blue-ish
|
let color = '#3FB1CE';
|
||||||
if (distanceFilter > 0 && homeAddress?.coords) {
|
if (distanceFilter > 0 && homeAddress?.coords) {
|
||||||
const dist = distanceMeters(
|
const dist = distanceMeters(
|
||||||
homeAddress.coords.lat,
|
homeAddress.coords.lat,
|
||||||
@@ -315,114 +311,17 @@ export default function MapView() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="map-view-container">
|
<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 && (
|
{!homeAddress && (
|
||||||
<Banner
|
<Banner
|
||||||
fullMode={true}
|
fullMode={true}
|
||||||
type="warning"
|
type="warning"
|
||||||
bordered
|
bordered
|
||||||
closeIcon={null}
|
closeIcon={null}
|
||||||
|
style={{ marginBottom: '8px' }}
|
||||||
description={
|
description={
|
||||||
<span>
|
<span>
|
||||||
You have not set your home address yet. Please do so in the <Link to="/userSettings">user settings</Link>{' '}
|
No home address set. Configure it in <Link to="/userSettings">user settings</Link> to use the distance
|
||||||
to use the distance filter.
|
filter.
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -433,10 +332,103 @@ export default function MapView() {
|
|||||||
type="info"
|
type="info"
|
||||||
bordered
|
bordered
|
||||||
closeIcon={null}
|
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={(val) => setPriceRange(val)}
|
||||||
|
/>
|
||||||
|
</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) => setMapStyle(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
|
<ListingDeletionModal
|
||||||
visible={deleteModalVisible}
|
visible={deleteModalVisible}
|
||||||
onConfirm={confirmListingDeletion}
|
onConfirm={confirmListingDeletion}
|
||||||
|
|||||||
@@ -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
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -9,18 +9,48 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
|
||||||
|
|
||||||
.map-filter-bar {
|
&__map-wrapper {
|
||||||
margin-bottom: 1rem;
|
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 {
|
.map-container {
|
||||||
flex-grow: 1;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid var(--semi-color-border);
|
border: 1px solid #262a3a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-popup-content {
|
.map-popup-content {
|
||||||
@@ -126,7 +156,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Override MapLibre default popup styles to match application theme */
|
/* Override MapLibre default popup styles */
|
||||||
.maplibregl-popup-content {
|
.maplibregl-popup-content {
|
||||||
background-color: var(--semi-color-bg-1) !important;
|
background-color: var(--semi-color-bg-1) !important;
|
||||||
color: var(--semi-color-text-0) !important;
|
color: var(--semi-color-text-0) !important;
|
||||||
@@ -140,21 +170,26 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.map {
|
.map {
|
||||||
&__rangesliderLabels{
|
&__rangesliderLabels {
|
||||||
color: white;
|
color: #94a3b8;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: .3rem;
|
margin-bottom: .3rem;
|
||||||
font-size: .7rem;
|
font-size: .7rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.range-slider .range-slider__thumb {
|
.range-slider .range-slider__thumb {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
width: 16px;
|
width: 14px;
|
||||||
height: 16px;
|
height: 14px;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #2196f3;
|
background: #0ab5b3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range-slider .range-slider__range {
|
||||||
|
background: #0ab5b3;
|
||||||
}
|
}
|
||||||
208
yarn.lock
208
yarn.lock
@@ -1082,19 +1082,12 @@
|
|||||||
debug "^4.3.1"
|
debug "^4.3.1"
|
||||||
minimatch "^10.2.4"
|
minimatch "^10.2.4"
|
||||||
|
|
||||||
"@eslint/config-helpers@^0.5.2":
|
"@eslint/config-helpers@^0.5.3":
|
||||||
version "0.5.2"
|
version "0.5.3"
|
||||||
resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.5.2.tgz#314c7b03d02a371ad8c0a7f6821d5a8a8437ba9d"
|
resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.5.3.tgz#721fe6bbb90d74b0c80d6ff2428e5bbcb002becb"
|
||||||
integrity sha512-a5MxrdDXEvqnIq+LisyCX6tQMPF/dSJpCfBgBauY+pNZ28yCtSsTvyTYrMhaI+LK26bVyCJfJkT0u8KIj2i1dQ==
|
integrity sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@eslint/core" "^1.1.0"
|
"@eslint/core" "^1.1.1"
|
||||||
|
|
||||||
"@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":
|
"@eslint/core@^1.1.1":
|
||||||
version "1.1.1"
|
version "1.1.1"
|
||||||
@@ -1276,10 +1269,10 @@
|
|||||||
resolved "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-5.0.4.tgz"
|
resolved "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-5.0.4.tgz"
|
||||||
integrity sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==
|
integrity sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==
|
||||||
|
|
||||||
"@maplibre/geojson-vt@^6.0.2":
|
"@maplibre/geojson-vt@^6.0.4":
|
||||||
version "6.0.3"
|
version "6.0.4"
|
||||||
resolved "https://registry.yarnpkg.com/@maplibre/geojson-vt/-/geojson-vt-6.0.3.tgz#1167caa068468b39989e8be272e3200831de7bab"
|
resolved "https://registry.yarnpkg.com/@maplibre/geojson-vt/-/geojson-vt-6.0.4.tgz#f028fa633594c067b4c24030c1c282c0dd6cc835"
|
||||||
integrity sha512-tJ8df2SAIacER7pWTiSlDjIULBBAfZnzAURvWb1d8kVzx/pmSJcG0L2p0DTAB6nEu8Lmsx5zAc8JFDcs2DTwaw==
|
integrity sha512-HYv3POhMRCdhP3UPPATM/hfcy6/WuVIf5FKboH8u/ZuFMTnAIcSVlq5nfOqroLokd925w2QtE7YwquFOIacwVQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
kdbush "^4.0.2"
|
kdbush "^4.0.2"
|
||||||
|
|
||||||
@@ -1296,10 +1289,10 @@
|
|||||||
rw "^1.3.3"
|
rw "^1.3.3"
|
||||||
tinyqueue "^3.0.0"
|
tinyqueue "^3.0.0"
|
||||||
|
|
||||||
"@maplibre/mlt@^1.1.7":
|
"@maplibre/mlt@^1.1.8":
|
||||||
version "1.1.7"
|
version "1.1.8"
|
||||||
resolved "https://registry.yarnpkg.com/@maplibre/mlt/-/mlt-1.1.7.tgz#cb8d6ede486f5e48a33dd1f373fa5d908ce8062f"
|
resolved "https://registry.yarnpkg.com/@maplibre/mlt/-/mlt-1.1.8.tgz#ad1f7169197e5c64eace4f61c168dcd202076e03"
|
||||||
integrity sha512-HZSsXrgn2V6T3o0qklMwKERfKaAxjO8shmiFnVygCtXTg4SPKWVX+U99RkvxUfCsjYBEcT4ltor8lSlBSCca7Q==
|
integrity sha512-8vtfYGidr1rNkv5IwIoU2lfe3Oy+Wa8HluzQYcQi9cveU9K3pweAal/poQj4GJ0K/EW4bTQp2wVAs09g2yDRZg==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@mapbox/point-geometry" "^1.1.0"
|
"@mapbox/point-geometry" "^1.1.0"
|
||||||
|
|
||||||
@@ -1396,6 +1389,11 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.115.0.tgz#92a599543529bce45f8f2da77f40a124d63349dc"
|
resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.115.0.tgz#92a599543529bce45f8f2da77f40a124d63349dc"
|
||||||
integrity sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==
|
integrity sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==
|
||||||
|
|
||||||
|
"@oxc-project/types@=0.120.0":
|
||||||
|
version "0.120.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.120.0.tgz#af521b0e689dd0eaa04fe4feef9b68d98b74783d"
|
||||||
|
integrity sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg==
|
||||||
|
|
||||||
"@puppeteer/browsers@2.13.0":
|
"@puppeteer/browsers@2.13.0":
|
||||||
version "2.13.0"
|
version "2.13.0"
|
||||||
resolved "https://registry.yarnpkg.com/@puppeteer/browsers/-/browsers-2.13.0.tgz#10f980c6d65efeff77f8a3cac6e1a7ac10604500"
|
resolved "https://registry.yarnpkg.com/@puppeteer/browsers/-/browsers-2.13.0.tgz#10f980c6d65efeff77f8a3cac6e1a7ac10604500"
|
||||||
@@ -1414,66 +1412,133 @@
|
|||||||
resolved "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz"
|
resolved "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz"
|
||||||
integrity sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==
|
integrity sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==
|
||||||
|
|
||||||
|
"@rolldown/binding-android-arm64@1.0.0-rc.10":
|
||||||
|
version "1.0.0-rc.10"
|
||||||
|
resolved "https://registry.yarnpkg.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz#0bbd3380f49a6d0dc96c9b32fb7dad26ae0dfaa7"
|
||||||
|
integrity sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg==
|
||||||
|
|
||||||
"@rolldown/binding-android-arm64@1.0.0-rc.9":
|
"@rolldown/binding-android-arm64@1.0.0-rc.9":
|
||||||
version "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"
|
resolved "https://registry.yarnpkg.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz#4bbd28868564948c2bf04b3ca117a6828f95626c"
|
||||||
integrity sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==
|
integrity sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==
|
||||||
|
|
||||||
|
"@rolldown/binding-darwin-arm64@1.0.0-rc.10":
|
||||||
|
version "1.0.0-rc.10"
|
||||||
|
resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.10.tgz#a30b051784fbb13635e652ba4041c6ce7a4ce7ab"
|
||||||
|
integrity sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w==
|
||||||
|
|
||||||
"@rolldown/binding-darwin-arm64@1.0.0-rc.9":
|
"@rolldown/binding-darwin-arm64@1.0.0-rc.9":
|
||||||
version "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"
|
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==
|
integrity sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ==
|
||||||
|
|
||||||
|
"@rolldown/binding-darwin-x64@1.0.0-rc.10":
|
||||||
|
version "1.0.0-rc.10"
|
||||||
|
resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.10.tgz#2d9dea982d5be90b95b6d8836ff26a4b0959d94b"
|
||||||
|
integrity sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A==
|
||||||
|
|
||||||
"@rolldown/binding-darwin-x64@1.0.0-rc.9":
|
"@rolldown/binding-darwin-x64@1.0.0-rc.9":
|
||||||
version "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"
|
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==
|
integrity sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg==
|
||||||
|
|
||||||
|
"@rolldown/binding-freebsd-x64@1.0.0-rc.10":
|
||||||
|
version "1.0.0-rc.10"
|
||||||
|
resolved "https://registry.yarnpkg.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.10.tgz#4efc3aca43ae4dfb90729eeca6e84ef6e6b38c4a"
|
||||||
|
integrity sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w==
|
||||||
|
|
||||||
"@rolldown/binding-freebsd-x64@1.0.0-rc.9":
|
"@rolldown/binding-freebsd-x64@1.0.0-rc.9":
|
||||||
version "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"
|
resolved "https://registry.yarnpkg.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.9.tgz#35c29d7c83aa75429c74d7d1ee9c7d3e61f4552c"
|
||||||
integrity sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q==
|
integrity sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q==
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.10":
|
||||||
|
version "1.0.0-rc.10"
|
||||||
|
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.10.tgz#4a19a5d24537e925b25e9583b6cd575b2ad9fa27"
|
||||||
|
integrity sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA==
|
||||||
|
|
||||||
"@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.9":
|
"@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.9":
|
||||||
version "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"
|
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==
|
integrity sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ==
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-arm64-gnu@1.0.0-rc.10":
|
||||||
|
version "1.0.0-rc.10"
|
||||||
|
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.10.tgz#01a41e5e905838353ae9a3da10dc8242dcd61453"
|
||||||
|
integrity sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg==
|
||||||
|
|
||||||
"@rolldown/binding-linux-arm64-gnu@1.0.0-rc.9":
|
"@rolldown/binding-linux-arm64-gnu@1.0.0-rc.9":
|
||||||
version "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"
|
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==
|
integrity sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg==
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-arm64-musl@1.0.0-rc.10":
|
||||||
|
version "1.0.0-rc.10"
|
||||||
|
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.10.tgz#bd059e5f83471de29ce35b0ba254995d8091ca40"
|
||||||
|
integrity sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g==
|
||||||
|
|
||||||
"@rolldown/binding-linux-arm64-musl@1.0.0-rc.9":
|
"@rolldown/binding-linux-arm64-musl@1.0.0-rc.9":
|
||||||
version "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"
|
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.9.tgz#d3cbd1b1760d34b5789af89f4bcc09a1446d3eb5"
|
||||||
integrity sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==
|
integrity sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.10":
|
||||||
|
version "1.0.0-rc.10"
|
||||||
|
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.10.tgz#fe726a540631015f269a989c0cfb299283190390"
|
||||||
|
integrity sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w==
|
||||||
|
|
||||||
"@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.9":
|
"@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.9":
|
||||||
version "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"
|
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==
|
integrity sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-s390x-gnu@1.0.0-rc.10":
|
||||||
|
version "1.0.0-rc.10"
|
||||||
|
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.10.tgz#825ced028bad3f1fa9ce83b1f3dac76e0424367f"
|
||||||
|
integrity sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg==
|
||||||
|
|
||||||
"@rolldown/binding-linux-s390x-gnu@1.0.0-rc.9":
|
"@rolldown/binding-linux-s390x-gnu@1.0.0-rc.9":
|
||||||
version "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"
|
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==
|
integrity sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-x64-gnu@1.0.0-rc.10":
|
||||||
|
version "1.0.0-rc.10"
|
||||||
|
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.10.tgz#b700dae69274aa3d54a16ca5e00e30f47a089119"
|
||||||
|
integrity sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw==
|
||||||
|
|
||||||
"@rolldown/binding-linux-x64-gnu@1.0.0-rc.9":
|
"@rolldown/binding-linux-x64-gnu@1.0.0-rc.9":
|
||||||
version "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"
|
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==
|
integrity sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-x64-musl@1.0.0-rc.10":
|
||||||
|
version "1.0.0-rc.10"
|
||||||
|
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.10.tgz#eb875660ad68a2348acab36a7005699e87f6e9dd"
|
||||||
|
integrity sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA==
|
||||||
|
|
||||||
"@rolldown/binding-linux-x64-musl@1.0.0-rc.9":
|
"@rolldown/binding-linux-x64-musl@1.0.0-rc.9":
|
||||||
version "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"
|
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==
|
integrity sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==
|
||||||
|
|
||||||
|
"@rolldown/binding-openharmony-arm64@1.0.0-rc.10":
|
||||||
|
version "1.0.0-rc.10"
|
||||||
|
resolved "https://registry.yarnpkg.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.10.tgz#72aa24b412f83025087bcf83ce09634b2bd93c5c"
|
||||||
|
integrity sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q==
|
||||||
|
|
||||||
"@rolldown/binding-openharmony-arm64@1.0.0-rc.9":
|
"@rolldown/binding-openharmony-arm64@1.0.0-rc.9":
|
||||||
version "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"
|
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==
|
integrity sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==
|
||||||
|
|
||||||
|
"@rolldown/binding-wasm32-wasi@1.0.0-rc.10":
|
||||||
|
version "1.0.0-rc.10"
|
||||||
|
resolved "https://registry.yarnpkg.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.10.tgz#7f3303a96c5dc01d1f4c539b1dcbc16392c6f17d"
|
||||||
|
integrity sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA==
|
||||||
|
dependencies:
|
||||||
|
"@napi-rs/wasm-runtime" "^1.1.1"
|
||||||
|
|
||||||
"@rolldown/binding-wasm32-wasi@1.0.0-rc.9":
|
"@rolldown/binding-wasm32-wasi@1.0.0-rc.9":
|
||||||
version "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"
|
resolved "https://registry.yarnpkg.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.9.tgz#c3a82bef0ddd644efa74c050c26223f29f55039c"
|
||||||
@@ -1481,16 +1546,31 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@napi-rs/wasm-runtime" "^1.1.1"
|
"@napi-rs/wasm-runtime" "^1.1.1"
|
||||||
|
|
||||||
|
"@rolldown/binding-win32-arm64-msvc@1.0.0-rc.10":
|
||||||
|
version "1.0.0-rc.10"
|
||||||
|
resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.10.tgz#3419144a04ad12c69c48536b01fc21ac9d87ecf4"
|
||||||
|
integrity sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ==
|
||||||
|
|
||||||
"@rolldown/binding-win32-arm64-msvc@1.0.0-rc.9":
|
"@rolldown/binding-win32-arm64-msvc@1.0.0-rc.9":
|
||||||
version "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"
|
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==
|
integrity sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA==
|
||||||
|
|
||||||
|
"@rolldown/binding-win32-x64-msvc@1.0.0-rc.10":
|
||||||
|
version "1.0.0-rc.10"
|
||||||
|
resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.10.tgz#09bee46e6a32c6086beeabc3da12e67be714f882"
|
||||||
|
integrity sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w==
|
||||||
|
|
||||||
"@rolldown/binding-win32-x64-msvc@1.0.0-rc.9":
|
"@rolldown/binding-win32-x64-msvc@1.0.0-rc.9":
|
||||||
version "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"
|
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==
|
integrity sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ==
|
||||||
|
|
||||||
|
"@rolldown/pluginutils@1.0.0-rc.10":
|
||||||
|
version "1.0.0-rc.10"
|
||||||
|
resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.10.tgz#eed997f37f928a3300bbe2161f42687d8a3ae759"
|
||||||
|
integrity sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg==
|
||||||
|
|
||||||
"@rolldown/pluginutils@1.0.0-rc.7":
|
"@rolldown/pluginutils@1.0.0-rc.7":
|
||||||
version "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"
|
resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz#0414869467f0e471a6515d4f506c85fde867e022"
|
||||||
@@ -3415,15 +3495,15 @@ eslint-visitor-keys@^5.0.1:
|
|||||||
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz#9e3c9489697824d2d4ce3a8ad12628f91e9f59be"
|
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz#9e3c9489697824d2d4ce3a8ad12628f91e9f59be"
|
||||||
integrity sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==
|
integrity sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==
|
||||||
|
|
||||||
eslint@10.0.3:
|
eslint@10.1.0:
|
||||||
version "10.0.3"
|
version "10.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.0.3.tgz#360a7de7f2706eb8a32caa17ca983f0089efe694"
|
resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.1.0.tgz#9ca98e654e642ab2e1af6d1e9d8613857ac341b4"
|
||||||
integrity sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==
|
integrity sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@eslint-community/eslint-utils" "^4.8.0"
|
"@eslint-community/eslint-utils" "^4.8.0"
|
||||||
"@eslint-community/regexpp" "^4.12.2"
|
"@eslint-community/regexpp" "^4.12.2"
|
||||||
"@eslint/config-array" "^0.23.3"
|
"@eslint/config-array" "^0.23.3"
|
||||||
"@eslint/config-helpers" "^0.5.2"
|
"@eslint/config-helpers" "^0.5.3"
|
||||||
"@eslint/core" "^1.1.1"
|
"@eslint/core" "^1.1.1"
|
||||||
"@eslint/plugin-kit" "^0.6.1"
|
"@eslint/plugin-kit" "^0.6.1"
|
||||||
"@humanfs/node" "^0.16.6"
|
"@humanfs/node" "^0.16.6"
|
||||||
@@ -3436,7 +3516,7 @@ eslint@10.0.3:
|
|||||||
escape-string-regexp "^4.0.0"
|
escape-string-regexp "^4.0.0"
|
||||||
eslint-scope "^9.1.2"
|
eslint-scope "^9.1.2"
|
||||||
eslint-visitor-keys "^5.0.1"
|
eslint-visitor-keys "^5.0.1"
|
||||||
espree "^11.1.1"
|
espree "^11.2.0"
|
||||||
esquery "^1.7.0"
|
esquery "^1.7.0"
|
||||||
esutils "^2.0.2"
|
esutils "^2.0.2"
|
||||||
fast-deep-equal "^3.1.3"
|
fast-deep-equal "^3.1.3"
|
||||||
@@ -3451,7 +3531,7 @@ eslint@10.0.3:
|
|||||||
natural-compare "^1.4.0"
|
natural-compare "^1.4.0"
|
||||||
optionator "^0.9.3"
|
optionator "^0.9.3"
|
||||||
|
|
||||||
espree@^11.1.1:
|
espree@^11.2.0:
|
||||||
version "11.2.0"
|
version "11.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/espree/-/espree-11.2.0.tgz#01d5e47dc332aaba3059008362454a8cc34ccaa5"
|
resolved "https://registry.yarnpkg.com/espree/-/espree-11.2.0.tgz#01d5e47dc332aaba3059008362454a8cc34ccaa5"
|
||||||
integrity sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==
|
integrity sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==
|
||||||
@@ -4901,10 +4981,10 @@ make-dir@^2.1.0:
|
|||||||
pify "^4.0.1"
|
pify "^4.0.1"
|
||||||
semver "^5.6.0"
|
semver "^5.6.0"
|
||||||
|
|
||||||
maplibre-gl@^5.20.1:
|
maplibre-gl@^5.21.0:
|
||||||
version "5.20.1"
|
version "5.21.0"
|
||||||
resolved "https://registry.yarnpkg.com/maplibre-gl/-/maplibre-gl-5.20.1.tgz#6903c4085fd383bf2167edcb2b7fcc5f6dfbf4b1"
|
resolved "https://registry.yarnpkg.com/maplibre-gl/-/maplibre-gl-5.21.0.tgz#2d2bc81196d4b922a00c4cc7f90175f53eb7a2d3"
|
||||||
integrity sha512-57YIgfRct+rrk78ldoWRuLWRnXV/1vM2Rk0QYfEDQmsXdpgbACwvGoREIOZtyDIaq/GJK/ORYEriaAdVZuNfvw==
|
integrity sha512-n0v4J/Ge0EG8ix/z3TY3ragtJYMqzbtSnj1riOC0OwQbzwp0lUF2maS1ve1z8HhitQCKtZZiZJhb8to36aMMfQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@mapbox/jsonlint-lines-primitives" "^2.0.2"
|
"@mapbox/jsonlint-lines-primitives" "^2.0.2"
|
||||||
"@mapbox/point-geometry" "^1.1.0"
|
"@mapbox/point-geometry" "^1.1.0"
|
||||||
@@ -4912,9 +4992,9 @@ maplibre-gl@^5.20.1:
|
|||||||
"@mapbox/unitbezier" "^0.0.1"
|
"@mapbox/unitbezier" "^0.0.1"
|
||||||
"@mapbox/vector-tile" "^2.0.4"
|
"@mapbox/vector-tile" "^2.0.4"
|
||||||
"@mapbox/whoots-js" "^3.1.0"
|
"@mapbox/whoots-js" "^3.1.0"
|
||||||
"@maplibre/geojson-vt" "^6.0.2"
|
"@maplibre/geojson-vt" "^6.0.4"
|
||||||
"@maplibre/maplibre-gl-style-spec" "^24.7.0"
|
"@maplibre/maplibre-gl-style-spec" "^24.7.0"
|
||||||
"@maplibre/mlt" "^1.1.7"
|
"@maplibre/mlt" "^1.1.8"
|
||||||
"@maplibre/vt-pbf" "^4.3.0"
|
"@maplibre/vt-pbf" "^4.3.0"
|
||||||
"@types/geojson" "^7946.0.16"
|
"@types/geojson" "^7946.0.16"
|
||||||
earcut "^3.0.2"
|
earcut "^3.0.2"
|
||||||
@@ -5721,6 +5801,11 @@ node-releases@^2.0.27:
|
|||||||
resolved "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz"
|
resolved "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz"
|
||||||
integrity sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==
|
integrity sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==
|
||||||
|
|
||||||
|
nodemailer@^8.0.3:
|
||||||
|
version "8.0.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-8.0.3.tgz#6c5c10d3e70b8ca1b311646c4d03e1b206ef168c"
|
||||||
|
integrity sha512-JQNBqvK+bj3NMhUFR3wmCl3SYcOeMotDiwDBvIoCuQdF0PvlIY0BH+FJ2CG7u4cXKPChplE78oowlH/Otsc4ZQ==
|
||||||
|
|
||||||
nodemon@^3.1.14:
|
nodemon@^3.1.14:
|
||||||
version "3.1.14"
|
version "3.1.14"
|
||||||
resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-3.1.14.tgz#8487ca379c515301d221ec007f27f24ecafa2b51"
|
resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-3.1.14.tgz#8487ca379c515301d221ec007f27f24ecafa2b51"
|
||||||
@@ -6337,10 +6422,10 @@ punycode@^2.1.0:
|
|||||||
resolved "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz"
|
resolved "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz"
|
||||||
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
|
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
|
||||||
|
|
||||||
puppeteer-core@24.39.1:
|
puppeteer-core@24.40.0:
|
||||||
version "24.39.1"
|
version "24.40.0"
|
||||||
resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-24.39.1.tgz#7d155cc4b38b32ccb29b862e52b1f87e39704d6f"
|
resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-24.40.0.tgz#1f389cd9432cb077f703ca2cb6758490cdccbc7e"
|
||||||
integrity sha512-AMqQIKoEhPS6CilDzw0Gd1brLri3emkC+1N2J6ZCCuY1Cglo56M63S0jOeBZDQlemOiRd686MYVMl9ELJBzN3A==
|
integrity sha512-MWL3XbUCfVgGR0gRsidzT6oKJT2QydPLhMITU6HoVWiiv4gkb6gJi3pcdAa8q4HwjBTbqISOWVP4aJiiyUJvag==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@puppeteer/browsers" "2.13.0"
|
"@puppeteer/browsers" "2.13.0"
|
||||||
chromium-bidi "14.0.0"
|
chromium-bidi "14.0.0"
|
||||||
@@ -6397,16 +6482,16 @@ puppeteer-extra@^3.3.6:
|
|||||||
debug "^4.1.1"
|
debug "^4.1.1"
|
||||||
deepmerge "^4.2.2"
|
deepmerge "^4.2.2"
|
||||||
|
|
||||||
puppeteer@^24.39.1:
|
puppeteer@^24.40.0:
|
||||||
version "24.39.1"
|
version "24.40.0"
|
||||||
resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-24.39.1.tgz#5ca7db71508a35d00d4a3b23a9c117ea2022efc3"
|
resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-24.40.0.tgz#6df6aeee9dabf29bed3bb2be5c209d00518d4a79"
|
||||||
integrity sha512-68Zc9QpcVvfxp2C+3UL88TyUogEAn5tSylXidbEuEXvhiqK1+v65zeBU5ubinAgEHMGr3dcSYqvYrGtdzsPI3w==
|
integrity sha512-IxQbDq93XHVVLWHrAkFP7F7iHvb9o0mgfsSIMlhHb+JM+JjM1V4v4MNSQfcRWJopx9dsNOr9adYv0U5fm9BJBQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@puppeteer/browsers" "2.13.0"
|
"@puppeteer/browsers" "2.13.0"
|
||||||
chromium-bidi "14.0.0"
|
chromium-bidi "14.0.0"
|
||||||
cosmiconfig "^9.0.0"
|
cosmiconfig "^9.0.0"
|
||||||
devtools-protocol "0.0.1581282"
|
devtools-protocol "0.0.1581282"
|
||||||
puppeteer-core "24.39.1"
|
puppeteer-core "24.40.0"
|
||||||
typed-query-selector "^2.12.1"
|
typed-query-selector "^2.12.1"
|
||||||
|
|
||||||
qs@^6.14.0:
|
qs@^6.14.0:
|
||||||
@@ -6800,6 +6885,30 @@ robust-predicates@^3.0.2:
|
|||||||
resolved "https://registry.yarnpkg.com/robust-predicates/-/robust-predicates-3.0.2.tgz#d5b28528c4824d20fc48df1928d41d9efa1ad771"
|
resolved "https://registry.yarnpkg.com/robust-predicates/-/robust-predicates-3.0.2.tgz#d5b28528c4824d20fc48df1928d41d9efa1ad771"
|
||||||
integrity sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==
|
integrity sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==
|
||||||
|
|
||||||
|
rolldown@1.0.0-rc.10:
|
||||||
|
version "1.0.0-rc.10"
|
||||||
|
resolved "https://registry.yarnpkg.com/rolldown/-/rolldown-1.0.0-rc.10.tgz#41c55e52d833c52c90131973047250548e35f2bf"
|
||||||
|
integrity sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA==
|
||||||
|
dependencies:
|
||||||
|
"@oxc-project/types" "=0.120.0"
|
||||||
|
"@rolldown/pluginutils" "1.0.0-rc.10"
|
||||||
|
optionalDependencies:
|
||||||
|
"@rolldown/binding-android-arm64" "1.0.0-rc.10"
|
||||||
|
"@rolldown/binding-darwin-arm64" "1.0.0-rc.10"
|
||||||
|
"@rolldown/binding-darwin-x64" "1.0.0-rc.10"
|
||||||
|
"@rolldown/binding-freebsd-x64" "1.0.0-rc.10"
|
||||||
|
"@rolldown/binding-linux-arm-gnueabihf" "1.0.0-rc.10"
|
||||||
|
"@rolldown/binding-linux-arm64-gnu" "1.0.0-rc.10"
|
||||||
|
"@rolldown/binding-linux-arm64-musl" "1.0.0-rc.10"
|
||||||
|
"@rolldown/binding-linux-ppc64-gnu" "1.0.0-rc.10"
|
||||||
|
"@rolldown/binding-linux-s390x-gnu" "1.0.0-rc.10"
|
||||||
|
"@rolldown/binding-linux-x64-gnu" "1.0.0-rc.10"
|
||||||
|
"@rolldown/binding-linux-x64-musl" "1.0.0-rc.10"
|
||||||
|
"@rolldown/binding-openharmony-arm64" "1.0.0-rc.10"
|
||||||
|
"@rolldown/binding-wasm32-wasi" "1.0.0-rc.10"
|
||||||
|
"@rolldown/binding-win32-arm64-msvc" "1.0.0-rc.10"
|
||||||
|
"@rolldown/binding-win32-x64-msvc" "1.0.0-rc.10"
|
||||||
|
|
||||||
rolldown@1.0.0-rc.9:
|
rolldown@1.0.0-rc.9:
|
||||||
version "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"
|
resolved "https://registry.yarnpkg.com/rolldown/-/rolldown-1.0.0-rc.9.tgz#5a0d3e194f2bcc7a134870b174042fcaed463689"
|
||||||
@@ -7764,7 +7873,20 @@ vfile@^6.0.0:
|
|||||||
"@types/unist" "^3.0.0"
|
"@types/unist" "^3.0.0"
|
||||||
vfile-message "^4.0.0"
|
vfile-message "^4.0.0"
|
||||||
|
|
||||||
vite@8.0.0, "vite@^6.0.0 || ^7.0.0 || ^8.0.0-0":
|
vite@8.0.1:
|
||||||
|
version "8.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/vite/-/vite-8.0.1.tgz#015cef9a747c07c0cf9cf553f37571885504e9d3"
|
||||||
|
integrity sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw==
|
||||||
|
dependencies:
|
||||||
|
lightningcss "^1.32.0"
|
||||||
|
picomatch "^4.0.3"
|
||||||
|
postcss "^8.5.8"
|
||||||
|
rolldown "1.0.0-rc.10"
|
||||||
|
tinyglobby "^0.2.15"
|
||||||
|
optionalDependencies:
|
||||||
|
fsevents "~2.3.3"
|
||||||
|
|
||||||
|
"vite@^6.0.0 || ^7.0.0 || ^8.0.0-0":
|
||||||
version "8.0.0"
|
version "8.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/vite/-/vite-8.0.0.tgz#d749f9bf5be196635982bc16ec0c6faf2b31f3a4"
|
resolved "https://registry.yarnpkg.com/vite/-/vite-8.0.0.tgz#d749f9bf5be196635982bc16ec0c6faf2b31f3a4"
|
||||||
integrity sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==
|
integrity sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==
|
||||||
|
|||||||
Reference in New Issue
Block a user