Compare commits

...

19 Commits

Author SHA1 Message Date
orangecoding
e6ca3a78f0 next release version 2025-12-09 13:55:32 +01:00
orangecoding
89c93efa17 adding features 2025-12-09 13:40:13 +01:00
orangecoding
9a6ae5c30a merged master 2025-12-09 12:54:45 +01:00
orangecoding
f6bfc0f3ae upgrade version 2025-11-27 16:08:20 +01:00
orangecoding
e7ea0369ed upgrade version 2025-11-27 16:04:55 +01:00
orangecoding
b74268a234 merged master 2025-11-27 15:58:12 +01:00
orangecoding
3d40d9a548 storing settings in db 2025-11-17 12:06:26 +01:00
orangecoding
656a615b4a Merge branch 'master' into listing-management 2025-11-17 10:28:16 +01:00
orangecoding
f9e08015f1 preparing new settings page 2025-11-02 15:04:19 +01:00
orangecoding
f0ecfb12c7 preparing new settings page 2025-11-02 15:01:19 +01:00
orangecoding
96cd9098f0 improve filtering for listings 2025-11-01 16:29:05 +01:00
orangecoding
60525d4cca merged master 2025-11-01 10:50:58 +01:00
orangecoding
77a7c5493c preparing listing management 2025-11-01 10:45:19 +01:00
orangecoding
99a23fff06 improving footer 2025-11-01 10:36:58 +01:00
orangecoding
712fa84a02 smaller security and memory improvements 2025-11-01 10:30:08 +01:00
orangecoding
2d31608fc6 renaming settings -> general settings 2025-11-01 10:20:27 +01:00
orangecoding
946160345d aligning run now button 2025-11-01 10:09:40 +01:00
orangecoding
9bd05e1e73 improving processing times label and hide when screen width is too low 2025-11-01 10:08:13 +01:00
orangecoding
1104f200ac upgrading dependencies, fixing image placeholder 2025-11-01 09:42:01 +01:00
30 changed files with 656 additions and 282 deletions

View File

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

View File

@@ -1,6 +1,6 @@
import fs from 'fs'; import fs from 'fs';
import path from 'path'; 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 similarityCache from './lib/services/similarity-check/similarityCache.js';
import * as jobStorage from './lib/services/storage/jobStorage.js'; import * as jobStorage from './lib/services/storage/jobStorage.js';
import FredyPipeline from './lib/FredyPipeline.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 logger from './lib/services/logger.js';
import { bus } from './lib/services/events/event-bus.js'; import { bus } from './lib/services/events/event-bus.js';
import { initActiveCheckerCron } from './lib/services/crons/listing-alive-cron.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 // Load configuration before any other startup steps
await refreshConfig(); await refreshConfig();
const isConfigAccessible = await checkIfConfigIsAccessible();
if (!isConfigAccessible) { if (!isConfigAccessible) {
logger.error('Configuration exists, but is not accessible. Please check the file permission'); logger.error('Configuration exists, but is not accessible. Please check the file permission');
process.exit(1); 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) // 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 relDir = rawDir.startsWith('/') ? rawDir.slice(1) : rawDir;
const absDir = path.isAbsolute(relDir) ? relDir : path.join(process.cwd(), relDir); const absDir = path.isAbsolute(relDir) ? relDir : path.join(process.cwd(), relDir);
if (!fs.existsSync(absDir)) { if (!fs.existsSync(absDir)) {
fs.mkdirSync(absDir, { recursive: true }); fs.mkdirSync(absDir, { recursive: true });
} }
// Run DB migrations once at startup and block until finished
await runMigrations();
// Load provider modules once at startup // Load provider modules once at startup
const providers = await getProviders(); const providers = await getProviders();
@@ -41,17 +47,17 @@ similarityCache.initSimilarityCache();
similarityCache.startSimilarityCacheReloader(); similarityCache.startSimilarityCacheReloader();
//assuming interval is always in minutes //assuming interval is always in minutes
const INTERVAL = config.interval * 60 * 1000; const INTERVAL = settings.interval * 60 * 1000;
// Initialize API only after migrations completed // Initialize API only after migrations completed
await import('./lib/api/api.js'); await import('./lib/api/api.js');
if (config.demoMode) { if (settings.demoMode) {
logger.info('Running in demo mode'); logger.info('Running in demo mode');
cleanupDemoAtMidnight(); 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(); ensureAdminUserExists();
ensureDemoUserExists(); ensureDemoUserExists();
@@ -65,10 +71,10 @@ bus.on('jobs:runAll', () => {
}); });
const execute = () => { const execute = () => {
const isDuringWorkingHoursOrNotSet = duringWorkingHoursOrNotSet(config, Date.now()); const isDuringWorkingHoursOrNotSet = duringWorkingHoursOrNotSet(settings, Date.now());
if (!config.demoMode) { if (!settings.demoMode) {
if (isDuringWorkingHoursOrNotSet) { if (isDuringWorkingHoursOrNotSet) {
config.lastRun = Date.now(); settings.lastRun = Date.now();
jobStorage jobStorage
.getJobs() .getJobs()
.filter((job) => job.enabled) .filter((job) => job.enabled)

View File

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

View File

@@ -1,10 +1,11 @@
import restana from 'restana'; import restana from 'restana';
import { config } from '../../utils.js'; import { getSettings } from '../../services/storage/settingsStorage.js';
const service = restana(); const service = restana();
const demoRouter = service.newRouter(); const demoRouter = service.newRouter();
demoRouter.get('/', async (req, res) => { 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(); res.send();
}); });

View File

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

View File

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

View File

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

View File

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

View File

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

9
lib/features.js Normal file
View File

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

View File

@@ -1,8 +1,8 @@
import { removeJobsByUserId } from '../storage/jobStorage.js'; import { removeJobsByUserId } from '../storage/jobStorage.js';
import { config } from '../../utils.js';
import { getUsers } from '../storage/userStorage.js'; import { getUsers } from '../storage/userStorage.js';
import logger from '../logger.js'; import logger from '../logger.js';
import cron from 'node-cron'; 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) * 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); cron.schedule('0 0 * * *', cleanup);
} }
function cleanup() { async function cleanup() {
if (config.demoMode) { const settings = await getSettings();
if (settings.demoMode) {
const demoUser = getUsers(false).find((user) => user.username === 'demo'); const demoUser = getUsers(false).find((user) => user.username === 'demo');
if (demoUser == null) { if (demoUser == null) {
logger.error('Demo user not found, cannot remove Jobs'); logger.error('Demo user not found, cannot remove Jobs');
return; return Promise.resolve();
} }
removeJobsByUserId(demoUser.id); removeJobsByUserId(demoUser.id);
} }

View File

@@ -1,10 +1,12 @@
import cron from 'node-cron'; import cron from 'node-cron';
import { config, inDevMode } from '../../utils.js'; import { inDevMode } from '../../utils.js';
import { trackMainEvent } from '../tracking/Tracker.js'; import { trackMainEvent } from '../tracking/Tracker.js';
import { getSettings } from '../storage/settingsStorage.js';
async function runTask() { 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 //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(); await trackMainEvent();
} }
} }

View File

@@ -2,7 +2,7 @@ import fs from 'fs';
import path from 'path'; import path from 'path';
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
import logger from '../../services/logger.js'; import logger from '../../services/logger.js';
import { config } from '../../utils.js'; import { readConfigFromStorage } from '../../utils.js';
/** /**
* SqliteConnection * SqliteConnection
@@ -25,6 +25,15 @@ import { config } from '../../utils.js';
class SqliteConnection { class SqliteConnection {
static #db = null; 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. * Returns a singleton instance of better-sqlite3 Database.
* Respects env var SQLITE_DB_PATH and defaults to db/listings.db. * Respects env var SQLITE_DB_PATH and defaults to db/listings.db.
@@ -32,9 +41,12 @@ class SqliteConnection {
static getConnection() { static getConnection() {
if (this.#db) return this.#db; 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 '/' // 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 = this.#sqlLiteCfg && this.#sqlLiteCfg.length > 0 ? this.#sqlLiteCfg : '/db';
const rawDir = cfg && cfg.length > 0 ? cfg : '/db';
const relDir = rawDir.startsWith('/') ? rawDir.slice(1) : rawDir; const relDir = rawDir.startsWith('/') ? rawDir.slice(1) : rawDir;
const absDir = path.isAbsolute(relDir) ? relDir : path.join(process.cwd(), relDir); const absDir = path.isAbsolute(relDir) ? relDir : path.join(process.cwd(), relDir);
const dbPath = path.join(absDir, 'listings.db'); const dbPath = path.join(absDir, 'listings.db');

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import { config } from '../../utils.js';
import * as hasher from '../security/hash.js'; import * as hasher from '../security/hash.js';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import SqliteConnection from './SqliteConnection.js'; import SqliteConnection from './SqliteConnection.js';
import { getSettings } from './settingsStorage.js';
/** /**
* Get all users. * 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. * Security: The demo user's password is set to a known value ('demo') and should only be enabled in demoMode.
* @returns {void} * @returns {void}
*/ */
export const ensureDemoUserExists = () => { export const ensureDemoUserExists = async () => {
if (!config.demoMode) { const settings = await getSettings();
if (!settings.demoMode) {
// Remove demo user (and cascade delete their jobs/listings) // Remove demo user (and cascade delete their jobs/listings)
SqliteConnection.execute(`DELETE FROM users WHERE username = 'demo'`); SqliteConnection.execute(`DELETE FROM users WHERE username = 'demo'`);
return; return;

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{ {
"name": "fredy", "name": "fredy",
"version": "14.4.0", "version": "15.0.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",

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,18 @@
import React, { useState, useEffect, useMemo } from 'react'; import 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 { useActions, useSelector } from '../../../services/state/store.js';
import { IconClose, IconDelete, IconSearch, IconStar, IconStarStroked, IconTick } from '@douyinfe/semi-icons'; import { IconClose, IconDelete, IconSearch, IconStar, IconStarStroked, IconTick } from '@douyinfe/semi-icons';
import * as timeService from '../../../services/time/timeService.js'; import * as timeService from '../../../services/time/timeService.js';
@@ -10,14 +23,22 @@ import './ListingsTable.less';
import { format } from '../../../services/time/timeService.js'; import { format } from '../../../services/time/timeService.js';
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations'; import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
import { xhrDelete, xhrPost } from '../../../services/xhr.js'; 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 = [ const getColumns = (provider, setProviderFilter, jobs, setJobNameFilter) => {
return [
{ {
title: 'Watchlist', title: 'Watchlist',
width: 110, width: 133,
dataIndex: 'isWatched', dataIndex: 'isWatched',
sorter: true, sorter: true,
filters: [
{
text: 'Show only watched listings',
value: 'watchList',
},
],
render: (id, row) => { render: (id, row) => {
return ( return (
<div> <div>
@@ -41,7 +62,9 @@ const columns = [
onClick={async () => { onClick={async () => {
try { try {
await xhrPost('/api/listings/watch', { listingId: row.id }); await xhrPost('/api/listings/watch', { listingId: row.id });
Toast.success(row.isWatched === 1 ? 'Listing removed from Watchlist' : 'Listing added to Watchlist'); Toast.success(
row.isWatched === 1 ? 'Listing removed from Watchlist' : 'Listing added to Watchlist',
);
row.reloadTable(); row.reloadTable();
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@@ -79,10 +102,16 @@ const columns = [
}, },
}, },
{ {
title: 'State', title: 'Active',
dataIndex: 'is_active', dataIndex: 'is_active',
width: 84, width: 110,
sorter: true, sorter: true,
filters: [
{
text: 'Show only active listings',
value: 'activityStatus',
},
],
render: (value) => { render: (value) => {
return value ? ( return value ? (
<div style={{ color: 'rgba(var(--semi-green-6), 1)' }}> <div style={{ color: 'rgba(var(--semi-green-6), 1)' }}>
@@ -117,6 +146,24 @@ const columns = [
ellipsis: true, ellipsis: true,
dataIndex: 'job_name', dataIndex: 'job_name',
width: 150, 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', title: 'Listing date',
@@ -131,6 +178,24 @@ const columns = [
dataIndex: 'provider', dataIndex: 'provider',
sorter: true, sorter: true,
render: (text) => text.charAt(0).toUpperCase() + text.slice(1), 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', title: 'Price',
@@ -159,17 +224,23 @@ const columns = [
}, },
}, },
]; ];
};
const empty = ( const empty = (
<Empty <Empty
image={<IllustrationNoResult />} image={<IllustrationNoResult />}
darkModeImage={<IllustrationNoResultDark />} darkModeImage={<IllustrationNoResultDark />}
description="No listings available." description="No listings found."
/> />
); );
export default function ListingsTable() { export default function ListingsTable() {
const tableData = useSelector((state) => state.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 actions = useActions();
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const pageSize = 10; const pageSize = 10;
@@ -179,12 +250,14 @@ export default function ListingsTable() {
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 [allFilters, setAllFilters] = useState([]);
const [imageWidth, setImageWidth] = useState('100%'); const [imageWidth, setImageWidth] = useState('100%');
const handlePageChange = (_page) => { const handlePageChange = (_page) => {
setPage(_page); setPage(_page);
}; };
const columns = getColumns(provider, setProviderFilter, jobs, setJobNameFilter);
const loadTable = () => { const loadTable = () => {
let sortfield = null; let sortfield = null;
let sortdir = null; let sortdir = null;
@@ -209,6 +282,20 @@ export default function ListingsTable() {
const handleFilterChange = useMemo(() => debounce((value) => setFreeTextFilter(value), 500), []); 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(() => { useEffect(() => {
return () => { return () => {
// cleanup debounced handler to avoid memory leaks // cleanup debounced handler to avoid memory leaks
@@ -258,12 +345,6 @@ export default function ListingsTable() {
return ( return (
<div> <div>
<ListingsFilter
onActivityFilter={setActivityFilter}
onWatchListFilter={setWatchListFilter}
onJobNameFilter={setJobNameFilter}
onProviderFilter={setProviderFilter}
/>
<Input <Input
prefix={<IconSearch />} prefix={<IconSearch />}
showClear showClear
@@ -271,6 +352,16 @@ export default function ListingsTable() {
placeholder="Search" placeholder="Search"
onChange={handleFilterChange} onChange={handleFilterChange}
/> />
{watchlistFeature && (
<Button
className="listingsTable__setupButton"
onClick={() => {
navigate('/watchlistManagement');
}}
>
Setup notifications on watchlist changes
</Button>
)}
<Table <Table
rowKey="id" rowKey="id"
empty={empty} empty={empty}
@@ -285,7 +376,23 @@ export default function ListingsTable() {
}; };
})} })}
onChange={(changeSet) => { 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({ setSortData({
field: changeSet.sorter.dataIndex, field: changeSet.sorter.dataIndex,
direction: changeSet.sorter.sortOrder === 'ascend' ? 'asc' : 'desc', direction: changeSet.sorter.sortOrder === 'ascend' ? 'asc' : 'desc',

View File

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

View File

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

View File

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

View File

@@ -54,6 +54,8 @@ function spreadPrefilledAdapterWithValues(prefilled, fields) {
} }
export default function NotificationAdapterMutator({ export default function NotificationAdapterMutator({
title,
description,
onVisibilityChanged, onVisibilityChanged,
visible = false, visible = false,
selected = [], selected = [],
@@ -172,7 +174,7 @@ export default function NotificationAdapterMutator({
return ( return (
<Modal <Modal
title="Adding a new Notification Adapter" title={title != null ? title : 'Adding a new Notification Adapter'}
visible={visible} visible={visible}
style={{ width: isMobile ? '95%' : '50rem' }} style={{ width: isMobile ? '95%' : '50rem' }}
onCancel={() => onSubmit(false)} onCancel={() => onSubmit(false)}
@@ -211,11 +213,15 @@ export default function NotificationAdapterMutator({
/> />
)} )}
{description != null ? (
<p>{description}</p>
) : (
<p> <p>
When Fredy finds new listings, we like to report them to you. To do so, the notification adapter can be When Fredy finds new listings, we like to report them to you. To do so, notification adapter can be
configured. <br /> configured. <br />
There are multiple ways Fredy can send new listings to you. Choose your weapon... There are multiple ways how Fredy can send new listings to you. Chose your weapon...
</p> </p>
)}
<Select <Select
filter filter

View File

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

View File

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