mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
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:
committed by
GitHub
parent
5cfa674d7f
commit
3e5cd97400
@@ -1 +1 @@
|
|||||||
{"interval":"60","port":9998,"workingHours":{"from":"","to":""},"demoMode":false,"analyticsEnabled":true,"sqlitepath":"/db"}
|
{"sqlitepath":"/db"}
|
||||||
32
index.js
32
index.js
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
12
lib/api/routes/featureRouter.js
Normal file
12
lib/api/routes/featureRouter.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import restana from 'restana';
|
||||||
|
import getFeatures from '../../features.js';
|
||||||
|
const service = restana();
|
||||||
|
const featureRouter = service.newRouter();
|
||||||
|
|
||||||
|
featureRouter.get('/', async (req, res) => {
|
||||||
|
const features = getFeatures();
|
||||||
|
res.body = Object.assign({}, { features });
|
||||||
|
res.send();
|
||||||
|
});
|
||||||
|
|
||||||
|
export { featureRouter };
|
||||||
@@ -1,24 +1,30 @@
|
|||||||
import restana from 'restana';
|
import 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);
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
9
lib/features.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
const FEATURES = {
|
||||||
|
WATCHLIST_MANAGEMENT: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function getFeatures() {
|
||||||
|
return {
|
||||||
|
...FEATURES,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
73
lib/services/storage/migrations/sql/6.settings.js
Normal file
73
lib/services/storage/migrations/sql/6.settings.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
87
lib/services/storage/settingsStorage.js
Normal file
87
lib/services/storage/settingsStorage.js
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import SqliteConnection from './SqliteConnection.js';
|
||||||
|
import { fromJson, readConfigFromStorage, toJson } from '../../utils.js';
|
||||||
|
|
||||||
|
// In-memory cache for compiled settings config
|
||||||
|
/** @type {Record<string, any>|null} */
|
||||||
|
let cachedSettingsConfig = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a config object from DB rows of settings.
|
||||||
|
* - Unwraps stored shape { value: any } into raw values.
|
||||||
|
* - Add additional config values from file config. E.g. sqlite part cannot be stored in db for obvious reasons ;)
|
||||||
|
* @param {{name:string, value:string|null}[]} rows
|
||||||
|
* @param {{name:value}} configValues
|
||||||
|
* @returns {Record<string, any>}
|
||||||
|
*/
|
||||||
|
function compileSettings(rows, configValues) {
|
||||||
|
const config = {};
|
||||||
|
for (const r of rows) {
|
||||||
|
const parsed = fromJson(r.value, null);
|
||||||
|
// unwrap { value: any } if present
|
||||||
|
config[r.name] = parsed && typeof parsed === 'object' && 'value' in parsed ? parsed.value : parsed;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...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();
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)]}
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
import { Card, Checkbox, Descriptions, Divider, Select } from '@douyinfe/semi-ui';
|
|
||||||
import React from 'react';
|
|
||||||
import { useSelector } from '../../../services/state/store.js';
|
|
||||||
import { Typography } from '@douyinfe/semi-ui';
|
|
||||||
|
|
||||||
import './ListingsFilter.less';
|
|
||||||
|
|
||||||
export default function ListingsFilter({ onWatchListFilter, onActivityFilter, onJobNameFilter, onProviderFilter }) {
|
|
||||||
const jobs = useSelector((state) => state.jobs.jobs);
|
|
||||||
const provider = useSelector((state) => state.provider);
|
|
||||||
const { Title } = Typography;
|
|
||||||
return (
|
|
||||||
<Card className="listingsFilter">
|
|
||||||
<Title heading={6}>Filter by:</Title>
|
|
||||||
<Divider />
|
|
||||||
<br />
|
|
||||||
<Descriptions row>
|
|
||||||
<Descriptions.Item itemKey="Watch List">
|
|
||||||
<Checkbox onChange={(e) => onWatchListFilter(e.target.checked)}>Only Watch List</Checkbox>
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item itemKey="Activity status">
|
|
||||||
<Checkbox onChange={(e) => onActivityFilter(e.target.checked)}>Only Active Listings</Checkbox>
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item itemKey="Job Name">
|
|
||||||
<Select showClear placeholder="Select Job to Filter" onChange={(val) => onJobNameFilter(val)}>
|
|
||||||
{jobs != null &&
|
|
||||||
jobs.length > 0 &&
|
|
||||||
jobs.map((job) => {
|
|
||||||
return (
|
|
||||||
<Select.Option value={job.id} key={job.id}>
|
|
||||||
{job.name}
|
|
||||||
</Select.Option>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Select>
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item itemKey="Provider">
|
|
||||||
<Select showClear placeholder="Select Provider to Filter" onChange={(val) => onProviderFilter(val)}>
|
|
||||||
{provider != null &&
|
|
||||||
provider.length > 0 &&
|
|
||||||
provider.map((prov) => {
|
|
||||||
return (
|
|
||||||
<Select.Option value={prov.id} key={prov.id}>
|
|
||||||
{prov.name}
|
|
||||||
</Select.Option>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Select>
|
|
||||||
</Descriptions.Item>
|
|
||||||
</Descriptions>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
.listingsFilter {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
background: rgb(53, 54, 60);
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,18 @@
|
|||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import 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',
|
||||||
|
|||||||
@@ -11,4 +11,8 @@
|
|||||||
&__toolbar {
|
&__toolbar {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__setupButton {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
15
ui/src/hooks/featureHook.js
Normal file
15
ui/src/hooks/featureHook.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { useSelector } from '../services/state/store.js';
|
||||||
|
|
||||||
|
export function useFeature(name) {
|
||||||
|
const currentFeatureFlags = useSelector((state) => state.features);
|
||||||
|
if (Object.keys(currentFeatureFlags || {}).length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentFeatureFlags[name] == null) {
|
||||||
|
console.warn(`Feature flag with name ${name} is unknown.`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentFeatureFlags[name];
|
||||||
|
}
|
||||||
@@ -48,6 +48,16 @@ export const useFredyState = create(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
features: {
|
||||||
|
async getFeatures() {
|
||||||
|
try {
|
||||||
|
const response = await xhrGet('/api/features');
|
||||||
|
set((state) => ({ ...state.features, ...response.json }));
|
||||||
|
} catch (Exception) {
|
||||||
|
console.error('Error while trying to get resource for api/features. Error:', Exception);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
provider: {
|
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 },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
59
ui/src/views/listings/management/WatchlistManagement.jsx
Normal file
59
ui/src/views/listings/management/WatchlistManagement.jsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { IconHorn } from '@douyinfe/semi-icons';
|
||||||
|
import { SegmentPart } from '../../../components/segment/SegmentPart.jsx';
|
||||||
|
import { Banner, Button, Checkbox, Space } from '@douyinfe/semi-ui';
|
||||||
|
import NotificationAdapterMutator from '../../jobs/mutation/components/notificationAdapter/NotificationAdapterMutator.jsx';
|
||||||
|
import Headline from '../../../components/headline/Headline.jsx';
|
||||||
|
|
||||||
|
export default function WatchlistManagement() {
|
||||||
|
const [notificationChooserVisible, setNotificationChooserVisible] = useState(false);
|
||||||
|
const [notificationAdapterData, setNotificationAdapterData] = useState([]);
|
||||||
|
//TODO: Set default
|
||||||
|
const [activityChanges, setActivityChanges] = useState(false);
|
||||||
|
const [priceChanges, setPriceChanges] = useState(false);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<SegmentPart
|
||||||
|
name="Notification for Watch List"
|
||||||
|
helpText="You can get notified for changes on listings from your watch list."
|
||||||
|
Icon={IconHorn}
|
||||||
|
>
|
||||||
|
<Banner
|
||||||
|
fullMode={false}
|
||||||
|
type="info"
|
||||||
|
closeIcon={null}
|
||||||
|
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>Note</div>}
|
||||||
|
description="You’ll receive notifications only for listings that are on your watch list. To add listings to it, open the 'Listings' section and tag the ones you want to follow."
|
||||||
|
/>
|
||||||
|
<Space />
|
||||||
|
<Headline size={5} text="Notify me when:" style={{ marginTop: '1rem' }} />
|
||||||
|
|
||||||
|
<Checkbox checked={activityChanges} onChange={(e) => setActivityChanges(e.target.checked)}>
|
||||||
|
Listing state changes (e.g. listing becomes inactive)
|
||||||
|
</Checkbox>
|
||||||
|
<Checkbox checked={priceChanges} onChange={(e) => setPriceChanges(e.target.checked)}>
|
||||||
|
Listing price changes
|
||||||
|
</Checkbox>
|
||||||
|
|
||||||
|
<Space />
|
||||||
|
<Headline size={5} text="Notify me with:" style={{ marginTop: '1rem' }} />
|
||||||
|
<Button onClick={() => setNotificationChooserVisible(true)}>Select notification method</Button>
|
||||||
|
|
||||||
|
<NotificationAdapterMutator
|
||||||
|
title="Add notification method"
|
||||||
|
description="When something has changed, Fredy will notify you using the selected notification adapter. Note, some adapter like SqLite are not available here."
|
||||||
|
visible={notificationChooserVisible}
|
||||||
|
onVisibilityChanged={(visible) => {
|
||||||
|
setNotificationChooserVisible(visible);
|
||||||
|
}}
|
||||||
|
selected={notificationAdapterData}
|
||||||
|
editNotificationAdapter={null}
|
||||||
|
onData={(data) => {
|
||||||
|
const oldData = [...notificationAdapterData].filter((o) => o.id !== data.id);
|
||||||
|
setNotificationAdapterData([...oldData, data]);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SegmentPart>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user