Listing management (#223)

* upgrading dependencies, fixing image placeholder

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

* aligning run now button

* renaming settings -> general settings

* smaller security and memory improvements

* improving footer

* preparing listing management

* improve filtering for listings

* preparing new settings page

* preparing new settings page

* storing settings in db

* next release version
This commit is contained in:
Christian Kellner
2025-12-09 13:56:46 +01:00
committed by GitHub
parent 5cfa674d7f
commit 3e5cd97400
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 || {};
const localSettings = await getSettings();
if (localSettings.demoMode) {
res.send(new Error('In demo mode, it is not allowed to change these settings.'));
return;
}
try { try {
if (config.demoMode) { if (typeof sqlitepath !== 'undefined') {
res.send(new Error('In demo mode, it is not allowed to change these settings.')); fs.writeFileSync(`${getDirName()}/../conf/config.json`, JSON.stringify({ sqlitepath }));
return;
} }
const currentConfig = await readConfigFromStorage(); upsertSettings(appSettings);
fs.writeFileSync(`${getDirName()}/../conf/config.json`, JSON.stringify({ ...currentConfig, ...settings }));
await refreshConfig();
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,166 +23,224 @@ 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', {
width: 110, title: 'Watchlist',
dataIndex: 'isWatched', width: 133,
sorter: true, dataIndex: 'isWatched',
render: (id, row) => { sorter: true,
return ( filters: [
<div> {
<Popover text: 'Show only watched listings',
style={{ value: 'watchList',
padding: '.4rem', },
color: 'var(--semi-color-white)', ],
}} render: (id, row) => {
content={row.isWatched === 1 ? 'Unwatch Listing' : 'Watch Listing'} return (
> <div>
<Button <Popover
icon={ style={{
row.isWatched === 1 ? ( padding: '.4rem',
<IconStar style={{ color: 'rgba(var(--semi-green-5), 1)' }} /> color: 'var(--semi-color-white)',
) : (
<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');
}
}} }}
/> content={row.isWatched === 1 ? 'Unwatch Listing' : 'Watch Listing'}
</Popover> >
<Divider layout="vertical" margin="4px" /> <Button
<Popover icon={
style={{ row.isWatched === 1 ? (
padding: '.4rem', <IconStar style={{ color: 'rgba(var(--semi-green-5), 1)' }} />
color: 'var(--semi-color-white)', ) : (
}} <IconStarStroked />
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);
} }
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)',
}} }}
/> content="Delete Listing"
</Popover> >
</div> <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: 'Active',
title: 'State', dataIndex: 'is_active',
dataIndex: 'is_active', width: 110,
width: 84, sorter: true,
sorter: true, filters: [
render: (value) => { {
return value ? ( text: 'Show only active listings',
<div style={{ color: 'rgba(var(--semi-green-6), 1)' }}> value: 'activityStatus',
<Popover },
style={{ ],
padding: '.4rem', render: (value) => {
color: 'var(--semi-color-white)', return value ? (
}} <div style={{ color: 'rgba(var(--semi-green-6), 1)' }}>
content="Listing is still active" <Popover
> style={{
<IconTick /> padding: '.4rem',
</Popover> color: 'var(--semi-color-white)',
</div> }}
) : ( content="Listing is still active"
<div style={{ color: 'rgba(var(--semi-red-5), 1)' }}> >
<Popover <IconTick />
style={{ </Popover>
padding: '.4rem', </div>
color: 'var(--semi-color-white)', ) : (
}} <div style={{ color: 'rgba(var(--semi-red-5), 1)' }}>
content="Listing is inactive" <Popover
> style={{
<IconClose /> padding: '.4rem',
</Popover> color: 'var(--semi-color-white)',
</div> }}
); content="Listing is inactive"
>
<IconClose />
</Popover>
</div>
);
},
}, },
}, {
{ title: 'Job-Name',
title: 'Job-Name', sorter: true,
sorter: true, ellipsis: true,
ellipsis: true, dataIndex: 'job_name',
dataIndex: 'job_name', width: 150,
width: 150, onFilter: () => true,
}, renderFilterDropdown: () => {
{ return (
title: 'Listing date', <Space vertical style={{ padding: 8 }}>
width: 130, <Select showClear placeholder="Select Job to Filter" onChange={(val) => setJobNameFilter(val)}>
dataIndex: 'created_at', {jobs != null &&
sorter: true, jobs.length > 0 &&
render: (text) => timeService.format(text, false), jobs.map((job) => {
}, return (
{ <Select.Option value={job.id} key={job.id}>
title: 'Provider', {job.name}
width: 130, </Select.Option>
dataIndex: 'provider', );
sorter: true, })}
render: (text) => text.charAt(0).toUpperCase() + text.slice(1), </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>
);
}, },
}, {
]; 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 = ( 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({
/> />
)} )}
<p> {description != null ? (
When Fredy finds new listings, we like to report them to you. To do so, the notification adapter can be <p>{description}</p>
configured. <br /> ) : (
There are multiple ways Fredy can send new listings to you. Choose your weapon... <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 <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>
);
}