mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a815c92e6 | ||
|
|
cef9b5c8fc | ||
|
|
1e2476a375 | ||
|
|
78b762bd9e | ||
|
|
3e5cd97400 | ||
|
|
5cfa674d7f | ||
|
|
5bd4219743 | ||
|
|
ea24eb4374 | ||
|
|
9f67e30ff4 |
@@ -1,7 +1,47 @@
|
||||
# Dependencies (will be installed fresh in container)
|
||||
node_modules/
|
||||
npm-debug.log
|
||||
test/
|
||||
|
||||
# Database and config (mounted as volumes)
|
||||
db/
|
||||
conf/
|
||||
|
||||
# Git
|
||||
.git/
|
||||
.github/
|
||||
.gitignore
|
||||
|
||||
# IDE and editor
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
|
||||
# Testing
|
||||
test/
|
||||
|
||||
# Documentation
|
||||
doc/
|
||||
*.md
|
||||
!README.md
|
||||
|
||||
# Development config files
|
||||
.babelrc
|
||||
.husky/
|
||||
.nvmrc
|
||||
.prettierrc
|
||||
.prettierignore
|
||||
eslint.config.js
|
||||
|
||||
# Docker files (not needed inside container)
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
docker-test.sh
|
||||
.dockerignore
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log
|
||||
|
||||
# Build artifacts (built fresh in container)
|
||||
dist/
|
||||
|
||||
66
Dockerfile
66
Dockerfile
@@ -1,27 +1,58 @@
|
||||
FROM node:22-slim
|
||||
# ================================
|
||||
# Stage 1: Build stage
|
||||
# ================================
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Install build dependencies needed for native modules (better-sqlite3)
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
# Copy package files first for better layer caching
|
||||
COPY package.json yarn.lock ./
|
||||
|
||||
# Install all dependencies (including devDependencies for building)
|
||||
RUN yarn config set network-timeout 600000 \
|
||||
&& yarn --frozen-lockfile
|
||||
|
||||
# Copy source files needed for build
|
||||
COPY index.html vite.config.js ./
|
||||
COPY ui ./ui
|
||||
COPY lib ./lib
|
||||
|
||||
# Build frontend assets
|
||||
RUN yarn build:frontend
|
||||
|
||||
# ================================
|
||||
# Stage 2: Production stage
|
||||
# ================================
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /fredy
|
||||
|
||||
# Install Chromium and curl without extra recommended packages and clean apt cache
|
||||
# curl is needed for the health check
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends chromium curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
# Install Chromium and curl (for healthcheck)
|
||||
# Using Alpine's chromium package which is much smaller
|
||||
RUN apk add --no-cache chromium curl
|
||||
|
||||
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
|
||||
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
|
||||
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
|
||||
|
||||
# Copy lockfiles first to leverage cache for dependencies
|
||||
COPY package.json yarn.lock .
|
||||
# Install build dependencies for native modules, then remove them after yarn install
|
||||
COPY package.json yarn.lock ./
|
||||
|
||||
# Set Yarn timeout, install dependencies and PM2 globally
|
||||
RUN yarn config set network-timeout 600000 \
|
||||
&& yarn --frozen-lockfile \
|
||||
&& yarn global add pm2
|
||||
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 application source and build production assets
|
||||
COPY . .
|
||||
RUN yarn build:frontend
|
||||
# Copy built frontend from builder stage
|
||||
COPY --from=builder /build/ui/public ./ui/public
|
||||
|
||||
# Copy application source (only what's needed at runtime)
|
||||
COPY index.js ./
|
||||
COPY index.html ./
|
||||
COPY lib ./lib
|
||||
|
||||
# Prepare runtime directories and symlinks for data and config
|
||||
RUN mkdir -p /db /conf \
|
||||
@@ -34,5 +65,4 @@ EXPOSE 9998
|
||||
VOLUME /db
|
||||
VOLUME /conf
|
||||
|
||||
# Start application using PM2 runtime
|
||||
CMD ["pm2-runtime", "index.js"]
|
||||
CMD ["node", "index.js"]
|
||||
|
||||
@@ -107,6 +107,10 @@ yarn run start:frontend # in another terminal
|
||||
|
||||
👉 Open <http://localhost:9998>
|
||||
|
||||
### With Unraid
|
||||
|
||||
Should you use [Unraid](https://unraid.net/), you can now install Fredy from the community store :)
|
||||
|
||||
**Default Login:**
|
||||
- Username: `admin`
|
||||
- Password: `admin`
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"interval":"60","port":9998,"workingHours":{"from":"","to":""},"demoMode":false,"analyticsEnabled":true,"sqlitepath":"/db"}
|
||||
{"sqlitepath":"/db"}
|
||||
BIN
doc/unraid_fredy_logo.png
Normal file
BIN
doc/unraid_fredy_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 417 KiB |
@@ -1,22 +1,24 @@
|
||||
services:
|
||||
fredy:
|
||||
container_name: fredy
|
||||
# build from empty build folder to reduce size of image
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: ghcr.io/orangecoding/fredy
|
||||
# map existing config and database
|
||||
volumes:
|
||||
- ./conf:/conf
|
||||
- ./db:/db
|
||||
ports:
|
||||
- "9998:9998"
|
||||
restart: unless-stopped
|
||||
# Resource limits to prevent runaway memory usage from Chromium
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 1G
|
||||
healthcheck:
|
||||
# The container will immediately stop when health check fails after retries
|
||||
test: ["CMD-SHELL", "curl --fail --silent --show-error --max-time 5 http://localhost:9998/ || exit 1"]
|
||||
test: ["CMD", "curl", "--fail", "--silent", "--show-error", "--max-time", "5", "http://localhost:9998/"]
|
||||
interval: 120s
|
||||
timeout: 10s
|
||||
retries: 1
|
||||
start_period: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
32
index.js
32
index.js
@@ -1,6 +1,6 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { checkIfConfigIsAccessible, config, getProviders, refreshConfig } from './lib/utils.js';
|
||||
import { checkIfConfigIsAccessible, getProviders, refreshConfig } from './lib/utils.js';
|
||||
import * as similarityCache from './lib/services/similarity-check/similarityCache.js';
|
||||
import * as jobStorage from './lib/services/storage/jobStorage.js';
|
||||
import FredyPipeline from './lib/FredyPipeline.js';
|
||||
@@ -12,28 +12,34 @@ import { initTrackerCron } from './lib/services/crons/tracker-cron.js';
|
||||
import logger from './lib/services/logger.js';
|
||||
import { bus } from './lib/services/events/event-bus.js';
|
||||
import { initActiveCheckerCron } from './lib/services/crons/listing-alive-cron.js';
|
||||
import { getSettings } from './lib/services/storage/settingsStorage.js';
|
||||
import SqliteConnection from './lib/services/storage/SqliteConnection.js';
|
||||
|
||||
//in the config, we store the path of the sqlite file, thus we must check if it is available
|
||||
const isConfigAccessible = await checkIfConfigIsAccessible();
|
||||
await SqliteConnection.init();
|
||||
|
||||
// Load configuration before any other startup steps
|
||||
await refreshConfig();
|
||||
|
||||
const isConfigAccessible = await checkIfConfigIsAccessible();
|
||||
|
||||
if (!isConfigAccessible) {
|
||||
logger.error('Configuration exists, but is not accessible. Please check the file permission');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Run DB migrations once at startup and block until finished
|
||||
await runMigrations();
|
||||
|
||||
const settings = await getSettings();
|
||||
|
||||
// Ensure sqlite directory exists before loading anything else (based on config.sqlitepath)
|
||||
const rawDir = config.sqlitepath || '/db';
|
||||
const rawDir = settings.sqlitepath || '/db';
|
||||
const relDir = rawDir.startsWith('/') ? rawDir.slice(1) : rawDir;
|
||||
const absDir = path.isAbsolute(relDir) ? relDir : path.join(process.cwd(), relDir);
|
||||
if (!fs.existsSync(absDir)) {
|
||||
fs.mkdirSync(absDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Run DB migrations once at startup and block until finished
|
||||
await runMigrations();
|
||||
|
||||
// Load provider modules once at startup
|
||||
const providers = await getProviders();
|
||||
|
||||
@@ -41,17 +47,17 @@ similarityCache.initSimilarityCache();
|
||||
similarityCache.startSimilarityCacheReloader();
|
||||
|
||||
//assuming interval is always in minutes
|
||||
const INTERVAL = config.interval * 60 * 1000;
|
||||
const INTERVAL = settings.interval * 60 * 1000;
|
||||
|
||||
// Initialize API only after migrations completed
|
||||
await import('./lib/api/api.js');
|
||||
|
||||
if (config.demoMode) {
|
||||
if (settings.demoMode) {
|
||||
logger.info('Running in demo mode');
|
||||
cleanupDemoAtMidnight();
|
||||
}
|
||||
|
||||
logger.info(`Started Fredy successfully. Ui can be accessed via http://localhost:${config.port}`);
|
||||
logger.info(`Started Fredy successfully. Ui can be accessed via http://localhost:${settings.port}`);
|
||||
|
||||
ensureAdminUserExists();
|
||||
ensureDemoUserExists();
|
||||
@@ -65,10 +71,10 @@ bus.on('jobs:runAll', () => {
|
||||
});
|
||||
|
||||
const execute = () => {
|
||||
const isDuringWorkingHoursOrNotSet = duringWorkingHoursOrNotSet(config, Date.now());
|
||||
if (!config.demoMode) {
|
||||
const isDuringWorkingHoursOrNotSet = duringWorkingHoursOrNotSet(settings, Date.now());
|
||||
if (!settings.demoMode) {
|
||||
if (isDuringWorkingHoursOrNotSet) {
|
||||
config.lastRun = Date.now();
|
||||
settings.lastRun = Date.now();
|
||||
jobStorage
|
||||
.getJobs()
|
||||
.filter((job) => job.enabled)
|
||||
|
||||
@@ -7,7 +7,6 @@ import { versionRouter } from './routes/versionRouter.js';
|
||||
import { loginRouter } from './routes/loginRoute.js';
|
||||
import { userRouter } from './routes/userRoute.js';
|
||||
import { jobRouter } from './routes/jobRouter.js';
|
||||
import { config } from '../utils.js';
|
||||
import bodyParser from 'body-parser';
|
||||
import restana from 'restana';
|
||||
import files from 'serve-static';
|
||||
@@ -16,9 +15,11 @@ import { getDirName } from '../utils.js';
|
||||
import { demoRouter } from './routes/demoRouter.js';
|
||||
import logger from '../services/logger.js';
|
||||
import { listingsRouter } from './routes/listingsRouter.js';
|
||||
import { getSettings } from '../services/storage/settingsStorage.js';
|
||||
import { featureRouter } from './routes/featureRouter.js';
|
||||
const service = restana();
|
||||
const staticService = files(path.join(getDirName(), '../ui/public'));
|
||||
const PORT = config.port || 9998;
|
||||
const PORT = (await getSettings()).port || 9998;
|
||||
|
||||
service.use(bodyParser.json());
|
||||
service.use(cookieSession());
|
||||
@@ -39,6 +40,7 @@ service.use('/api/version', versionRouter);
|
||||
service.use('/api/jobs', jobRouter);
|
||||
service.use('/api/login', loginRouter);
|
||||
service.use('/api/listings', listingsRouter);
|
||||
service.use('/api/features', featureRouter);
|
||||
//this route is unsecured intentionally as it is being queried from the login page
|
||||
service.use('/api/demo', demoRouter);
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import restana from 'restana';
|
||||
import { config } from '../../utils.js';
|
||||
import { getSettings } from '../../services/storage/settingsStorage.js';
|
||||
const service = restana();
|
||||
const demoRouter = service.newRouter();
|
||||
|
||||
demoRouter.get('/', async (req, res) => {
|
||||
res.body = Object.assign({}, { demoMode: config.demoMode });
|
||||
const settings = await getSettings();
|
||||
res.body = Object.assign({}, { demoMode: settings.demoMode });
|
||||
res.send();
|
||||
});
|
||||
|
||||
|
||||
12
lib/api/routes/featureRouter.js
Normal file
12
lib/api/routes/featureRouter.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import restana from 'restana';
|
||||
import getFeatures from '../../features.js';
|
||||
const service = restana();
|
||||
const featureRouter = service.newRouter();
|
||||
|
||||
featureRouter.get('/', async (req, res) => {
|
||||
const features = getFeatures();
|
||||
res.body = Object.assign({}, { features });
|
||||
res.send();
|
||||
});
|
||||
|
||||
export { featureRouter };
|
||||
@@ -1,24 +1,30 @@
|
||||
import restana from 'restana';
|
||||
import { config, getDirName, readConfigFromStorage, refreshConfig } from '../../utils.js';
|
||||
import { getDirName } from '../../utils.js';
|
||||
import fs from 'fs';
|
||||
import { ensureDemoUserExists } from '../../services/storage/userStorage.js';
|
||||
import logger from '../../services/logger.js';
|
||||
import { getSettings, upsertSettings } from '../../services/storage/settingsStorage.js';
|
||||
const service = restana();
|
||||
const generalSettingsRouter = service.newRouter();
|
||||
|
||||
generalSettingsRouter.get('/', async (req, res) => {
|
||||
res.body = Object.assign({}, config);
|
||||
res.body = Object.assign({}, await getSettings());
|
||||
res.send();
|
||||
});
|
||||
generalSettingsRouter.post('/', async (req, res) => {
|
||||
const settings = req.body;
|
||||
const { sqlitepath, ...appSettings } = req.body || {};
|
||||
const localSettings = await getSettings();
|
||||
|
||||
if (localSettings.demoMode) {
|
||||
res.send(new Error('In demo mode, it is not allowed to change these settings.'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (config.demoMode) {
|
||||
res.send(new Error('In demo mode, it is not allowed to change these settings.'));
|
||||
return;
|
||||
if (typeof sqlitepath !== 'undefined') {
|
||||
fs.writeFileSync(`${getDirName()}/../conf/config.json`, JSON.stringify({ sqlitepath }));
|
||||
}
|
||||
const currentConfig = await readConfigFromStorage();
|
||||
fs.writeFileSync(`${getDirName()}/../conf/config.json`, JSON.stringify({ ...currentConfig, ...settings }));
|
||||
await refreshConfig();
|
||||
upsertSettings(appSettings);
|
||||
ensureDemoUserExists();
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import restana from 'restana';
|
||||
import * as jobStorage from '../../services/storage/jobStorage.js';
|
||||
import * as userStorage from '../../services/storage/userStorage.js';
|
||||
import { config } from '../../utils.js';
|
||||
import { isAdmin } from '../security.js';
|
||||
import logger from '../../services/logger.js';
|
||||
import { bus } from '../../services/events/event-bus.js';
|
||||
import { getSettings } from '../../services/storage/settingsStorage.js';
|
||||
|
||||
const service = restana();
|
||||
const jobRouter = service.newRouter();
|
||||
@@ -44,9 +44,10 @@ jobRouter.get('/', async (req, res) => {
|
||||
});
|
||||
|
||||
jobRouter.get('/processingTimes', async (req, res) => {
|
||||
const settings = await getSettings();
|
||||
res.body = {
|
||||
interval: config.interval,
|
||||
lastRun: config.lastRun || null,
|
||||
interval: settings.interval,
|
||||
lastRun: settings.lastRun || null,
|
||||
};
|
||||
res.send();
|
||||
});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import restana from 'restana';
|
||||
import * as userStorage from '../../services/storage/userStorage.js';
|
||||
import * as hasher from '../../services/security/hash.js';
|
||||
import { config } from '../../utils.js';
|
||||
import { trackDemoAccessed } from '../../services/tracking/Tracker.js';
|
||||
import logger from '../../services/logger.js';
|
||||
import { getSettings } from '../../services/storage/settingsStorage.js';
|
||||
const service = restana();
|
||||
const loginRouter = service.newRouter();
|
||||
loginRouter.get('/user', async (req, res) => {
|
||||
@@ -20,6 +20,7 @@ loginRouter.get('/user', async (req, res) => {
|
||||
res.send();
|
||||
});
|
||||
loginRouter.post('/', async (req, res) => {
|
||||
const settings = await getSettings();
|
||||
const { username, password } = req.body;
|
||||
const user = userStorage.getUsers(true).find((user) => user.username === username);
|
||||
if (user == null) {
|
||||
@@ -27,7 +28,7 @@ loginRouter.post('/', async (req, res) => {
|
||||
return;
|
||||
}
|
||||
if (user.password === hasher.hash(password)) {
|
||||
if (config.demoMode) {
|
||||
if (settings.demoMode) {
|
||||
await trackDemoAccessed();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import restana from 'restana';
|
||||
import * as userStorage from '../../services/storage/userStorage.js';
|
||||
import * as jobStorage from '../../services/storage/jobStorage.js';
|
||||
import { config } from '../../utils.js';
|
||||
import { getSettings } from '../../services/storage/settingsStorage.js';
|
||||
const service = restana();
|
||||
const userRouter = service.newRouter();
|
||||
function checkIfAnyAdminAfterRemovingUser(userIdToBeRemoved, allUser) {
|
||||
@@ -23,7 +23,8 @@ userRouter.get('/:userId', async (req, res) => {
|
||||
res.send();
|
||||
});
|
||||
userRouter.delete('/', async (req, res) => {
|
||||
if (config.demoMode) {
|
||||
const settings = await getSettings();
|
||||
if (settings.demoMode) {
|
||||
res.send(new Error('In demo mode, it is not allowed to remove user.'));
|
||||
return;
|
||||
}
|
||||
@@ -44,7 +45,8 @@ userRouter.delete('/', async (req, res) => {
|
||||
res.send();
|
||||
});
|
||||
userRouter.post('/', async (req, res) => {
|
||||
if (config.demoMode) {
|
||||
const settings = await getSettings();
|
||||
if (settings.demoMode) {
|
||||
res.send(new Error('In demo mode, it is not allowed to change or add user.'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
export const DEFAULT_CONFIG = {
|
||||
interval: '60',
|
||||
port: 9998,
|
||||
workingHours: { from: '', to: '' },
|
||||
demoMode: false,
|
||||
analyticsEnabled: null,
|
||||
// Default path for sqlite storage directory. Interpreted relative to project root.
|
||||
sqlitepath: '/db',
|
||||
};
|
||||
|
||||
9
lib/features.js
Normal file
9
lib/features.js
Normal file
@@ -0,0 +1,9 @@
|
||||
const FEATURES = {
|
||||
WATCHLIST_MANAGEMENT: false,
|
||||
};
|
||||
|
||||
export default function getFeatures() {
|
||||
return {
|
||||
...FEATURES,
|
||||
};
|
||||
}
|
||||
45
lib/provider/ohneMakler.js
Executable file
45
lib/provider/ohneMakler.js
Executable file
@@ -0,0 +1,45 @@
|
||||
import { isOneOf, buildHash } from '../utils.js';
|
||||
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||
let appliedBlackList = [];
|
||||
|
||||
function normalize(o) {
|
||||
const link = metaInformation.baseUrl + o.link;
|
||||
const id = buildHash(o.title, o.link, o.price);
|
||||
return Object.assign(o, { link, id });
|
||||
}
|
||||
function applyBlacklist(o) {
|
||||
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
||||
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
||||
return titleNotBlacklisted && descNotBlacklisted;
|
||||
}
|
||||
const config = {
|
||||
url: null,
|
||||
crawlContainer: 'div[data-livecomponent-id*="search/property_list"] .grid > div',
|
||||
sortByDateParam: null,
|
||||
waitForSelector: null,
|
||||
crawlFields: {
|
||||
id: 'a@href',
|
||||
title: 'h4 | removeNewline | trim',
|
||||
price: '.text-xl | trim',
|
||||
size: 'div[title="Wohnfläche"] | trim',
|
||||
address: '.text-slate-800 | removeNewline | trim',
|
||||
image: 'img@src',
|
||||
link: 'a@href',
|
||||
},
|
||||
normalize: normalize,
|
||||
filter: applyBlacklist,
|
||||
activeTester: checkIfListingIsActive,
|
||||
};
|
||||
|
||||
export const init = (sourceConfig, blacklist) => {
|
||||
config.enabled = sourceConfig.enabled;
|
||||
config.url = sourceConfig.url;
|
||||
appliedBlackList = blacklist || [];
|
||||
};
|
||||
|
||||
export const metaInformation = {
|
||||
name: 'OhneMakler',
|
||||
baseUrl: 'https://www.ohne-makler.net/immobilien',
|
||||
id: 'ohneMakler',
|
||||
};
|
||||
export { config };
|
||||
@@ -1,8 +1,8 @@
|
||||
import { removeJobsByUserId } from '../storage/jobStorage.js';
|
||||
import { config } from '../../utils.js';
|
||||
import { getUsers } from '../storage/userStorage.js';
|
||||
import logger from '../logger.js';
|
||||
import cron from 'node-cron';
|
||||
import { getSettings } from '../storage/settingsStorage.js';
|
||||
|
||||
/**
|
||||
* if we are running in demo environment, we have to cleanup the db files (specifically the jobs table)
|
||||
@@ -11,12 +11,13 @@ export function cleanupDemoAtMidnight() {
|
||||
cron.schedule('0 0 * * *', cleanup);
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (config.demoMode) {
|
||||
async function cleanup() {
|
||||
const settings = await getSettings();
|
||||
if (settings.demoMode) {
|
||||
const demoUser = getUsers(false).find((user) => user.username === 'demo');
|
||||
if (demoUser == null) {
|
||||
logger.error('Demo user not found, cannot remove Jobs');
|
||||
return;
|
||||
return Promise.resolve();
|
||||
}
|
||||
removeJobsByUserId(demoUser.id);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import cron from 'node-cron';
|
||||
import { config, inDevMode } from '../../utils.js';
|
||||
import { inDevMode } from '../../utils.js';
|
||||
import { trackMainEvent } from '../tracking/Tracker.js';
|
||||
import { getSettings } from '../storage/settingsStorage.js';
|
||||
|
||||
async function runTask() {
|
||||
const settings = await getSettings();
|
||||
//make sure to only send tracking events if the user gave us the green light and we are not in dev mode
|
||||
if (config.analyticsEnabled && !inDevMode()) {
|
||||
if (settings.analyticsEnabled && !inDevMode()) {
|
||||
await trackMainEvent();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
import Database from 'better-sqlite3';
|
||||
import logger from '../../services/logger.js';
|
||||
import { config } from '../../utils.js';
|
||||
import { readConfigFromStorage } from '../../utils.js';
|
||||
|
||||
/**
|
||||
* SqliteConnection
|
||||
@@ -25,6 +25,15 @@ import { config } from '../../utils.js';
|
||||
class SqliteConnection {
|
||||
static #db = null;
|
||||
|
||||
static #sqlLiteCfg = null;
|
||||
|
||||
static async init() {
|
||||
if (this.#sqlLiteCfg == null) {
|
||||
readConfigFromStorage().then((c) => {
|
||||
this.#sqlLiteCfg = c.sqlitepath;
|
||||
});
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Returns a singleton instance of better-sqlite3 Database.
|
||||
* Respects env var SQLITE_DB_PATH and defaults to db/listings.db.
|
||||
@@ -32,9 +41,12 @@ class SqliteConnection {
|
||||
static getConnection() {
|
||||
if (this.#db) return this.#db;
|
||||
|
||||
if (this.#sqlLiteCfg == null) {
|
||||
logger.warn('No sqlitepath configured. Using default db/listings.db');
|
||||
}
|
||||
|
||||
// Interpret config.sqlitepath as a directory relative to project root when it starts with '/'
|
||||
const cfg = typeof config === 'object' && config ? config.sqlitepath : undefined;
|
||||
const rawDir = cfg && cfg.length > 0 ? cfg : '/db';
|
||||
const rawDir = this.#sqlLiteCfg && this.#sqlLiteCfg.length > 0 ? this.#sqlLiteCfg : '/db';
|
||||
const relDir = rawDir.startsWith('/') ? rawDir.slice(1) : rawDir;
|
||||
const absDir = path.isAbsolute(relDir) ? relDir : path.join(process.cwd(), relDir);
|
||||
const dbPath = path.join(absDir, 'listings.db');
|
||||
|
||||
79
lib/services/storage/migrations/sql/6.settings.js
Normal file
79
lib/services/storage/migrations/sql/6.settings.js
Normal file
@@ -0,0 +1,79 @@
|
||||
// Migration: Adding a settings table to store important (config) settings instead of using config file
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { nanoid } from 'nanoid';
|
||||
import logger from '../../../logger.js';
|
||||
import { DEFAULT_CONFIG } from '../../../../defaultConfig.js';
|
||||
import { getDirName } from '../../../../utils.js';
|
||||
|
||||
export function up(db) {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS settings
|
||||
(
|
||||
id TEXT PRIMARY KEY,
|
||||
create_date INTEGER NOT NULL,
|
||||
user_id TEXT,
|
||||
name TEXT NOT NULL,
|
||||
value jsonb NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_settings_name ON settings (name);
|
||||
`);
|
||||
|
||||
// Helper to insert one setting row
|
||||
const insertSetting = (name, rawValue) => {
|
||||
try {
|
||||
const id = nanoid();
|
||||
const createDate = Date.now();
|
||||
const value = JSON.stringify(rawValue);
|
||||
db.prepare(
|
||||
`INSERT INTO settings (id, create_date, name, value)
|
||||
VALUES (@id, @create_date, @name, @value)`,
|
||||
).run({ id, create_date: createDate, name, value });
|
||||
} catch {
|
||||
// Ignore duplicate inserts if any (unique by name)
|
||||
}
|
||||
};
|
||||
|
||||
// Migrate currently existing config.json into settings
|
||||
try {
|
||||
const configPath = path.resolve(process.cwd(), 'conf', 'config.json');
|
||||
|
||||
// Defaults
|
||||
const defaults = {
|
||||
interval: '60',
|
||||
port: 9998,
|
||||
workingHours: { from: '', to: '' },
|
||||
demoMode: false,
|
||||
analyticsEnabled: true,
|
||||
};
|
||||
|
||||
let config = {};
|
||||
if (fs.existsSync(configPath)) {
|
||||
const file = fs.readFileSync(configPath, 'utf8');
|
||||
try {
|
||||
config = JSON.parse(file) || {};
|
||||
} catch (parseErr) {
|
||||
// If parsing fails, still proceed with defaults
|
||||
logger.error(parseErr);
|
||||
config = {};
|
||||
}
|
||||
}
|
||||
|
||||
// Insert each known setting, using the value from config when present, otherwise default
|
||||
insertSetting('interval', config.interval != null ? config.interval : defaults.interval);
|
||||
insertSetting('port', config.port != null ? config.port : defaults.port);
|
||||
insertSetting('workingHours', config.workingHours != null ? config.workingHours : defaults.workingHours);
|
||||
insertSetting('demoMode', config.demoMode != null ? config.demoMode : defaults.demoMode);
|
||||
insertSetting(
|
||||
'analyticsEnabled',
|
||||
config.analyticsEnabled != null ? config.analyticsEnabled : defaults.analyticsEnabled,
|
||||
);
|
||||
|
||||
//now making sure only sqlite path remains in the config
|
||||
const sqlitepath = config.sqlitepath || DEFAULT_CONFIG.sqlitepath;
|
||||
fs.writeFileSync(`${getDirName()}/../conf/config.json`, JSON.stringify({ sqlitepath }));
|
||||
} catch (e) {
|
||||
logger.error(e);
|
||||
}
|
||||
}
|
||||
87
lib/services/storage/settingsStorage.js
Normal file
87
lib/services/storage/settingsStorage.js
Normal file
@@ -0,0 +1,87 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
import SqliteConnection from './SqliteConnection.js';
|
||||
import { fromJson, readConfigFromStorage, toJson } from '../../utils.js';
|
||||
|
||||
// In-memory cache for compiled settings config
|
||||
/** @type {Record<string, any>|null} */
|
||||
let cachedSettingsConfig = null;
|
||||
|
||||
/**
|
||||
* Build a config object from DB rows of settings.
|
||||
* - Unwraps stored shape { value: any } into raw values.
|
||||
* - Add additional config values from file config. E.g. sqlite part cannot be stored in db for obvious reasons ;)
|
||||
* @param {{name:string, value:string|null}[]} rows
|
||||
* @param {{name:value}} configValues
|
||||
* @returns {Record<string, any>}
|
||||
*/
|
||||
function compileSettings(rows, configValues) {
|
||||
const config = {};
|
||||
for (const r of rows) {
|
||||
const parsed = fromJson(r.value, null);
|
||||
// unwrap { value: any } if present
|
||||
config[r.name] = parsed && typeof parsed === 'object' && 'value' in parsed ? parsed.value : parsed;
|
||||
}
|
||||
return {
|
||||
...configValues,
|
||||
...config,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Force reload the settings config cache from DB and return it.
|
||||
* @returns {Record<string, any>}
|
||||
*/
|
||||
export async function refreshSettingsCache() {
|
||||
const rows = SqliteConnection.query(`SELECT name, value FROM settings`);
|
||||
const configValues = await readConfigFromStorage();
|
||||
cachedSettingsConfig = compileSettings(rows, configValues);
|
||||
return cachedSettingsConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the compiled settings config. Loads it once and caches the result.
|
||||
* @returns {Record<string, any>}
|
||||
*/
|
||||
export async function getSettings() {
|
||||
if (cachedSettingsConfig == null) {
|
||||
return refreshSettingsCache();
|
||||
}
|
||||
return cachedSettingsConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert settings rows.
|
||||
* - Accepts an object map of name -> value, or an entry {name, value}.
|
||||
* - id: random string (nanoid) when inserting
|
||||
* - create_date: epoch ms when inserting
|
||||
* - name: unique key
|
||||
* - value: JSON string of the raw value (no wrapper)
|
||||
* @param {Record<string, any>|{name:string, value:any}|[string, any][]} settingsMapOrEntry
|
||||
* @returns {void}
|
||||
*/
|
||||
// Upsert one or more settings by name. Accepts either a single pair or an object map.
|
||||
// Preferred usage: upsertSettings({ settingName: any, another: any })
|
||||
export function upsertSettings(settingsMapOrEntry, userId = null) {
|
||||
const entries = Array.isArray(settingsMapOrEntry)
|
||||
? settingsMapOrEntry
|
||||
: typeof settingsMapOrEntry === 'object' &&
|
||||
settingsMapOrEntry != null &&
|
||||
'name' in settingsMapOrEntry &&
|
||||
'value' in settingsMapOrEntry
|
||||
? [[settingsMapOrEntry.name, settingsMapOrEntry.value]]
|
||||
: Object.entries(settingsMapOrEntry || {});
|
||||
|
||||
for (const [name, rawValue] of entries) {
|
||||
const id = nanoid();
|
||||
const create_date = Date.now();
|
||||
const json = toJson(rawValue);
|
||||
SqliteConnection.execute(
|
||||
`INSERT INTO settings (id, create_date, name, value, user_id)
|
||||
VALUES (@id, @create_date, @name, @value, @userId)
|
||||
ON CONFLICT(name) DO UPDATE SET value = excluded.value`,
|
||||
{ id, create_date, name, value: json, userId },
|
||||
);
|
||||
}
|
||||
// keep cache in sync
|
||||
refreshSettingsCache();
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { config } from '../../utils.js';
|
||||
import * as hasher from '../security/hash.js';
|
||||
import { nanoid } from 'nanoid';
|
||||
import SqliteConnection from './SqliteConnection.js';
|
||||
import { getSettings } from './settingsStorage.js';
|
||||
|
||||
/**
|
||||
* Get all users.
|
||||
@@ -129,8 +129,9 @@ export const removeUser = (userId) => {
|
||||
* Security: The demo user's password is set to a known value ('demo') and should only be enabled in demoMode.
|
||||
* @returns {void}
|
||||
*/
|
||||
export const ensureDemoUserExists = () => {
|
||||
if (!config.demoMode) {
|
||||
export const ensureDemoUserExists = async () => {
|
||||
const settings = await getSettings();
|
||||
if (!settings.demoMode) {
|
||||
// Remove demo user (and cascade delete their jobs/listings)
|
||||
SqliteConnection.execute(`DELETE FROM users WHERE username = 'demo'`);
|
||||
return;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { getJobs } from '../storage/jobStorage.js';
|
||||
import { getUniqueId } from './uniqueId.js';
|
||||
import { config, getPackageVersion, inDevMode } from '../../utils.js';
|
||||
import { getPackageVersion, inDevMode } from '../../utils.js';
|
||||
import os from 'os';
|
||||
import fetch from 'node-fetch';
|
||||
import logger from '../logger.js';
|
||||
import { getSettings } from '../storage/settingsStorage.js';
|
||||
|
||||
const deviceId = getUniqueId() || 'N/A';
|
||||
const version = await getPackageVersion();
|
||||
@@ -11,7 +12,8 @@ const FREDY_TRACKING_URL = 'https://fredy.orange-coding.net/tracking';
|
||||
|
||||
export const trackMainEvent = async () => {
|
||||
try {
|
||||
if (config.analyticsEnabled && !inDevMode()) {
|
||||
const settings = await getSettings();
|
||||
if (settings.analyticsEnabled && !inDevMode()) {
|
||||
const activeProvider = new Set();
|
||||
const activeAdapter = new Set();
|
||||
|
||||
@@ -44,7 +46,8 @@ export const trackMainEvent = async () => {
|
||||
* Note, this will only be used when Fredy runs in demo mode
|
||||
*/
|
||||
export async function trackDemoAccessed() {
|
||||
if (config.analyticsEnabled && !inDevMode() && config.demoMode) {
|
||||
const settings = await getSettings();
|
||||
if (settings.analyticsEnabled && !inDevMode() && settings.demoMode) {
|
||||
try {
|
||||
await fetch(`${FREDY_TRACKING_URL}/demo/accessed`, {
|
||||
method: 'POST',
|
||||
@@ -56,7 +59,8 @@ export async function trackDemoAccessed() {
|
||||
}
|
||||
}
|
||||
|
||||
function enrichTrackingObject(trackingObject) {
|
||||
async function enrichTrackingObject(trackingObject) {
|
||||
const settings = await getSettings();
|
||||
const operatingSystem = os.platform();
|
||||
const osVersion = os.release();
|
||||
const arch = process.arch;
|
||||
@@ -65,7 +69,7 @@ function enrichTrackingObject(trackingObject) {
|
||||
|
||||
return {
|
||||
...trackingObject,
|
||||
isDemo: config.demoMode,
|
||||
isDemo: settings.demoMode,
|
||||
operatingSystem,
|
||||
osVersion,
|
||||
arch,
|
||||
|
||||
@@ -215,10 +215,6 @@ export async function refreshConfig() {
|
||||
|
||||
try {
|
||||
config = await readConfigFromStorage();
|
||||
//backwards compatibility...
|
||||
config.analyticsEnabled ??= null;
|
||||
config.demoMode ??= false;
|
||||
// default sqlitepath when missing in older configs
|
||||
config.sqlitepath ??= '/db';
|
||||
} catch (error) {
|
||||
config = { ...DEFAULT_CONFIG };
|
||||
@@ -306,7 +302,6 @@ export {
|
||||
getDirName,
|
||||
sleep,
|
||||
randomBetween,
|
||||
config,
|
||||
buildHash,
|
||||
getPackageVersion,
|
||||
toJson,
|
||||
|
||||
26
package.json
26
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "fredy",
|
||||
"version": "14.3.4",
|
||||
"version": "15.1.1",
|
||||
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
||||
"scripts": {
|
||||
"prepare": "husky",
|
||||
@@ -57,14 +57,14 @@
|
||||
"Firefox ESR"
|
||||
],
|
||||
"dependencies": {
|
||||
"@douyinfe/semi-icons": "^2.88.3",
|
||||
"@douyinfe/semi-ui": "2.88.3",
|
||||
"@douyinfe/semi-icons": "^2.89.0",
|
||||
"@douyinfe/semi-ui": "2.89.0",
|
||||
"@sendgrid/mail": "8.1.6",
|
||||
"@visactor/react-vchart": "^2.0.9",
|
||||
"@visactor/vchart": "^2.0.9",
|
||||
"@visactor/react-vchart": "^2.0.10",
|
||||
"@visactor/vchart": "^2.0.10",
|
||||
"@visactor/vchart-semi-theme": "^1.12.2",
|
||||
"@vitejs/plugin-react": "5.1.1",
|
||||
"better-sqlite3": "^12.4.6",
|
||||
"@vitejs/plugin-react": "5.1.2",
|
||||
"better-sqlite3": "^12.5.0",
|
||||
"body-parser": "2.2.1",
|
||||
"cheerio": "^1.1.2",
|
||||
"cookie-session": "2.1.1",
|
||||
@@ -76,21 +76,21 @@
|
||||
"node-mailjet": "6.0.11",
|
||||
"p-throttle": "^8.1.0",
|
||||
"package-up": "^5.0.0",
|
||||
"puppeteer": "^24.31.0",
|
||||
"puppeteer": "^24.32.1",
|
||||
"puppeteer-extra": "^3.3.6",
|
||||
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
||||
"query-string": "9.3.1",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-router": "7.9.6",
|
||||
"react-router-dom": "7.9.6",
|
||||
"react-router": "7.10.1",
|
||||
"react-router-dom": "7.10.1",
|
||||
"restana": "5.1.0",
|
||||
"semver": "^7.7.3",
|
||||
"serve-static": "2.2.0",
|
||||
"slack": "11.0.2",
|
||||
"vite": "7.2.4",
|
||||
"vite": "7.2.7",
|
||||
"x-var": "^3.0.1",
|
||||
"zustand": "^5.0.8"
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.28.5",
|
||||
@@ -108,6 +108,6 @@
|
||||
"lint-staged": "16.2.7",
|
||||
"mocha": "11.7.5",
|
||||
"nodemon": "^3.1.11",
|
||||
"prettier": "3.7.1"
|
||||
"prettier": "3.7.4"
|
||||
}
|
||||
}
|
||||
|
||||
33
test/provider/ohneMakler.test.js
Normal file
33
test/provider/ohneMakler.test.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||
import { get } from '../mocks/mockNotification.js';
|
||||
import { mockFredy, providerConfig } from '../utils.js';
|
||||
import { expect } from 'chai';
|
||||
import * as provider from '../../lib/provider/ohneMakler.js';
|
||||
|
||||
describe('#ohneMakler testsuite()', () => {
|
||||
it('should test ohneMakler provider', async () => {
|
||||
const Fredy = await mockFredy();
|
||||
provider.init(providerConfig.ohneMakler, []);
|
||||
|
||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'ohneMakler', similarityCache);
|
||||
const listing = await fredy.execute();
|
||||
|
||||
expect(listing).to.be.a('array');
|
||||
const notificationObj = get();
|
||||
expect(notificationObj).to.be.a('object');
|
||||
expect(notificationObj.serviceName).to.equal('ohneMakler');
|
||||
notificationObj.payload.forEach((notify) => {
|
||||
/** check the actual structure **/
|
||||
expect(notify.id).to.be.a('string');
|
||||
expect(notify.price).to.be.a('string');
|
||||
expect(notify.size).to.be.a('string');
|
||||
expect(notify.title).to.be.a('string');
|
||||
expect(notify.link).to.be.a('string');
|
||||
expect(notify.address).to.be.a('string');
|
||||
/** check the values if possible **/
|
||||
expect(notify.size).that.does.include('m²');
|
||||
expect(notify.title).to.be.not.empty;
|
||||
expect(notify.address).to.be.not.empty;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,10 @@
|
||||
"url": "https://www.mcmakler.de/immobilien/results?placeId=62649&search=Leipzig%252C+Sachsen&propertyTypes=APARTMENT&page=0",
|
||||
"enabled": true
|
||||
},
|
||||
"ohneMakler": {
|
||||
"url": "https://www.ohne-makler.net/immobilien/wohnung-kaufen/nordrhein-westfalen/dusseldorf/",
|
||||
"enabled": true
|
||||
},
|
||||
"neubauKompass": {
|
||||
"url": "https://www.neubaukompass.de/neubau-immobilien/duesseldorf-region/eigentumswohnung/",
|
||||
"enabled": true
|
||||
|
||||
@@ -21,6 +21,7 @@ import Navigation from './components/navigation/Navigation.jsx';
|
||||
import { Layout } from '@douyinfe/semi-ui';
|
||||
import FredyFooter from './components/footer/FredyFooter.jsx';
|
||||
import ProcessingTimes from './views/jobs/ProcessingTimes.jsx';
|
||||
import WatchlistManagement from './views/listings/management/WatchlistManagement.jsx';
|
||||
|
||||
export default function FredyApp() {
|
||||
const actions = useActions();
|
||||
@@ -34,6 +35,7 @@ export default function FredyApp() {
|
||||
async function init() {
|
||||
await actions.user.getCurrentUser();
|
||||
if (!needsLogin()) {
|
||||
await actions.features.getFeatures();
|
||||
await actions.provider.getProvider();
|
||||
await actions.jobs.getJobs();
|
||||
await actions.jobs.getProcessingTimes();
|
||||
@@ -91,6 +93,7 @@ export default function FredyApp() {
|
||||
<Route path="/jobs/insights/:jobId" element={<JobInsight />} />
|
||||
<Route path="/jobs" element={<Jobs />} />
|
||||
<Route path="/listings" element={<Listings />} />
|
||||
<Route path="/watchlistManagement" element={<WatchlistManagement />} />
|
||||
|
||||
{/* Permission-aware routes */}
|
||||
<Route
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import React from 'react';
|
||||
import { Nav } from '@douyinfe/semi-ui';
|
||||
import { IconUser, IconStar, IconSetting, IconTerminal } from '@douyinfe/semi-icons';
|
||||
import { IconStar, IconSetting, IconTerminal } from '@douyinfe/semi-icons';
|
||||
import logoWhite from '../../assets/logo_white.png';
|
||||
import Logout from '../logout/Logout.jsx';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import './Navigate.less';
|
||||
import { useScreenWidth } from '../../hooks/screenWidth.js';
|
||||
import { useFeature } from '../../hooks/featureHook.js';
|
||||
|
||||
export default function Navigation({ isAdmin }) {
|
||||
const navigate = useNavigate();
|
||||
@@ -14,15 +15,28 @@ export default function Navigation({ isAdmin }) {
|
||||
|
||||
const width = useScreenWidth();
|
||||
const collapsed = width <= 850;
|
||||
const watchlistFeature = useFeature('WATCHLIST_MANAGEMENT') || false;
|
||||
|
||||
const items = [
|
||||
{ itemKey: '/jobs', text: 'Jobs', icon: <IconTerminal /> },
|
||||
{ itemKey: '/listings', text: 'Found Listings', icon: <IconStar /> },
|
||||
{ itemKey: '/listings', text: 'Listings', icon: <IconStar /> },
|
||||
];
|
||||
|
||||
if (isAdmin) {
|
||||
items.push({ itemKey: '/users', text: 'User Management', icon: <IconUser /> });
|
||||
items.push({ itemKey: '/generalSettings', text: 'General Settings', icon: <IconSetting /> });
|
||||
const settingsItems = [
|
||||
{ itemKey: '/users', text: 'User Management' },
|
||||
{ itemKey: '/generalSettings', text: 'General Settings' },
|
||||
];
|
||||
if (watchlistFeature) {
|
||||
settingsItems.push({ itemKey: '/watchlistManagement', text: 'Watchlist Management' });
|
||||
}
|
||||
|
||||
items.push({
|
||||
itemKey: 'settings',
|
||||
text: 'Settings',
|
||||
icon: <IconSetting />,
|
||||
items: settingsItems,
|
||||
});
|
||||
}
|
||||
|
||||
function parsePathName(name) {
|
||||
@@ -32,7 +46,7 @@ export default function Navigation({ isAdmin }) {
|
||||
|
||||
return (
|
||||
<Nav
|
||||
style={{ height: '100%', width: collapsed ? '' : '13rem' }}
|
||||
style={{ height: '100%', width: collapsed ? '' : '13.2rem' }}
|
||||
items={items}
|
||||
isCollapsed={collapsed}
|
||||
selectedKeys={[parsePathName(location.pathname)]}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { Card, Checkbox, Descriptions, Divider, Select } from '@douyinfe/semi-ui';
|
||||
import React from 'react';
|
||||
import { useSelector } from '../../../services/state/store.js';
|
||||
import { Typography } from '@douyinfe/semi-ui';
|
||||
|
||||
import './ListingsFilter.less';
|
||||
|
||||
export default function ListingsFilter({ onWatchListFilter, onActivityFilter, onJobNameFilter, onProviderFilter }) {
|
||||
const jobs = useSelector((state) => state.jobs.jobs);
|
||||
const provider = useSelector((state) => state.provider);
|
||||
const { Title } = Typography;
|
||||
return (
|
||||
<Card className="listingsFilter">
|
||||
<Title heading={6}>Filter by:</Title>
|
||||
<Divider />
|
||||
<br />
|
||||
<Descriptions row>
|
||||
<Descriptions.Item itemKey="Watch List">
|
||||
<Checkbox onChange={(e) => onWatchListFilter(e.target.checked)}>Only Watch List</Checkbox>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item itemKey="Activity status">
|
||||
<Checkbox onChange={(e) => onActivityFilter(e.target.checked)}>Only Active Listings</Checkbox>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item itemKey="Job Name">
|
||||
<Select showClear placeholder="Select Job to Filter" onChange={(val) => onJobNameFilter(val)}>
|
||||
{jobs != null &&
|
||||
jobs.length > 0 &&
|
||||
jobs.map((job) => {
|
||||
return (
|
||||
<Select.Option value={job.id} key={job.id}>
|
||||
{job.name}
|
||||
</Select.Option>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item itemKey="Provider">
|
||||
<Select showClear placeholder="Select Provider to Filter" onChange={(val) => onProviderFilter(val)}>
|
||||
{provider != null &&
|
||||
provider.length > 0 &&
|
||||
provider.map((prov) => {
|
||||
return (
|
||||
<Select.Option value={prov.id} key={prov.id}>
|
||||
{prov.name}
|
||||
</Select.Option>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
.listingsFilter {
|
||||
margin-bottom: 1rem;
|
||||
background: rgb(53, 54, 60);
|
||||
}
|
||||
@@ -1,5 +1,18 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Table, Popover, Input, Descriptions, Tag, Image, Empty, Button, Toast, Divider } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
Table,
|
||||
Popover,
|
||||
Input,
|
||||
Descriptions,
|
||||
Tag,
|
||||
Image,
|
||||
Empty,
|
||||
Button,
|
||||
Toast,
|
||||
Divider,
|
||||
Space,
|
||||
Select,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { useActions, useSelector } from '../../../services/state/store.js';
|
||||
import { IconClose, IconDelete, IconSearch, IconStar, IconStarStroked, IconTick } from '@douyinfe/semi-icons';
|
||||
import * as timeService from '../../../services/time/timeService.js';
|
||||
@@ -10,166 +23,224 @@ import './ListingsTable.less';
|
||||
import { format } from '../../../services/time/timeService.js';
|
||||
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
||||
import { xhrDelete, xhrPost } from '../../../services/xhr.js';
|
||||
import ListingsFilter from './ListingsFilter.jsx';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useFeature } from '../../../hooks/featureHook.js';
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: 'Watchlist',
|
||||
width: 110,
|
||||
dataIndex: 'isWatched',
|
||||
sorter: true,
|
||||
render: (id, row) => {
|
||||
return (
|
||||
<div>
|
||||
<Popover
|
||||
style={{
|
||||
padding: '.4rem',
|
||||
color: 'var(--semi-color-white)',
|
||||
}}
|
||||
content={row.isWatched === 1 ? 'Unwatch Listing' : 'Watch Listing'}
|
||||
>
|
||||
<Button
|
||||
icon={
|
||||
row.isWatched === 1 ? (
|
||||
<IconStar style={{ color: 'rgba(var(--semi-green-5), 1)' }} />
|
||||
) : (
|
||||
<IconStarStroked />
|
||||
)
|
||||
}
|
||||
theme="borderless"
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await xhrPost('/api/listings/watch', { listingId: row.id });
|
||||
Toast.success(row.isWatched === 1 ? 'Listing removed from Watchlist' : 'Listing added to Watchlist');
|
||||
row.reloadTable();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
Toast.error('Failed to operate Watchlist');
|
||||
}
|
||||
const getColumns = (provider, setProviderFilter, jobs, setJobNameFilter) => {
|
||||
return [
|
||||
{
|
||||
title: 'Watchlist',
|
||||
width: 133,
|
||||
dataIndex: 'isWatched',
|
||||
sorter: true,
|
||||
filters: [
|
||||
{
|
||||
text: 'Show only watched listings',
|
||||
value: 'watchList',
|
||||
},
|
||||
],
|
||||
render: (id, row) => {
|
||||
return (
|
||||
<div>
|
||||
<Popover
|
||||
style={{
|
||||
padding: '.4rem',
|
||||
color: 'var(--semi-color-white)',
|
||||
}}
|
||||
/>
|
||||
</Popover>
|
||||
<Divider layout="vertical" margin="4px" />
|
||||
<Popover
|
||||
style={{
|
||||
padding: '.4rem',
|
||||
color: 'var(--semi-color-white)',
|
||||
}}
|
||||
content="Delete Listing"
|
||||
>
|
||||
<Button
|
||||
icon={<IconDelete />}
|
||||
theme="borderless"
|
||||
size="small"
|
||||
type="danger"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await xhrDelete('/api/listings/', { ids: [row.id] });
|
||||
Toast.success('Listing(s) successfully removed');
|
||||
row.reloadTable();
|
||||
} catch (error) {
|
||||
Toast.error(error);
|
||||
content={row.isWatched === 1 ? 'Unwatch Listing' : 'Watch Listing'}
|
||||
>
|
||||
<Button
|
||||
icon={
|
||||
row.isWatched === 1 ? (
|
||||
<IconStar style={{ color: 'rgba(var(--semi-green-5), 1)' }} />
|
||||
) : (
|
||||
<IconStarStroked />
|
||||
)
|
||||
}
|
||||
theme="borderless"
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await xhrPost('/api/listings/watch', { listingId: row.id });
|
||||
Toast.success(
|
||||
row.isWatched === 1 ? 'Listing removed from Watchlist' : 'Listing added to Watchlist',
|
||||
);
|
||||
row.reloadTable();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
Toast.error('Failed to operate Watchlist');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Popover>
|
||||
<Divider layout="vertical" margin="4px" />
|
||||
<Popover
|
||||
style={{
|
||||
padding: '.4rem',
|
||||
color: 'var(--semi-color-white)',
|
||||
}}
|
||||
/>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
content="Delete Listing"
|
||||
>
|
||||
<Button
|
||||
icon={<IconDelete />}
|
||||
theme="borderless"
|
||||
size="small"
|
||||
type="danger"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await xhrDelete('/api/listings/', { ids: [row.id] });
|
||||
Toast.success('Listing(s) successfully removed');
|
||||
row.reloadTable();
|
||||
} catch (error) {
|
||||
Toast.error(error);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'State',
|
||||
dataIndex: 'is_active',
|
||||
width: 84,
|
||||
sorter: true,
|
||||
render: (value) => {
|
||||
return value ? (
|
||||
<div style={{ color: 'rgba(var(--semi-green-6), 1)' }}>
|
||||
<Popover
|
||||
style={{
|
||||
padding: '.4rem',
|
||||
color: 'var(--semi-color-white)',
|
||||
}}
|
||||
content="Listing is still active"
|
||||
>
|
||||
<IconTick />
|
||||
</Popover>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ color: 'rgba(var(--semi-red-5), 1)' }}>
|
||||
<Popover
|
||||
style={{
|
||||
padding: '.4rem',
|
||||
color: 'var(--semi-color-white)',
|
||||
}}
|
||||
content="Listing is inactive"
|
||||
>
|
||||
<IconClose />
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
{
|
||||
title: 'Active',
|
||||
dataIndex: 'is_active',
|
||||
width: 110,
|
||||
sorter: true,
|
||||
filters: [
|
||||
{
|
||||
text: 'Show only active listings',
|
||||
value: 'activityStatus',
|
||||
},
|
||||
],
|
||||
render: (value) => {
|
||||
return value ? (
|
||||
<div style={{ color: 'rgba(var(--semi-green-6), 1)' }}>
|
||||
<Popover
|
||||
style={{
|
||||
padding: '.4rem',
|
||||
color: 'var(--semi-color-white)',
|
||||
}}
|
||||
content="Listing is still active"
|
||||
>
|
||||
<IconTick />
|
||||
</Popover>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ color: 'rgba(var(--semi-red-5), 1)' }}>
|
||||
<Popover
|
||||
style={{
|
||||
padding: '.4rem',
|
||||
color: 'var(--semi-color-white)',
|
||||
}}
|
||||
content="Listing is inactive"
|
||||
>
|
||||
<IconClose />
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Job-Name',
|
||||
sorter: true,
|
||||
ellipsis: true,
|
||||
dataIndex: 'job_name',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: 'Listing date',
|
||||
width: 130,
|
||||
dataIndex: 'created_at',
|
||||
sorter: true,
|
||||
render: (text) => timeService.format(text, false),
|
||||
},
|
||||
{
|
||||
title: 'Provider',
|
||||
width: 130,
|
||||
dataIndex: 'provider',
|
||||
sorter: true,
|
||||
render: (text) => text.charAt(0).toUpperCase() + text.slice(1),
|
||||
},
|
||||
{
|
||||
title: 'Price',
|
||||
width: 110,
|
||||
dataIndex: 'price',
|
||||
sorter: true,
|
||||
render: (text) => text + ' €',
|
||||
},
|
||||
{
|
||||
title: 'Address',
|
||||
width: 150,
|
||||
dataIndex: 'address',
|
||||
sorter: true,
|
||||
},
|
||||
{
|
||||
title: 'Title',
|
||||
dataIndex: 'title',
|
||||
sorter: true,
|
||||
ellipsis: true,
|
||||
render: (text, row) => {
|
||||
return (
|
||||
<a href={row.url} target="_blank" rel="noopener noreferrer">
|
||||
{text}
|
||||
</a>
|
||||
);
|
||||
{
|
||||
title: 'Job-Name',
|
||||
sorter: true,
|
||||
ellipsis: true,
|
||||
dataIndex: 'job_name',
|
||||
width: 150,
|
||||
onFilter: () => true,
|
||||
renderFilterDropdown: () => {
|
||||
return (
|
||||
<Space vertical style={{ padding: 8 }}>
|
||||
<Select showClear placeholder="Select Job to Filter" onChange={(val) => setJobNameFilter(val)}>
|
||||
{jobs != null &&
|
||||
jobs.length > 0 &&
|
||||
jobs.map((job) => {
|
||||
return (
|
||||
<Select.Option value={job.id} key={job.id}>
|
||||
{job.name}
|
||||
</Select.Option>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
{
|
||||
title: 'Listing date',
|
||||
width: 130,
|
||||
dataIndex: 'created_at',
|
||||
sorter: true,
|
||||
render: (text) => timeService.format(text, false),
|
||||
},
|
||||
{
|
||||
title: 'Provider',
|
||||
width: 130,
|
||||
dataIndex: 'provider',
|
||||
sorter: true,
|
||||
render: (text) => text.charAt(0).toUpperCase() + text.slice(1),
|
||||
onFilter: () => true,
|
||||
renderFilterDropdown: () => {
|
||||
return (
|
||||
<Space vertical style={{ padding: 8 }}>
|
||||
<Select showClear placeholder="Select Provider to Filter" onChange={(val) => setProviderFilter(val)}>
|
||||
{provider != null &&
|
||||
provider.length > 0 &&
|
||||
provider.map((prov) => {
|
||||
return (
|
||||
<Select.Option value={prov.id} key={prov.id}>
|
||||
{prov.name}
|
||||
</Select.Option>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Price',
|
||||
width: 110,
|
||||
dataIndex: 'price',
|
||||
sorter: true,
|
||||
render: (text) => text + ' €',
|
||||
},
|
||||
{
|
||||
title: 'Address',
|
||||
width: 150,
|
||||
dataIndex: 'address',
|
||||
sorter: true,
|
||||
},
|
||||
{
|
||||
title: 'Title',
|
||||
dataIndex: 'title',
|
||||
sorter: true,
|
||||
ellipsis: true,
|
||||
render: (text, row) => {
|
||||
return (
|
||||
<a href={row.url} target="_blank" rel="noopener noreferrer">
|
||||
{text}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const empty = (
|
||||
<Empty
|
||||
image={<IllustrationNoResult />}
|
||||
darkModeImage={<IllustrationNoResultDark />}
|
||||
description="No listings available."
|
||||
description="No listings found."
|
||||
/>
|
||||
);
|
||||
|
||||
export default function ListingsTable() {
|
||||
const tableData = useSelector((state) => state.listingsTable);
|
||||
const provider = useSelector((state) => state.provider);
|
||||
const jobs = useSelector((state) => state.jobs.jobs);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const watchlistFeature = useFeature('WATCHLIST_MANAGEMENT') || false;
|
||||
const actions = useActions();
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 10;
|
||||
@@ -179,12 +250,14 @@ export default function ListingsTable() {
|
||||
const [jobNameFilter, setJobNameFilter] = useState(null);
|
||||
const [activityFilter, setActivityFilter] = useState(null);
|
||||
const [providerFilter, setProviderFilter] = useState(null);
|
||||
const [allFilters, setAllFilters] = useState([]);
|
||||
|
||||
const [imageWidth, setImageWidth] = useState('100%');
|
||||
const handlePageChange = (_page) => {
|
||||
setPage(_page);
|
||||
};
|
||||
|
||||
const columns = getColumns(provider, setProviderFilter, jobs, setJobNameFilter);
|
||||
const loadTable = () => {
|
||||
let sortfield = null;
|
||||
let sortdir = null;
|
||||
@@ -209,6 +282,20 @@ export default function ListingsTable() {
|
||||
|
||||
const handleFilterChange = useMemo(() => debounce((value) => setFreeTextFilter(value), 500), []);
|
||||
|
||||
const diffArrays = (primary, secondary) => {
|
||||
const result = {};
|
||||
|
||||
for (const item of secondary) {
|
||||
if (!primary.includes(item)) result[item] = true;
|
||||
}
|
||||
|
||||
for (const item of primary) {
|
||||
if (!secondary.includes(item)) result[item] = false;
|
||||
}
|
||||
|
||||
return [result];
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// cleanup debounced handler to avoid memory leaks
|
||||
@@ -258,12 +345,6 @@ export default function ListingsTable() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ListingsFilter
|
||||
onActivityFilter={setActivityFilter}
|
||||
onWatchListFilter={setWatchListFilter}
|
||||
onJobNameFilter={setJobNameFilter}
|
||||
onProviderFilter={setProviderFilter}
|
||||
/>
|
||||
<Input
|
||||
prefix={<IconSearch />}
|
||||
showClear
|
||||
@@ -271,6 +352,16 @@ export default function ListingsTable() {
|
||||
placeholder="Search"
|
||||
onChange={handleFilterChange}
|
||||
/>
|
||||
{watchlistFeature && (
|
||||
<Button
|
||||
className="listingsTable__setupButton"
|
||||
onClick={() => {
|
||||
navigate('/watchlistManagement');
|
||||
}}
|
||||
>
|
||||
Setup notifications on watchlist changes
|
||||
</Button>
|
||||
)}
|
||||
<Table
|
||||
rowKey="id"
|
||||
empty={empty}
|
||||
@@ -285,7 +376,23 @@ export default function ListingsTable() {
|
||||
};
|
||||
})}
|
||||
onChange={(changeSet) => {
|
||||
if (changeSet?.extra?.changeType === 'sorter') {
|
||||
if (changeSet?.extra?.changeType === 'filter') {
|
||||
const transformed = changeSet.filters.map((f) => f.dataIndex);
|
||||
const diff = diffArrays(allFilters, transformed);
|
||||
setAllFilters(transformed);
|
||||
diff.forEach((filter) => {
|
||||
switch (Object.keys(filter)[0]) {
|
||||
case 'isWatched':
|
||||
setWatchListFilter(Object.values(filter)[0]);
|
||||
break;
|
||||
case 'is_active':
|
||||
setActivityFilter(Object.values(filter)[0]);
|
||||
break;
|
||||
default:
|
||||
console.error('Unknown filter: ', filter.dataIndex);
|
||||
}
|
||||
});
|
||||
} else if (changeSet?.extra?.changeType === 'sorter') {
|
||||
setSortData({
|
||||
field: changeSet.sorter.dataIndex,
|
||||
direction: changeSet.sorter.sortOrder === 'ascend' ? 'asc' : 'desc',
|
||||
|
||||
@@ -11,4 +11,8 @@
|
||||
&__toolbar {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
&__setupButton {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
15
ui/src/hooks/featureHook.js
Normal file
15
ui/src/hooks/featureHook.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useSelector } from '../services/state/store.js';
|
||||
|
||||
export function useFeature(name) {
|
||||
const currentFeatureFlags = useSelector((state) => state.features);
|
||||
if (Object.keys(currentFeatureFlags || {}).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (currentFeatureFlags[name] == null) {
|
||||
console.warn(`Feature flag with name ${name} is unknown.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return currentFeatureFlags[name];
|
||||
}
|
||||
@@ -48,6 +48,16 @@ export const useFredyState = create(
|
||||
}
|
||||
},
|
||||
},
|
||||
features: {
|
||||
async getFeatures() {
|
||||
try {
|
||||
const response = await xhrGet('/api/features');
|
||||
set((state) => ({ ...state.features, ...response.json }));
|
||||
} catch (Exception) {
|
||||
console.error('Error while trying to get resource for api/features. Error:', Exception);
|
||||
}
|
||||
},
|
||||
},
|
||||
provider: {
|
||||
async getProvider() {
|
||||
try {
|
||||
@@ -176,6 +186,7 @@ export const useFredyState = create(
|
||||
page: 1,
|
||||
result: [],
|
||||
},
|
||||
features: {},
|
||||
generalSettings: { settings: {} },
|
||||
demoMode: { demoMode: false },
|
||||
versionUpdate: {},
|
||||
@@ -192,6 +203,7 @@ export const useFredyState = create(
|
||||
versionUpdate: { ...effects.versionUpdate },
|
||||
listingsTable: { ...effects.listingsTable },
|
||||
provider: { ...effects.provider },
|
||||
features: { ...effects.features },
|
||||
jobs: { ...effects.jobs },
|
||||
user: { ...effects.user },
|
||||
};
|
||||
|
||||
@@ -54,6 +54,8 @@ function spreadPrefilledAdapterWithValues(prefilled, fields) {
|
||||
}
|
||||
|
||||
export default function NotificationAdapterMutator({
|
||||
title,
|
||||
description,
|
||||
onVisibilityChanged,
|
||||
visible = false,
|
||||
selected = [],
|
||||
@@ -172,7 +174,7 @@ export default function NotificationAdapterMutator({
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Adding a new Notification Adapter"
|
||||
title={title != null ? title : 'Adding a new Notification Adapter'}
|
||||
visible={visible}
|
||||
style={{ width: isMobile ? '95%' : '50rem' }}
|
||||
onCancel={() => onSubmit(false)}
|
||||
@@ -211,11 +213,15 @@ export default function NotificationAdapterMutator({
|
||||
/>
|
||||
)}
|
||||
|
||||
<p>
|
||||
When Fredy finds new listings, we like to report them to you. To do so, the notification adapter can be
|
||||
configured. <br />
|
||||
There are multiple ways Fredy can send new listings to you. Choose your weapon...
|
||||
</p>
|
||||
{description != null ? (
|
||||
<p>{description}</p>
|
||||
) : (
|
||||
<p>
|
||||
When Fredy finds new listings, we like to report them to you. To do so, notification adapter can be
|
||||
configured. <br />
|
||||
There are multiple ways how Fredy can send new listings to you. Chose your weapon...
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Select
|
||||
filter
|
||||
|
||||
@@ -3,9 +3,5 @@ import React from 'react';
|
||||
import ListingsTable from '../../components/table/listings/ListingsTable.jsx';
|
||||
|
||||
export default function Listings() {
|
||||
return (
|
||||
<div>
|
||||
<ListingsTable />
|
||||
</div>
|
||||
);
|
||||
return <ListingsTable />;
|
||||
}
|
||||
|
||||
59
ui/src/views/listings/management/WatchlistManagement.jsx
Normal file
59
ui/src/views/listings/management/WatchlistManagement.jsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import React, { useState } from 'react';
|
||||
import { IconHorn } from '@douyinfe/semi-icons';
|
||||
import { SegmentPart } from '../../../components/segment/SegmentPart.jsx';
|
||||
import { Banner, Button, Checkbox, Space } from '@douyinfe/semi-ui';
|
||||
import NotificationAdapterMutator from '../../jobs/mutation/components/notificationAdapter/NotificationAdapterMutator.jsx';
|
||||
import Headline from '../../../components/headline/Headline.jsx';
|
||||
|
||||
export default function WatchlistManagement() {
|
||||
const [notificationChooserVisible, setNotificationChooserVisible] = useState(false);
|
||||
const [notificationAdapterData, setNotificationAdapterData] = useState([]);
|
||||
//TODO: Set default
|
||||
const [activityChanges, setActivityChanges] = useState(false);
|
||||
const [priceChanges, setPriceChanges] = useState(false);
|
||||
return (
|
||||
<div>
|
||||
<SegmentPart
|
||||
name="Notification for Watch List"
|
||||
helpText="You can get notified for changes on listings from your watch list."
|
||||
Icon={IconHorn}
|
||||
>
|
||||
<Banner
|
||||
fullMode={false}
|
||||
type="info"
|
||||
closeIcon={null}
|
||||
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>Note</div>}
|
||||
description="You’ll receive notifications only for listings that are on your watch list. To add listings to it, open the 'Listings' section and tag the ones you want to follow."
|
||||
/>
|
||||
<Space />
|
||||
<Headline size={5} text="Notify me when:" style={{ marginTop: '1rem' }} />
|
||||
|
||||
<Checkbox checked={activityChanges} onChange={(e) => setActivityChanges(e.target.checked)}>
|
||||
Listing state changes (e.g. listing becomes inactive)
|
||||
</Checkbox>
|
||||
<Checkbox checked={priceChanges} onChange={(e) => setPriceChanges(e.target.checked)}>
|
||||
Listing price changes
|
||||
</Checkbox>
|
||||
|
||||
<Space />
|
||||
<Headline size={5} text="Notify me with:" style={{ marginTop: '1rem' }} />
|
||||
<Button onClick={() => setNotificationChooserVisible(true)}>Select notification method</Button>
|
||||
|
||||
<NotificationAdapterMutator
|
||||
title="Add notification method"
|
||||
description="When something has changed, Fredy will notify you using the selected notification adapter. Note, some adapter like SqLite are not available here."
|
||||
visible={notificationChooserVisible}
|
||||
onVisibilityChanged={(visible) => {
|
||||
setNotificationChooserVisible(visible);
|
||||
}}
|
||||
selected={notificationAdapterData}
|
||||
editNotificationAdapter={null}
|
||||
onData={(data) => {
|
||||
const oldData = [...notificationAdapterData].filter((o) => o.id !== data.id);
|
||||
setNotificationAdapterData([...oldData, data]);
|
||||
}}
|
||||
/>
|
||||
</SegmentPart>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
304
yarn.lock
304
yarn.lock
@@ -997,34 +997,34 @@
|
||||
dependencies:
|
||||
tslib "^2.0.0"
|
||||
|
||||
"@douyinfe/semi-animation-react@2.88.3":
|
||||
version "2.88.3"
|
||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation-react/-/semi-animation-react-2.88.3.tgz#8e3b7e11e90baa3d6b7e5a692c4e3b4b72d33958"
|
||||
integrity sha512-Jhk/QXZ1Wz++D7PZpdRSc3Pj9sDHIDeOXal/zfzUVpEjuQWlD/ebup0MM50ChBkMPI9HkOQLCbZ2Z5qV7bX5SQ==
|
||||
"@douyinfe/semi-animation-react@2.89.0":
|
||||
version "2.89.0"
|
||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation-react/-/semi-animation-react-2.89.0.tgz#be95b42a928ffe60b54d688dcf1d0f65e81b5bcc"
|
||||
integrity sha512-6GSQMF2bIoWN2Bua4wYGCe//ltfE1/iNQRMF7+TybVMz9kBJU0gelFsvxxVnqpka994RuTvhe73CSWWdpLwjng==
|
||||
dependencies:
|
||||
"@douyinfe/semi-animation" "2.88.3"
|
||||
"@douyinfe/semi-animation-styled" "2.88.3"
|
||||
"@douyinfe/semi-animation" "2.89.0"
|
||||
"@douyinfe/semi-animation-styled" "2.89.0"
|
||||
classnames "^2.2.6"
|
||||
|
||||
"@douyinfe/semi-animation-styled@2.88.3":
|
||||
version "2.88.3"
|
||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation-styled/-/semi-animation-styled-2.88.3.tgz#e284da909af68e2f7d96a0bf1847ceea69c455e5"
|
||||
integrity sha512-VNuMBD4mffSB4yCzhxHf7/AIRddIxUzz6U+jHQbd0ZAt75CXmj6h/YNyURpaYspHKr3bMqgRW98956CTa8qbjQ==
|
||||
"@douyinfe/semi-animation-styled@2.89.0":
|
||||
version "2.89.0"
|
||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation-styled/-/semi-animation-styled-2.89.0.tgz#72cd09f73abf5198bcfb47f6254c0f2799c146b2"
|
||||
integrity sha512-y1wXswseGbJpPh3hJQ9aNjnMzecLh9eUERmSpQaWbDSdrzk65hBa91MMC2rk/wlIN0/Q6OAKU8FcMoSiBiuI0Q==
|
||||
|
||||
"@douyinfe/semi-animation@2.88.3":
|
||||
version "2.88.3"
|
||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation/-/semi-animation-2.88.3.tgz#b7ee6f792844c1c8abb087b30e709cd9b4600b83"
|
||||
integrity sha512-x7Ef2IJjW8M0cgg41P7hgePlxz3XvWRVcov4fvzAE3LYBPgYRz9FL+k/gQ/OhT8PpnlHh5XpPUL3N2ATBLeiEQ==
|
||||
"@douyinfe/semi-animation@2.89.0":
|
||||
version "2.89.0"
|
||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation/-/semi-animation-2.89.0.tgz#a30de59827f6b8452100a5dd2a828aebe8cb86ec"
|
||||
integrity sha512-y6an913b841V0BAdR5qSLYvoK5C2OAbNKImzM+FzWmbRQjzbOEYcF3bqi5AZhY4mYk7v05k2W7U6fmaXYNOS1Q==
|
||||
dependencies:
|
||||
bezier-easing "^2.1.0"
|
||||
|
||||
"@douyinfe/semi-foundation@2.88.3":
|
||||
version "2.88.3"
|
||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-foundation/-/semi-foundation-2.88.3.tgz#761203e8d71359ec61084007a18d7779e627fe5a"
|
||||
integrity sha512-/jlSfki5Bg4lAqbl0oAUtHqzWqFijvqcHvH/dmU1wgLw/kZYj/bQkA3G9/VrTyWggfXmq2JC6bx9EoQAvXEsSQ==
|
||||
"@douyinfe/semi-foundation@2.89.0":
|
||||
version "2.89.0"
|
||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-foundation/-/semi-foundation-2.89.0.tgz#299b10ecb92289bd4158471d91a08dfc461b7ef2"
|
||||
integrity sha512-Ryc2XywB3BVoUHETp5e7cY9x/ccweeKyCjqw/dcM16txeSpGxW7p1ykexGHRl3+dz1QcVrU4vp/ELD6GutC0Sg==
|
||||
dependencies:
|
||||
"@douyinfe/semi-animation" "2.88.3"
|
||||
"@douyinfe/semi-json-viewer-core" "2.88.3"
|
||||
"@douyinfe/semi-animation" "2.89.0"
|
||||
"@douyinfe/semi-json-viewer-core" "2.89.0"
|
||||
"@mdx-js/mdx" "^3.0.1"
|
||||
async-validator "^3.5.0"
|
||||
classnames "^2.2.6"
|
||||
@@ -1038,44 +1038,44 @@
|
||||
remark-gfm "^4.0.0"
|
||||
scroll-into-view-if-needed "^2.2.24"
|
||||
|
||||
"@douyinfe/semi-icons@2.88.3", "@douyinfe/semi-icons@^2.88.3":
|
||||
version "2.88.3"
|
||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-icons/-/semi-icons-2.88.3.tgz#9aaa2e580147affa8587a495679b8d860a8af194"
|
||||
integrity sha512-g3YFaM8Jr0GRY9rV5OuxvmZiqZUN9grj+TdRC930StxBjxdp901WGhuaPGpkaVahO0mkX3hSAJVlQYPEF/MOzQ==
|
||||
"@douyinfe/semi-icons@2.89.0", "@douyinfe/semi-icons@^2.89.0":
|
||||
version "2.89.0"
|
||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-icons/-/semi-icons-2.89.0.tgz#c9252981fde29668e3a88862948d09f71360a4fa"
|
||||
integrity sha512-LfUhh/S0+3bOdD7jy1xg5F1y6mXrYtDiIsA1Hmuhy3zhNSpSKSwfqPiV3IxwRRmGXFWjgiSefKd99h5OmKMPHg==
|
||||
dependencies:
|
||||
classnames "^2.2.6"
|
||||
|
||||
"@douyinfe/semi-illustrations@2.88.3":
|
||||
version "2.88.3"
|
||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-illustrations/-/semi-illustrations-2.88.3.tgz#72101f0eec8d3bb7d10f8108af6cc91c8de3300f"
|
||||
integrity sha512-L8nqLz7YaRTDM+ZSXmWLrt+zjaGl7SFOBT4QQG7O6q1gmaK2XOiHWLiiQYztp863cMr11LSBIckMy56M9ScCbQ==
|
||||
"@douyinfe/semi-illustrations@2.89.0":
|
||||
version "2.89.0"
|
||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-illustrations/-/semi-illustrations-2.89.0.tgz#93611cfa572d79eb4bd50a1e13ee416161e6f41f"
|
||||
integrity sha512-yAU4sSHr236E7ygTlwxupQkeF/W7EtfrUfRx3NUdGWuswMPAICz7d6Upa0XAZoCJ4skBZ5ItcQq9FfM+pw4wKg==
|
||||
|
||||
"@douyinfe/semi-json-viewer-core@2.88.3":
|
||||
version "2.88.3"
|
||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-json-viewer-core/-/semi-json-viewer-core-2.88.3.tgz#f2451d0423f028758e44ea7035a2f668651959a3"
|
||||
integrity sha512-2tqTXbUrDYxZVu/Stl14Rv/WeiqbYlfyKmra9AIG81ZaqG1fP28g/dkiBlHVzUZbYCMM9s3EGAuZugn1jWkWKQ==
|
||||
"@douyinfe/semi-json-viewer-core@2.89.0":
|
||||
version "2.89.0"
|
||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-json-viewer-core/-/semi-json-viewer-core-2.89.0.tgz#4254ce7d36c24f70267980f8e8a42faf6757f502"
|
||||
integrity sha512-BGMJgg+tBFcwg3/7aJmtIXaHW+tSA6Tae3UfyhLYjUxcl6cFtYjtN0DAGwoia9KzUdNHoSAhl3GJVtGCBsmApQ==
|
||||
dependencies:
|
||||
jsonc-parser "^3.3.1"
|
||||
|
||||
"@douyinfe/semi-theme-default@2.88.3":
|
||||
version "2.88.3"
|
||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-theme-default/-/semi-theme-default-2.88.3.tgz#513506c6eaa9d23510054ae852cb63f4147f37c3"
|
||||
integrity sha512-mCuxedgCT1bJChMEQ+AAJ8g0oJQZaXsL+vVKxjqISjFUutbUvs++K+A2pWobgZafOXu1JS+cpppPsEcR4G4JvQ==
|
||||
"@douyinfe/semi-theme-default@2.89.0":
|
||||
version "2.89.0"
|
||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-theme-default/-/semi-theme-default-2.89.0.tgz#eb1fed1939a16fb903f6845867007020d76a503b"
|
||||
integrity sha512-mdL6Ui1XMGW9L5tYl9uG3MnmyHaIXnRVa7b4PB0Y8kfFGAVzls5XBjZT5ACVtwxlVZrU5BrJBFOXGER5p1FVDg==
|
||||
|
||||
"@douyinfe/semi-ui@2.88.3":
|
||||
version "2.88.3"
|
||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-ui/-/semi-ui-2.88.3.tgz#d2b1b4fe9147d18371ddc45d91601378eb885d45"
|
||||
integrity sha512-lVW4euk+j+ev2c4c2LNwx1gwnEU6pGguaB5Z9E11DoyrvMtIg5irow5o1M4pG1r3xbU+4+oQqkRLPXAx01WPzg==
|
||||
"@douyinfe/semi-ui@2.89.0":
|
||||
version "2.89.0"
|
||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-ui/-/semi-ui-2.89.0.tgz#563adb7f33b9d888a882573024df2296be3c8bf4"
|
||||
integrity sha512-XZ2yo2TgGWk8ubukJq7zbpKePpswQRq3nxeBlmL39SEben8AUfEq92vus0Hcmua5Y2wgi6TY2qWPgo+WEZCrkQ==
|
||||
dependencies:
|
||||
"@dnd-kit/core" "^6.0.8"
|
||||
"@dnd-kit/sortable" "^7.0.2"
|
||||
"@dnd-kit/utilities" "^3.2.1"
|
||||
"@douyinfe/semi-animation" "2.88.3"
|
||||
"@douyinfe/semi-animation-react" "2.88.3"
|
||||
"@douyinfe/semi-foundation" "2.88.3"
|
||||
"@douyinfe/semi-icons" "2.88.3"
|
||||
"@douyinfe/semi-illustrations" "2.88.3"
|
||||
"@douyinfe/semi-theme-default" "2.88.3"
|
||||
"@douyinfe/semi-animation" "2.89.0"
|
||||
"@douyinfe/semi-animation-react" "2.89.0"
|
||||
"@douyinfe/semi-foundation" "2.89.0"
|
||||
"@douyinfe/semi-icons" "2.89.0"
|
||||
"@douyinfe/semi-illustrations" "2.89.0"
|
||||
"@douyinfe/semi-theme-default" "2.89.0"
|
||||
"@tiptap/core" "^3.10.7"
|
||||
"@tiptap/extension-document" "^3.10.7"
|
||||
"@tiptap/extension-hard-break" "^3.10.7"
|
||||
@@ -1434,10 +1434,10 @@
|
||||
resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33"
|
||||
integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==
|
||||
|
||||
"@puppeteer/browsers@2.10.13":
|
||||
version "2.10.13"
|
||||
resolved "https://registry.yarnpkg.com/@puppeteer/browsers/-/browsers-2.10.13.tgz#42c8b7df14e992f311ca9dca5fed3f0c2182fd17"
|
||||
integrity sha512-a9Ruw3j3qlnB5a/zHRTkruppynxqaeE4H9WNj5eYGRWqw0ZauZ23f4W2ARf3hghF5doozyD+CRtt7XSYuYRI/Q==
|
||||
"@puppeteer/browsers@2.11.0":
|
||||
version "2.11.0"
|
||||
resolved "https://registry.yarnpkg.com/@puppeteer/browsers/-/browsers-2.11.0.tgz#b2dcd7cb02dd2de5909531d00e717a04bd61de73"
|
||||
integrity sha512-n6oQX6mYkG8TRPuPXmbPidkUbsSRalhmaaVAQxvH1IkQy63cwsH+kOjB3e4cpCDHg0aSvsiX9bQ4s2VB6mGWUQ==
|
||||
dependencies:
|
||||
debug "^4.4.3"
|
||||
extract-zip "^2.0.1"
|
||||
@@ -1530,10 +1530,10 @@
|
||||
"@resvg/resvg-js-win32-ia32-msvc" "2.4.1"
|
||||
"@resvg/resvg-js-win32-x64-msvc" "2.4.1"
|
||||
|
||||
"@rolldown/pluginutils@1.0.0-beta.47":
|
||||
version "1.0.0-beta.47"
|
||||
resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz#c282c4a8c39f3d6d2f1086aae09a34e6241f7a50"
|
||||
integrity sha512-8QagwMH3kNCuzD8EWL8R2YPW5e4OrHNSAHRFDdmFqEwEaD/KcNKjVoumo+gP2vW5eKB2UPbM6vTYiGZX0ixLnw==
|
||||
"@rolldown/pluginutils@1.0.0-beta.53":
|
||||
version "1.0.0-beta.53"
|
||||
resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz#c57a5234ae122671aff6fe72e673a7ed90f03f87"
|
||||
integrity sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==
|
||||
|
||||
"@rollup/rollup-android-arm-eabi@4.49.0":
|
||||
version "4.49.0"
|
||||
@@ -1937,30 +1937,30 @@
|
||||
resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz#d06bbb384ebcf6c505fde1c3d0ed4ddffe0aaff8"
|
||||
integrity sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==
|
||||
|
||||
"@visactor/react-vchart@^2.0.9":
|
||||
version "2.0.9"
|
||||
resolved "https://registry.yarnpkg.com/@visactor/react-vchart/-/react-vchart-2.0.9.tgz#3527cbc1350aa320b09e061302d4252d6000063c"
|
||||
integrity sha512-+R5dEqjZDZODYSINKuCuFuouOJ9v62GbEt44phblKWpYs2VaSGnELGXPI/C8tzf9n42IKSvJhqIneS+cBzyiSw==
|
||||
"@visactor/react-vchart@^2.0.10":
|
||||
version "2.0.10"
|
||||
resolved "https://registry.yarnpkg.com/@visactor/react-vchart/-/react-vchart-2.0.10.tgz#103e8f555a56a6f134bd2f5136480b3912153617"
|
||||
integrity sha512-OGpSBT7kutZZKSw3HlxivsmeqRZ6GEOXAxt20+hcZyeH34yqRHklksJYS6ET9E9uivbfrGzIAPniC4iizR8lhQ==
|
||||
dependencies:
|
||||
"@visactor/vchart" "2.0.9"
|
||||
"@visactor/vchart-extension" "2.0.9"
|
||||
"@visactor/vrender-core" "~1.0.26"
|
||||
"@visactor/vrender-kits" "~1.0.26"
|
||||
"@visactor/vchart" "2.0.10"
|
||||
"@visactor/vchart-extension" "2.0.10"
|
||||
"@visactor/vrender-core" "~1.0.30"
|
||||
"@visactor/vrender-kits" "~1.0.30"
|
||||
"@visactor/vutils" "~1.0.12"
|
||||
react-is "^18.2.0"
|
||||
|
||||
"@visactor/vchart-extension@2.0.9":
|
||||
version "2.0.9"
|
||||
resolved "https://registry.yarnpkg.com/@visactor/vchart-extension/-/vchart-extension-2.0.9.tgz#5109db3712de73d8c177c593f0eac1cf8cb806da"
|
||||
integrity sha512-Q/FVl4890IEL3xcF7vU+uknGpQQF2iAGEE5LeijQVYtid79Mf3EZmr9h+Yfs+LXfL9s7/CEytL+OvpOEiV/Hrw==
|
||||
"@visactor/vchart-extension@2.0.10":
|
||||
version "2.0.10"
|
||||
resolved "https://registry.yarnpkg.com/@visactor/vchart-extension/-/vchart-extension-2.0.10.tgz#1fab69e983ba4a743f2b1a7a53ef5cd3f820cfb4"
|
||||
integrity sha512-D/WDodDPtDFtMRXYEm9GNC/Qthu4WkMXMR4hS43dmqA2GYOcAB5/O3CfUFwm+efwBh2EEh46O3STEam20444og==
|
||||
dependencies:
|
||||
"@visactor/vchart" "2.0.9"
|
||||
"@visactor/vchart" "2.0.10"
|
||||
"@visactor/vdataset" "~1.0.12"
|
||||
"@visactor/vlayouts" "~1.0.12"
|
||||
"@visactor/vrender-animate" "~1.0.26"
|
||||
"@visactor/vrender-components" "~1.0.26"
|
||||
"@visactor/vrender-core" "~1.0.26"
|
||||
"@visactor/vrender-kits" "~1.0.26"
|
||||
"@visactor/vrender-animate" "~1.0.30"
|
||||
"@visactor/vrender-components" "~1.0.30"
|
||||
"@visactor/vrender-core" "~1.0.30"
|
||||
"@visactor/vrender-kits" "~1.0.30"
|
||||
"@visactor/vutils" "~1.0.12"
|
||||
|
||||
"@visactor/vchart-semi-theme@^1.12.2":
|
||||
@@ -1975,20 +1975,20 @@
|
||||
resolved "https://registry.yarnpkg.com/@visactor/vchart-theme-utils/-/vchart-theme-utils-1.12.2.tgz#bad0035e79dabbe80890bbd6196668551a12c874"
|
||||
integrity sha512-PkgSAivtUZukCWVUGCXxKcbTzI/oMj1Ky22VYcVs/KM4VFmmCywU2xjBBe1du0LUey6CAKB7bMlj5bL2jctG0A==
|
||||
|
||||
"@visactor/vchart@2.0.9", "@visactor/vchart@^2.0.9":
|
||||
version "2.0.9"
|
||||
resolved "https://registry.yarnpkg.com/@visactor/vchart/-/vchart-2.0.9.tgz#e5e8cbac577d68dfe994da99db26d360140669c9"
|
||||
integrity sha512-D+AfNfMd/Id+eVoRDOz6MBAQ5oV71FDwD+EX30trCw+weORSaTd5nJ8rg9XvYR0vq0v5aNL0TUexfNghnc0aGA==
|
||||
"@visactor/vchart@2.0.10", "@visactor/vchart@^2.0.10":
|
||||
version "2.0.10"
|
||||
resolved "https://registry.yarnpkg.com/@visactor/vchart/-/vchart-2.0.10.tgz#a357cfe8ab33c59d743c902920e9e7489268dd89"
|
||||
integrity sha512-YKOdrU08CVB7851UaxPWRC2+9B0zydzwps2Kdpm4fa8t/KCAn/tfL1/jLdhoTu5brk5UI68w414MX8OsjVosSQ==
|
||||
dependencies:
|
||||
"@visactor/vdataset" "~1.0.12"
|
||||
"@visactor/vlayouts" "~1.0.12"
|
||||
"@visactor/vrender-animate" "~1.0.26"
|
||||
"@visactor/vrender-components" "~1.0.26"
|
||||
"@visactor/vrender-core" "~1.0.26"
|
||||
"@visactor/vrender-kits" "~1.0.26"
|
||||
"@visactor/vrender-animate" "~1.0.30"
|
||||
"@visactor/vrender-components" "~1.0.30"
|
||||
"@visactor/vrender-core" "~1.0.30"
|
||||
"@visactor/vrender-kits" "~1.0.30"
|
||||
"@visactor/vscale" "~1.0.12"
|
||||
"@visactor/vutils" "~1.0.12"
|
||||
"@visactor/vutils-extension" "2.0.9"
|
||||
"@visactor/vutils-extension" "2.0.10"
|
||||
|
||||
"@visactor/vdataset@~1.0.12":
|
||||
version "1.0.16"
|
||||
@@ -2024,40 +2024,40 @@
|
||||
"@visactor/vutils" "1.0.16"
|
||||
eventemitter3 "^4.0.7"
|
||||
|
||||
"@visactor/vrender-animate@1.0.30", "@visactor/vrender-animate@~1.0.26":
|
||||
version "1.0.30"
|
||||
resolved "https://registry.yarnpkg.com/@visactor/vrender-animate/-/vrender-animate-1.0.30.tgz#cc856de3322235c9d9ae03596b9e2345d845b46f"
|
||||
integrity sha512-LgAJqvsJNtCGbxn8W9/68BG0McPz7rcO2lYuz0A+aJU72kyju5MIz+T7aegR/MajHXuHc9o3tayjjQ78qMicfA==
|
||||
"@visactor/vrender-animate@1.0.31", "@visactor/vrender-animate@~1.0.30":
|
||||
version "1.0.31"
|
||||
resolved "https://registry.yarnpkg.com/@visactor/vrender-animate/-/vrender-animate-1.0.31.tgz#4f25203a2073eecbb05dc20af4718b45107bdd12"
|
||||
integrity sha512-9xA9B8JihlsEfBziFUHdUGUizh6xRk07lejUk4f0+qGmGwMPvz32btszs2gw1eF9J6FVmBhxJdZILzcXz4ha0Q==
|
||||
dependencies:
|
||||
"@visactor/vrender-core" "1.0.30"
|
||||
"@visactor/vrender-core" "1.0.31"
|
||||
"@visactor/vutils" "~1.0.12"
|
||||
|
||||
"@visactor/vrender-components@~1.0.26":
|
||||
version "1.0.30"
|
||||
resolved "https://registry.yarnpkg.com/@visactor/vrender-components/-/vrender-components-1.0.30.tgz#eede9c8ebd5ea6458ea5b3a6bfc8faa3239a8363"
|
||||
integrity sha512-7CMb2J3euo6dS8o1CKILQls4mZ18hkcn4CzL7QJKt3LY/l2Y+ERZsjMnx1HBeoTVE+5tav6Ua2qq3/bQvQh6MA==
|
||||
"@visactor/vrender-components@~1.0.30":
|
||||
version "1.0.31"
|
||||
resolved "https://registry.yarnpkg.com/@visactor/vrender-components/-/vrender-components-1.0.31.tgz#76445c52150e6e4c6f2e23a0afc19afdfe4cc0af"
|
||||
integrity sha512-kB+ZdqCnfcmoLHleGPml/NmqgXViC0Vqk/64XzQzXd5fvFqCrgS1G6mpq+VrZjZhJx3gxCTQoct6q2qXN1aYIw==
|
||||
dependencies:
|
||||
"@visactor/vrender-animate" "1.0.30"
|
||||
"@visactor/vrender-core" "1.0.30"
|
||||
"@visactor/vrender-kits" "1.0.30"
|
||||
"@visactor/vrender-animate" "1.0.31"
|
||||
"@visactor/vrender-core" "1.0.31"
|
||||
"@visactor/vrender-kits" "1.0.31"
|
||||
"@visactor/vscale" "~1.0.12"
|
||||
"@visactor/vutils" "~1.0.12"
|
||||
|
||||
"@visactor/vrender-core@1.0.30", "@visactor/vrender-core@~1.0.26":
|
||||
version "1.0.30"
|
||||
resolved "https://registry.yarnpkg.com/@visactor/vrender-core/-/vrender-core-1.0.30.tgz#f24970f9bbae2a72189358f69a63acb06e408036"
|
||||
integrity sha512-SBKoul3PLOGKMow9yfEgZmKwXV5xc0jZV8drD21z1/g2QlWGstc4iQC3sARXyenhe8HJEPC9kQdYdo/AzZty6A==
|
||||
"@visactor/vrender-core@1.0.31", "@visactor/vrender-core@~1.0.30":
|
||||
version "1.0.31"
|
||||
resolved "https://registry.yarnpkg.com/@visactor/vrender-core/-/vrender-core-1.0.31.tgz#995551e4d519dfd0d71dbd27aa1112ff4e1125c3"
|
||||
integrity sha512-4tzN2M5GfI7612IHRiDqUetAjd3J3Ns5gHQxQvmMxismdw7UTrlB8PgnWx9djTYwoxZnhNX0MpPGz9CKgbb7RA==
|
||||
dependencies:
|
||||
"@visactor/vutils" "~1.0.12"
|
||||
color-convert "2.0.1"
|
||||
|
||||
"@visactor/vrender-kits@1.0.30", "@visactor/vrender-kits@~1.0.26":
|
||||
version "1.0.30"
|
||||
resolved "https://registry.yarnpkg.com/@visactor/vrender-kits/-/vrender-kits-1.0.30.tgz#dcde59b7c3e81b06b0665c40b252fa2ecb0ed4c0"
|
||||
integrity sha512-J6sPXNTu0X0eeIqOdNZrJFQukjrJQQuzblLS/p/kVTFf0UF5nF5rR/wA7NeK1gqMmeX1nQlllPM+doGfc7s4Fw==
|
||||
"@visactor/vrender-kits@1.0.31", "@visactor/vrender-kits@~1.0.30":
|
||||
version "1.0.31"
|
||||
resolved "https://registry.yarnpkg.com/@visactor/vrender-kits/-/vrender-kits-1.0.31.tgz#3c57cd84b48208fe6c7a19f4f7f05ba60bca4409"
|
||||
integrity sha512-ZS1vslveNfK6MrkL0tMIzQWT9G/q+P7201nNA55YM89N1Tzpzm0X55YHQiIx9TnWTWQijMlqkz1PR5n3Xh+Hjg==
|
||||
dependencies:
|
||||
"@resvg/resvg-js" "2.4.1"
|
||||
"@visactor/vrender-core" "1.0.30"
|
||||
"@visactor/vrender-core" "1.0.31"
|
||||
"@visactor/vutils" "~1.0.12"
|
||||
gifuct-js "2.1.2"
|
||||
lottie-web "^5.12.2"
|
||||
@@ -2070,10 +2070,10 @@
|
||||
dependencies:
|
||||
"@visactor/vutils" "1.0.16"
|
||||
|
||||
"@visactor/vutils-extension@2.0.9":
|
||||
version "2.0.9"
|
||||
resolved "https://registry.yarnpkg.com/@visactor/vutils-extension/-/vutils-extension-2.0.9.tgz#e471bd0fd83e58bc1350015c95ad294c783d9f6d"
|
||||
integrity sha512-+o0LC0OzqKDFK3NXJLmQ4PXOtZieaBPEczu0X0BPU2puHZa50MlL6g/IZ9/CGMW5fTxu3Uevc45u1Ga71hDGsw==
|
||||
"@visactor/vutils-extension@2.0.10":
|
||||
version "2.0.10"
|
||||
resolved "https://registry.yarnpkg.com/@visactor/vutils-extension/-/vutils-extension-2.0.10.tgz#512a65286cef0307bbddab06628da538021a043f"
|
||||
integrity sha512-XKps9vwm3rLLuP/oY9CHP1RZMmnXroT1l1yi+Ny+CD6NUaZpe0dlAcXgtsS6j0NztUA9KmIHol7Rnv5hhFz/iA==
|
||||
dependencies:
|
||||
"@visactor/vdataset" "~1.0.12"
|
||||
"@visactor/vutils" "~1.0.12"
|
||||
@@ -2087,15 +2087,15 @@
|
||||
"@turf/invariant" "^6.5.0"
|
||||
eventemitter3 "^4.0.7"
|
||||
|
||||
"@vitejs/plugin-react@5.1.1":
|
||||
version "5.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@vitejs/plugin-react/-/plugin-react-5.1.1.tgz#fa1957e053fe90d7cc2deea5593ae382a9761595"
|
||||
integrity sha512-WQfkSw0QbQ5aJ2CHYw23ZGkqnRwqKHD/KYsMeTkZzPT4Jcf0DcBxBtwMJxnu6E7oxw5+JC6ZAiePgh28uJ1HBA==
|
||||
"@vitejs/plugin-react@5.1.2":
|
||||
version "5.1.2"
|
||||
resolved "https://registry.yarnpkg.com/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz#46f47be184c05a18839cb8705d79578b469ac6eb"
|
||||
integrity sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==
|
||||
dependencies:
|
||||
"@babel/core" "^7.28.5"
|
||||
"@babel/plugin-transform-react-jsx-self" "^7.27.1"
|
||||
"@babel/plugin-transform-react-jsx-source" "^7.27.1"
|
||||
"@rolldown/pluginutils" "1.0.0-beta.47"
|
||||
"@rolldown/pluginutils" "1.0.0-beta.53"
|
||||
"@types/babel__core" "^7.20.5"
|
||||
react-refresh "^0.18.0"
|
||||
|
||||
@@ -2393,10 +2393,10 @@ basic-ftp@^5.0.2:
|
||||
resolved "https://registry.yarnpkg.com/basic-ftp/-/basic-ftp-5.0.5.tgz#14a474f5fffecca1f4f406f1c26b18f800225ac0"
|
||||
integrity sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==
|
||||
|
||||
better-sqlite3@^12.4.6:
|
||||
version "12.4.6"
|
||||
resolved "https://registry.yarnpkg.com/better-sqlite3/-/better-sqlite3-12.4.6.tgz#a6cff3be1411fb7a06eadc4449647f3561d3d805"
|
||||
integrity sha512-gaYt9yqTbQ1iOxLpJA8FPR5PiaHP+jlg8I5EX0Rs2KFwNzhBsF40KzMZS5FwelY7RG0wzaucWdqSAJM3uNCPCg==
|
||||
better-sqlite3@^12.5.0:
|
||||
version "12.5.0"
|
||||
resolved "https://registry.yarnpkg.com/better-sqlite3/-/better-sqlite3-12.5.0.tgz#c570873d9635b5d56baa52f7e72634c2c589f35f"
|
||||
integrity sha512-WwCZ/5Diz7rsF29o27o0Gcc1Du+l7Zsv7SYtVPG0X3G/uUI1LqdxrQI7c9Hs2FWpqXXERjW9hp6g3/tH7DlVKg==
|
||||
dependencies:
|
||||
bindings "^1.5.0"
|
||||
prebuild-install "^7.1.1"
|
||||
@@ -3064,10 +3064,10 @@ devlop@^1.0.0, devlop@^1.1.0:
|
||||
dependencies:
|
||||
dequal "^2.0.0"
|
||||
|
||||
devtools-protocol@0.0.1521046:
|
||||
version "0.0.1521046"
|
||||
resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.1521046.tgz#918e6175ea83100fefcb2b78779f15a77aa8a41b"
|
||||
integrity sha512-vhE6eymDQSKWUXwwA37NtTTVEzjtGVfDr3pRbsWEQ5onH/Snp2c+2xZHWJJawG/0hCCJLRGt4xVtEVUVILol4w==
|
||||
devtools-protocol@0.0.1534754:
|
||||
version "0.0.1534754"
|
||||
resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.1534754.tgz#75fb0496ff133d8d7e73d2e49600b37fcb4f46a9"
|
||||
integrity sha512-26T91cV5dbOYnXdJi5qQHoTtUoNEqwkHcAyu/IKtjIAxiEqPMrDiRkDOPWVsGfNZGmlQVHQbZRSjD8sxagWVsQ==
|
||||
|
||||
diff@^7.0.0:
|
||||
version "7.0.0"
|
||||
@@ -6130,10 +6130,10 @@ prelude-ls@^1.2.1:
|
||||
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"
|
||||
integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==
|
||||
|
||||
prettier@3.7.1:
|
||||
version "3.7.1"
|
||||
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.7.1.tgz#8dfbf54c98e85a113962d3d8414ae82ff3722991"
|
||||
integrity sha512-RWKXE4qB3u5Z6yz7omJkjWwmTfLdcbv44jUVHC5NpfXwFGzvpQM798FGv/6WNK879tc+Cn0AAyherCl1KjbyZQ==
|
||||
prettier@3.7.4:
|
||||
version "3.7.4"
|
||||
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.7.4.tgz#d2f8335d4b1cec47e1c8098645411b0c9dff9c0f"
|
||||
integrity sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==
|
||||
|
||||
prismjs@^1.29.0:
|
||||
version "1.30.0"
|
||||
@@ -6365,15 +6365,15 @@ punycode@^2.1.0:
|
||||
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
|
||||
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
|
||||
|
||||
puppeteer-core@24.31.0:
|
||||
version "24.31.0"
|
||||
resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-24.31.0.tgz#a00daa971fb6a9f722264afda7290dd0bfd566f0"
|
||||
integrity sha512-pnAohhSZipWQoFpXuGV7xCZfaGhqcBR9C4pVrU0QSrcMi7tQMH9J9lDBqBvyMAHQqe8HCARuREqFuVKRQOgTvg==
|
||||
puppeteer-core@24.32.1:
|
||||
version "24.32.1"
|
||||
resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-24.32.1.tgz#def7d96620d3460cb36a6e0dcd79cb897c19af30"
|
||||
integrity sha512-GdWTOgy3RqaW6Etgx93ydlVJ4FBJ6TmhMksG5W7v4uawKAzLHNj33k4kBQ1SFZ9NvoXNjhdQuIQ+uik2kWnarA==
|
||||
dependencies:
|
||||
"@puppeteer/browsers" "2.10.13"
|
||||
"@puppeteer/browsers" "2.11.0"
|
||||
chromium-bidi "11.0.0"
|
||||
debug "^4.4.3"
|
||||
devtools-protocol "0.0.1521046"
|
||||
devtools-protocol "0.0.1534754"
|
||||
typed-query-selector "^2.12.0"
|
||||
webdriver-bidi-protocol "0.3.9"
|
||||
ws "^8.18.3"
|
||||
@@ -6425,16 +6425,16 @@ puppeteer-extra@^3.3.6:
|
||||
debug "^4.1.1"
|
||||
deepmerge "^4.2.2"
|
||||
|
||||
puppeteer@^24.31.0:
|
||||
version "24.31.0"
|
||||
resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-24.31.0.tgz#cacd8c4563ff8bd49ee62bd03ae80e11944e698a"
|
||||
integrity sha512-q8y5yLxLD8xdZdzNWqdOL43NbfvUOp60SYhaLZQwHC9CdKldxQKXOyJAciOr7oUJfyAH/KgB2wKvqT2sFKoVXA==
|
||||
puppeteer@^24.32.1:
|
||||
version "24.32.1"
|
||||
resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-24.32.1.tgz#d11e204138750eeada834fdd6746ffba30733655"
|
||||
integrity sha512-wa8vGswFjH1iCyG6bGGydIYssEBluXixbMibK4x2x6/lIAuR87gF+c+Jjzom2Wiw/dDOtuki89VBurRWrgYaUA==
|
||||
dependencies:
|
||||
"@puppeteer/browsers" "2.10.13"
|
||||
"@puppeteer/browsers" "2.11.0"
|
||||
chromium-bidi "11.0.0"
|
||||
cosmiconfig "^9.0.0"
|
||||
devtools-protocol "0.0.1521046"
|
||||
puppeteer-core "24.31.0"
|
||||
devtools-protocol "0.0.1534754"
|
||||
puppeteer-core "24.32.1"
|
||||
typed-query-selector "^2.12.0"
|
||||
|
||||
qs@^6.14.0:
|
||||
@@ -6524,17 +6524,17 @@ react-resizable@^3.0.5:
|
||||
prop-types "15.x"
|
||||
react-draggable "^4.0.3"
|
||||
|
||||
react-router-dom@7.9.6:
|
||||
version "7.9.6"
|
||||
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.9.6.tgz#f2a0d12961d67bd87ab48e5ef42fa1f45beae357"
|
||||
integrity sha512-2MkC2XSXq6HjGcihnx1s0DBWQETI4mlis4Ux7YTLvP67xnGxCvq+BcCQSO81qQHVUTM1V53tl4iVVaY5sReCOA==
|
||||
react-router-dom@7.10.1:
|
||||
version "7.10.1"
|
||||
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.10.1.tgz#fddea814d30a3630c11d9ea539932482ff6f744c"
|
||||
integrity sha512-JNBANI6ChGVjA5bwsUIwJk7LHKmqB4JYnYfzFwyp2t12Izva11elds2jx7Yfoup2zssedntwU0oZ5DEmk5Sdaw==
|
||||
dependencies:
|
||||
react-router "7.9.6"
|
||||
react-router "7.10.1"
|
||||
|
||||
react-router@7.9.6:
|
||||
version "7.9.6"
|
||||
resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.9.6.tgz#003c8de335fdd7390286a478dcfd9579c1826137"
|
||||
integrity sha512-Y1tUp8clYRXpfPITyuifmSoE2vncSME18uVLgaqyxh9H35JWpIfzHo+9y3Fzh5odk/jxPW29IgLgzcdwxGqyNA==
|
||||
react-router@7.10.1:
|
||||
version "7.10.1"
|
||||
resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.10.1.tgz#e973146ed5f10a80783fdb3f27dbe37679557a7c"
|
||||
integrity sha512-gHL89dRa3kwlUYtRQ+m8NmxGI6CgqN+k4XyGjwcFoQwwCWF6xXpOCUlDovkXClS0d0XJN/5q7kc5W3kiFEd0Yw==
|
||||
dependencies:
|
||||
cookie "^1.0.1"
|
||||
set-cookie-parser "^2.6.0"
|
||||
@@ -7832,10 +7832,10 @@ vfile@^6.0.0:
|
||||
"@types/unist" "^3.0.0"
|
||||
vfile-message "^4.0.0"
|
||||
|
||||
vite@7.2.4:
|
||||
version "7.2.4"
|
||||
resolved "https://registry.yarnpkg.com/vite/-/vite-7.2.4.tgz#a3a09c7e25487612ecc1119c7d412c73da35bd4e"
|
||||
integrity sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==
|
||||
vite@7.2.7:
|
||||
version "7.2.7"
|
||||
resolved "https://registry.yarnpkg.com/vite/-/vite-7.2.7.tgz#0789a4c3206081699f34a9ecca2dda594a07478e"
|
||||
integrity sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==
|
||||
dependencies:
|
||||
esbuild "^0.25.0"
|
||||
fdir "^6.5.0"
|
||||
@@ -8063,10 +8063,10 @@ zod@^3.24.1:
|
||||
resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.76.tgz#26841c3f6fd22a6a2760e7ccb719179768471e34"
|
||||
integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==
|
||||
|
||||
zustand@^5.0.8:
|
||||
version "5.0.8"
|
||||
resolved "https://registry.yarnpkg.com/zustand/-/zustand-5.0.8.tgz#b998a0c088c7027a20f2709141a91cb07ac57f8a"
|
||||
integrity sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==
|
||||
zustand@^5.0.9:
|
||||
version "5.0.9"
|
||||
resolved "https://registry.yarnpkg.com/zustand/-/zustand-5.0.9.tgz#389dcd0309b9c545d7a461bd3c54955962847654"
|
||||
integrity sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==
|
||||
|
||||
zwitch@^2.0.0:
|
||||
version "2.0.4"
|
||||
|
||||
Reference in New Issue
Block a user