mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a794645393 | ||
|
|
fd7e228972 | ||
|
|
b86e351007 | ||
|
|
19c4860da7 | ||
|
|
d98e06cfdf | ||
|
|
6ae0c9749b | ||
|
|
10e40e038e | ||
|
|
4ba6828939 | ||
|
|
d09770dae2 | ||
|
|
248e4d2562 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,3 +6,4 @@ npm-debug.log
|
||||
.DS_Store
|
||||
.idea
|
||||
.vscode
|
||||
tools/release/config.json
|
||||
|
||||
@@ -35,6 +35,7 @@ WORKDIR /fredy
|
||||
RUN apk add --no-cache chromium curl
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
IS_DOCKER=true \
|
||||
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
|
||||
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
|
||||
|
||||
|
||||
@@ -8,20 +8,20 @@ import js from '@eslint/js';
|
||||
import prettier from 'eslint-config-prettier';
|
||||
import globals from 'globals';
|
||||
import react from 'eslint-plugin-react';
|
||||
import babelParser from '@babel/eslint-parser';
|
||||
|
||||
export default [
|
||||
{
|
||||
files: ['**/*.{js,jsx,ts,tsx}'],
|
||||
ignores: ['**/node_modules/**', '**/dist/**', '**/build/**', '**/public/**', 'db/**', 'conf/**'],
|
||||
},
|
||||
js.configs.recommended,
|
||||
prettier,
|
||||
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
languageOptions: {
|
||||
parser: babelParser,
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
ecmaVersion: 2021,
|
||||
parserOptions: {
|
||||
ecmaFeatures: { jsx: true },
|
||||
},
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
@@ -32,70 +32,14 @@ export default [
|
||||
after: 'readonly',
|
||||
it: 'readonly',
|
||||
},
|
||||
parserOptions: { requireConfigFile: false },
|
||||
},
|
||||
plugins: { react },
|
||||
rules: {
|
||||
eqeqeq: [2, 'allow-null'],
|
||||
strict: 0,
|
||||
'no-redeclare': [2, { builtinGlobals: false }],
|
||||
'class-methods-use-this': 'off',
|
||||
indent: ['off', 2],
|
||||
'linebreak-style': ['error', 'unix'],
|
||||
quotes: ['error', 'single', { avoidEscape: true, allowTemplateLiterals: true }],
|
||||
semi: ['error', 'always'],
|
||||
'no-console': ['error', { allow: ['warn', 'error'] }],
|
||||
'jsx-quotes': ['error', 'prefer-double'],
|
||||
'react/display-name': 'off',
|
||||
'react/forbid-prop-types': 'off',
|
||||
'react/jsx-closing-bracket-location': 'off',
|
||||
'react/jsx-curly-spacing': 'off',
|
||||
'react/jsx-handler-names': ['off', { eventHandlerPrefix: 'handle', eventHandlerPropPrefix: 'on' }],
|
||||
'react/jsx-indent-props': 'off',
|
||||
'react/jsx-key': 'off',
|
||||
'react/jsx-max-props-per-line': 'off',
|
||||
'react/jsx-no-bind': ['error', { ignoreRefs: true, allowArrowFunctions: true, allowBind: false }],
|
||||
'react/jsx-no-duplicate-props': ['error', { ignoreCase: true }],
|
||||
'react/jsx-no-literals': 'off',
|
||||
'react/jsx-no-undef': 'error',
|
||||
'react/jsx-pascal-case': ['error', { allowAllCaps: true, ignore: [] }],
|
||||
'react/sort-prop-types': ['off', { ignoreCase: true, callbacksLast: false, requiredFirst: false }],
|
||||
'react/jsx-sort-prop-types': 'off',
|
||||
'react/jsx-sort-props': 'off',
|
||||
'react/jsx-uses-react': 'error',
|
||||
'react/jsx-uses-vars': 'error',
|
||||
'react/no-danger': 'warn',
|
||||
'react/no-deprecated': 'error',
|
||||
'react/no-did-mount-set-state': 'error',
|
||||
'react/no-did-update-set-state': 'warn',
|
||||
'react/no-direct-mutation-state': 'off',
|
||||
'react/no-is-mounted': 'error',
|
||||
'react/no-set-state': 'off',
|
||||
'react/no-string-refs': 'warn',
|
||||
'react/no-unknown-property': 'error',
|
||||
'react/prop-types': ['error', { ignore: [], customValidators: [], skipUndeclared: true }],
|
||||
'react/react-in-jsx-scope': 'error',
|
||||
'react/require-extension': 'off',
|
||||
'react/require-render-return': 'error',
|
||||
'react/self-closing-comp': 'warn',
|
||||
'react/sort-comp': 'off',
|
||||
'react/jsx-wrap-multilines': ['warn', { declaration: true, assignment: true, return: true }],
|
||||
'react/wrap-multilines': 'off',
|
||||
'react/jsx-first-prop-new-line': 'off',
|
||||
'react/jsx-equals-spacing': ['warn', 'never'],
|
||||
'react/jsx-no-target-blank': 'error',
|
||||
'react/jsx-filename-extension': ['error', { extensions: ['.jsx'] }],
|
||||
'react/jsx-no-comment-textnodes': 'error',
|
||||
'react/no-comment-textnodes': 'off',
|
||||
'react/no-render-return-value': 'error',
|
||||
'react/require-optimization': ['off', { allowDecorators: [] }],
|
||||
'react/no-find-dom-node': 'warn',
|
||||
'react/forbid-component-props': ['off', { forbid: [] }],
|
||||
'react/no-danger-with-children': 'error',
|
||||
'react/no-unused-prop-types': ['warn', { customValidators: [], skipShapeProps: true }],
|
||||
'react/style-prop-object': 'error',
|
||||
'react/no-children-prop': 'warn',
|
||||
},
|
||||
settings: { react: { version: 'detect' } },
|
||||
rules: {
|
||||
...js.configs.recommended.rules,
|
||||
'no-console': ['error', { allow: ['warn', 'error'] }],
|
||||
},
|
||||
},
|
||||
|
||||
prettier,
|
||||
];
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
export const FEATURES = {
|
||||
export const TRACKING_POIS = {
|
||||
DISTANCE_ADDRESS_ENTERED: 'DISTANCE_ADDRESS_ENTERED',
|
||||
WELCOME_FINISHED: 'WELCOME_FINISHED',
|
||||
WELCOME_SKIPPED: 'WELCOME_SKIPPED',
|
||||
};
|
||||
@@ -23,6 +23,7 @@ import { listingsRouter } from './routes/listingsRouter.js';
|
||||
import { getSettings } from '../services/storage/settingsStorage.js';
|
||||
import { dashboardRouter } from './routes/dashboardRouter.js';
|
||||
import { backupRouter } from './routes/backupRouter.js';
|
||||
import { trackingRouter } from './routes/trackingRoute.js';
|
||||
const service = restana();
|
||||
const staticService = files(path.join(getDirName(), '../ui/public'));
|
||||
const PORT = (await getSettings()).port || 9998;
|
||||
@@ -36,6 +37,7 @@ service.use('/api/version', authInterceptor());
|
||||
service.use('/api/listings', authInterceptor());
|
||||
service.use('/api/dashboard', authInterceptor());
|
||||
service.use('/api/user/settings', authInterceptor());
|
||||
service.use('/api/tracking', authInterceptor());
|
||||
|
||||
// /admin can only be accessed when user is having admin permissions
|
||||
service.use('/api/admin', adminInterceptor());
|
||||
@@ -50,6 +52,7 @@ service.use('/api/jobs', jobRouter);
|
||||
service.use('/api/login', loginRouter);
|
||||
service.use('/api/listings', listingsRouter);
|
||||
service.use('/api/dashboard', dashboardRouter);
|
||||
service.use('/api/tracking', trackingRouter);
|
||||
//this route is unsecured intentionally as it is being queried from the login page
|
||||
service.use('/api/demo', demoRouter);
|
||||
|
||||
|
||||
37
lib/api/routes/trackingRoute.js
Normal file
37
lib/api/routes/trackingRoute.js
Normal file
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import restana from 'restana';
|
||||
import { trackPoi } from '../../services/tracking/Tracker.js';
|
||||
import { TRACKING_POIS } from '../../TRACKING_POIS.js';
|
||||
import logger from '../../services/logger.js';
|
||||
|
||||
const service = restana();
|
||||
const trackingRouter = service.newRouter();
|
||||
|
||||
trackingRouter.get('/trackingPois', async (req, res) => {
|
||||
res.body = TRACKING_POIS;
|
||||
res.send();
|
||||
});
|
||||
|
||||
trackingRouter.post('/poi', async (req, res) => {
|
||||
const { poi } = req.body;
|
||||
if (!poi) {
|
||||
res.statusCode = 400;
|
||||
res.send({ error: 'Feature name is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await trackPoi(poi);
|
||||
res.send({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Error tracking feature', error);
|
||||
res.statusCode = 500;
|
||||
res.send({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
export { trackingRouter };
|
||||
@@ -10,8 +10,8 @@ import { resetGeocoordinatesAndDistanceForUser } from '../../services/storage/li
|
||||
import { geocodeAddress } from '../../services/geocoding/geoCodingService.js';
|
||||
import { autocompleteAddress } from '../../services/geocoding/autocompleteService.js';
|
||||
import { fromJson } from '../../utils.js';
|
||||
import { trackFeature } from '../../services/tracking/Tracker.js';
|
||||
import { FEATURES } from '../../features.js';
|
||||
import { trackPoi } from '../../services/tracking/Tracker.js';
|
||||
import { TRACKING_POIS } from '../../TRACKING_POIS.js';
|
||||
import logger from '../../services/logger.js';
|
||||
import { runGeoCordTask } from '../../services/crons/geocoding-cron.js';
|
||||
|
||||
@@ -53,7 +53,7 @@ userSettingsRouter.post('/home-address', async (req, res) => {
|
||||
|
||||
try {
|
||||
if (home_address) {
|
||||
await trackFeature(FEATURES.DISTANCE_ADDRESS_ENTERED);
|
||||
await trackPoi(TRACKING_POIS.DISTANCE_ADDRESS_ENTERED);
|
||||
const coords = await geocodeAddress(home_address);
|
||||
if (coords && coords.lat !== -1) {
|
||||
upsertSettings({ home_address: { address: home_address, coords } }, userId);
|
||||
@@ -76,4 +76,25 @@ userSettingsRouter.post('/home-address', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
userSettingsRouter.post('/news-hash', async (req, res) => {
|
||||
const userId = req.session.currentUser;
|
||||
const { news_hash } = req.body;
|
||||
|
||||
const globalSettings = await getSettings();
|
||||
if (globalSettings.demoMode) {
|
||||
res.statusCode = 403;
|
||||
res.send({ error: 'In demo mode, it is not allowed to change settings.' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
upsertSettings({ news_hash }, userId);
|
||||
res.send({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Error updating news hash', error);
|
||||
res.statusCode = 500;
|
||||
res.send({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
export { userSettingsRouter };
|
||||
|
||||
@@ -22,7 +22,7 @@ puppeteer.use(StealthPlugin());
|
||||
export default async function execute(url, waitForSelector, options) {
|
||||
let browser;
|
||||
let page;
|
||||
let result = null;
|
||||
let result;
|
||||
let userDataDir;
|
||||
let removeUserDataDir = false;
|
||||
try {
|
||||
|
||||
@@ -88,7 +88,7 @@ export function up(db) {
|
||||
}
|
||||
} catch (e) {
|
||||
// If parsing fails, let it throw to rollback the migration
|
||||
throw new Error(`Failed to import users from ${usersJsonPath}: ${e.message}`);
|
||||
throw new Error(`Failed to import users from ${usersJsonPath}: ${e.message}`, { cause: e });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ export function up(db) {
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to import jobs from ${jobsJsonPath}: ${e.message}`);
|
||||
throw new Error(`Failed to import jobs from ${jobsJsonPath}: ${e.message}`, { cause: e });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,92 +14,89 @@ import { getSettings } from '../storage/settingsStorage.js';
|
||||
const deviceId = getUniqueId() || 'N/A';
|
||||
const version = await getPackageVersion();
|
||||
const FREDY_TRACKING_URL = 'https://fredy.orange-coding.net/tracking';
|
||||
const isDocker = process.env.IS_DOCKER != null;
|
||||
|
||||
export const trackMainEvent = async () => {
|
||||
const staticTrackingData = {
|
||||
operatingSystem: os.platform(),
|
||||
osVersion: os.release(),
|
||||
isDocker,
|
||||
arch: process.arch,
|
||||
language: process.env.LANG || 'en',
|
||||
nodeVersion: process.version || 'N/A',
|
||||
deviceId,
|
||||
version,
|
||||
};
|
||||
|
||||
const shouldTrack = async () => {
|
||||
const settings = await getSettings();
|
||||
return settings.analyticsEnabled && !inDevMode();
|
||||
};
|
||||
|
||||
const sendTrackingData = async (endpoint, payload) => {
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
if (settings.analyticsEnabled && !inDevMode()) {
|
||||
const activeProvider = new Set();
|
||||
const activeAdapter = new Set();
|
||||
|
||||
const jobs = getJobs();
|
||||
|
||||
if (jobs != null && jobs.length > 0) {
|
||||
jobs.forEach((job) => {
|
||||
job.provider.forEach((provider) => activeProvider.add(provider.id));
|
||||
job.notificationAdapter.forEach((adapter) => activeAdapter.add(adapter.id));
|
||||
});
|
||||
|
||||
const trackingObj = enrichTrackingObject({
|
||||
adapter: Array.from(activeAdapter),
|
||||
provider: Array.from(activeProvider),
|
||||
});
|
||||
|
||||
await fetch(`${FREDY_TRACKING_URL}/main`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(trackingObj),
|
||||
});
|
||||
}
|
||||
const response = await fetch(`${FREDY_TRACKING_URL}${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: payload ? JSON.stringify(payload) : undefined,
|
||||
});
|
||||
if (!response.ok) {
|
||||
logger.warn(`Error sending tracking data to ${endpoint}. Status: ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Error sending tracking data', error);
|
||||
logger.warn(`Error sending tracking data to ${endpoint}`, error);
|
||||
}
|
||||
};
|
||||
|
||||
export const trackFeature = async (feature) => {
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
if (settings.analyticsEnabled && !inDevMode()) {
|
||||
const trackingObj = await enrichTrackingObject({
|
||||
feature,
|
||||
});
|
||||
export const trackMainEvent = async () => {
|
||||
if (!(await shouldTrack())) return;
|
||||
|
||||
await fetch(`${FREDY_TRACKING_URL}/feature`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(trackingObj),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Error tracking feature', error);
|
||||
const activeProvider = new Set();
|
||||
const activeAdapter = new Set();
|
||||
|
||||
const jobs = getJobs();
|
||||
|
||||
if (jobs != null && jobs.length > 0) {
|
||||
jobs.forEach((job) => {
|
||||
job.provider.forEach((provider) => activeProvider.add(provider.id));
|
||||
job.notificationAdapter.forEach((adapter) => activeAdapter.add(adapter.id));
|
||||
});
|
||||
|
||||
const trackingObj = await enrichTrackingObject({
|
||||
adapter: Array.from(activeAdapter),
|
||||
provider: Array.from(activeProvider),
|
||||
});
|
||||
|
||||
await sendTrackingData('/main', trackingObj);
|
||||
}
|
||||
};
|
||||
|
||||
export const trackPoi = async (poi) => {
|
||||
if (!(await shouldTrack())) return;
|
||||
|
||||
const trackingObj = await enrichTrackingObject({
|
||||
feature: poi,
|
||||
});
|
||||
|
||||
await sendTrackingData('/feature', trackingObj);
|
||||
};
|
||||
|
||||
/**
|
||||
* Note, this will only be used when Fredy runs in demo mode
|
||||
*/
|
||||
export async function trackDemoAccessed() {
|
||||
const settings = await getSettings();
|
||||
if (settings.analyticsEnabled && !inDevMode() && settings.demoMode) {
|
||||
try {
|
||||
await fetch(`${FREDY_TRACKING_URL}/demo/accessed`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn('Error sending tracking data', error);
|
||||
}
|
||||
const trackingObj = await enrichTrackingObject({});
|
||||
await sendTrackingData('/demo/accessed', trackingObj);
|
||||
}
|
||||
}
|
||||
|
||||
async function enrichTrackingObject(trackingObject) {
|
||||
const settings = await getSettings();
|
||||
const operatingSystem = os.platform();
|
||||
const osVersion = os.release();
|
||||
const arch = process.arch;
|
||||
const language = process.env.LANG || 'en';
|
||||
const nodeVersion = process.version || 'N/A';
|
||||
|
||||
return {
|
||||
...trackingObject,
|
||||
...staticTrackingData,
|
||||
isDemo: settings.demoMode,
|
||||
operatingSystem,
|
||||
osVersion,
|
||||
arch,
|
||||
nodeVersion,
|
||||
language,
|
||||
deviceId,
|
||||
version,
|
||||
};
|
||||
}
|
||||
|
||||
24
package.json
24
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "fredy",
|
||||
"version": "19.3.5",
|
||||
"version": "19.4.0",
|
||||
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
||||
"scripts": {
|
||||
"prepare": "husky",
|
||||
@@ -17,7 +17,8 @@
|
||||
"lint:fix": "yarn lint --fix",
|
||||
"migratedb": "node lib/services/storage/migrations/migrate.js",
|
||||
"migratedb:overwrite": "x-var MIGRATION_ALLOW_CHECKSUM_UPDATE=true node lib/services/storage/migrations/migrate.js",
|
||||
"copyright": "node ./copyright.js"
|
||||
"copyright": "node ./copyright.js",
|
||||
"release": "node ./tools/release/release.js"
|
||||
},
|
||||
"type": "module",
|
||||
"lint-staged": {
|
||||
@@ -59,11 +60,11 @@
|
||||
"Firefox ESR"
|
||||
],
|
||||
"dependencies": {
|
||||
"@douyinfe/semi-icons": "^2.90.13",
|
||||
"@douyinfe/semi-ui": "2.90.13",
|
||||
"@douyinfe/semi-ui-19": "^2.90.13",
|
||||
"@douyinfe/semi-icons": "^2.91.0",
|
||||
"@douyinfe/semi-ui": "2.91.0",
|
||||
"@douyinfe/semi-ui-19": "^2.91.0",
|
||||
"@sendgrid/mail": "8.1.6",
|
||||
"@vitejs/plugin-react": "5.1.3",
|
||||
"@vitejs/plugin-react": "5.1.4",
|
||||
"adm-zip": "^0.5.16",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"body-parser": "2.2.2",
|
||||
@@ -72,14 +73,14 @@
|
||||
"cookie-session": "2.1.1",
|
||||
"handlebars": "4.7.8",
|
||||
"lodash": "4.17.23",
|
||||
"maplibre-gl": "^5.17.0",
|
||||
"maplibre-gl": "^5.18.0",
|
||||
"nanoid": "5.1.6",
|
||||
"node-cron": "^4.2.1",
|
||||
"node-fetch": "3.3.2",
|
||||
"node-mailjet": "6.0.11",
|
||||
"p-throttle": "^8.1.0",
|
||||
"package-up": "^5.0.0",
|
||||
"puppeteer": "^24.36.1",
|
||||
"puppeteer": "^24.37.2",
|
||||
"puppeteer-extra": "^3.3.6",
|
||||
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
||||
"query-string": "9.3.1",
|
||||
@@ -90,7 +91,7 @@
|
||||
"react-router": "7.13.0",
|
||||
"react-router-dom": "7.13.0",
|
||||
"restana": "5.1.0",
|
||||
"semver": "^7.7.3",
|
||||
"semver": "^7.7.4",
|
||||
"serve-static": "2.2.1",
|
||||
"slack": "11.0.2",
|
||||
"vite": "7.3.1",
|
||||
@@ -102,11 +103,14 @@
|
||||
"@babel/eslint-parser": "7.28.6",
|
||||
"@babel/preset-env": "7.29.0",
|
||||
"@babel/preset-react": "7.28.5",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"chai": "6.2.2",
|
||||
"eslint": "9.39.2",
|
||||
"chalk": "^5.6.2",
|
||||
"eslint": "10.0.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-plugin-react": "7.37.5",
|
||||
"esmock": "2.7.3",
|
||||
"globals": "^17.3.0",
|
||||
"history": "5.3.0",
|
||||
"husky": "9.1.7",
|
||||
"less": "4.5.1",
|
||||
|
||||
196
tools/release/release.js
Normal file
196
tools/release/release.js
Normal file
@@ -0,0 +1,196 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { execSync, spawn } from 'child_process';
|
||||
import fetch from 'node-fetch';
|
||||
import chalk from 'chalk';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
/**
|
||||
* Release Tool for Fredy
|
||||
*
|
||||
* This tool automates the process of creating a GitHub release.
|
||||
* It fetches the latest release, compares it with the current master branch,
|
||||
* allows manual editing of commit messages, and creates a new release on GitHub.
|
||||
*/
|
||||
|
||||
// Define __dirname for ESM
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Configuration and Paths
|
||||
const CONFIG_PATH = path.join(__dirname, 'config.json');
|
||||
const PACKAGE_JSON_PATH = path.join(__dirname, '../../package.json');
|
||||
const REPO = 'orangecoding/fredy';
|
||||
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
|
||||
const GITHUB_TOKEN = config.github_token;
|
||||
|
||||
/**
|
||||
* Main function to execute the release process
|
||||
*/
|
||||
async function createRelease() {
|
||||
/* eslint-disable no-console */
|
||||
try {
|
||||
console.log(chalk.cyan('🚀 Starting release process...'));
|
||||
|
||||
// 1. Load Configuration
|
||||
if (!fs.existsSync(CONFIG_PATH)) {
|
||||
console.error(chalk.red('❌ Error: config.json not found in tools/release/'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!GITHUB_TOKEN) {
|
||||
console.error(chalk.red('❌ Error: GitHub token not configured.'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 2. Get current version from package.json
|
||||
const packageJson = JSON.parse(fs.readFileSync(PACKAGE_JSON_PATH, 'utf8'));
|
||||
const version = packageJson.version;
|
||||
const tag = version; // Using version as tag
|
||||
|
||||
console.log(chalk.blue(`📦 Target version: ${version}`));
|
||||
|
||||
// 3. Check if release already exists
|
||||
console.log(chalk.yellow('🔍 Checking if release already exists...'));
|
||||
const existingReleaseResponse = await fetch(`https://api.github.com/repos/${REPO}/releases/tags/${tag}`, {
|
||||
headers: {
|
||||
Authorization: `token ${GITHUB_TOKEN}`,
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
},
|
||||
});
|
||||
|
||||
if (existingReleaseResponse.status === 200) {
|
||||
console.error(chalk.red(`❌ Error: A release with tag ${tag} already exists.`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 4. Fetch latest release to find the starting point for the diff
|
||||
console.log(chalk.yellow('📡 Fetching latest release from GitHub...'));
|
||||
const latestReleaseResponse = await fetch(`https://api.github.com/repos/${REPO}/releases/latest`, {
|
||||
headers: {
|
||||
Authorization: `token ${GITHUB_TOKEN}`,
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!latestReleaseResponse.ok) {
|
||||
console.error(chalk.red('❌ Error fetching latest release.'));
|
||||
const errorData = await latestReleaseResponse.json();
|
||||
console.error(chalk.red(JSON.stringify(errorData)));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const latestRelease = await latestReleaseResponse.json();
|
||||
const latestTag = latestRelease.tag_name;
|
||||
console.log(chalk.green(`✅ Latest release found: ${latestTag}`));
|
||||
|
||||
// 5. Ensure the latest tag is available locally
|
||||
console.log(chalk.yellow(`📡 Fetching tag ${latestTag} from remote...`));
|
||||
try {
|
||||
execSync(`git fetch origin tag ${latestTag} --no-tags`);
|
||||
} catch (error) {
|
||||
console.error(chalk.red(`❌ Error fetching tag ${latestTag} from origin.`));
|
||||
console.error(error.message);
|
||||
// We don't exit here, maybe it's already there but fetch failed for some reason
|
||||
}
|
||||
|
||||
// 6. Get commit messages between latest tag and current HEAD
|
||||
console.log(chalk.yellow(`Git diff: ${latestTag} .. HEAD`));
|
||||
let commitMessages;
|
||||
try {
|
||||
commitMessages = execSync(`git log ${latestTag}..HEAD --pretty=format:"- %s"`).toString().trim();
|
||||
} catch (error) {
|
||||
console.error(chalk.red('❌ Error running git log. Make sure the latest tag is available locally.'), error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!commitMessages) {
|
||||
console.log(chalk.magenta('⚠️ No new commits found since last release.'));
|
||||
commitMessages = '- No changes recorded';
|
||||
}
|
||||
|
||||
// 7. Open commit messages in editor for manual adjustment
|
||||
const tempFilePath = path.join(__dirname, 'CHANGELOG_EDIT.tmp');
|
||||
const initialContent = `# Release Notes for ${version}\n# Edit the messages below. Lines starting with # will be ignored.\n\n${commitMessages}`;
|
||||
fs.writeFileSync(tempFilePath, initialContent);
|
||||
|
||||
console.log(chalk.blue('📝 Opening editor for release notes (using nano or $EDITOR)...'));
|
||||
await openInEditor(tempFilePath);
|
||||
|
||||
// 8. Read edited content
|
||||
let editedContent = fs
|
||||
.readFileSync(tempFilePath, 'utf8')
|
||||
.split('\n')
|
||||
.filter((line) => !line.startsWith('#'))
|
||||
.join('\n')
|
||||
.trim();
|
||||
|
||||
fs.unlinkSync(tempFilePath); // Clean up temp file
|
||||
|
||||
if (!editedContent) {
|
||||
console.error(chalk.red('❌ Release notes are empty. Aborting release.'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 9. Create the new release
|
||||
console.log(chalk.cyan(`🚀 Creating release ${version} on GitHub...`));
|
||||
const createResponse = await fetch(`https://api.github.com/repos/${REPO}/releases`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `token ${GITHUB_TOKEN}`,
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
tag_name: tag,
|
||||
name: version,
|
||||
body: editedContent,
|
||||
draft: false,
|
||||
prerelease: false,
|
||||
}),
|
||||
});
|
||||
|
||||
if (createResponse.status === 201) {
|
||||
const data = await createResponse.json();
|
||||
console.log(chalk.green('🎉 Release successfully created!'));
|
||||
console.log(chalk.green(`🔗 URL: ${data.html_url}`));
|
||||
} else {
|
||||
const errorData = await createResponse.json();
|
||||
console.error(chalk.red('❌ Failed to create release.'));
|
||||
console.error(chalk.red(JSON.stringify(errorData, null, 2)));
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(chalk.red('💥 An unexpected error occurred:'));
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to open a file in a terminal editor
|
||||
* @param {string} filePath
|
||||
*/
|
||||
function openInEditor(filePath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const editor = process.env.EDITOR || 'nano';
|
||||
const child = spawn(editor, [filePath], {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
child.on('exit', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Editor exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
await createRelease();
|
||||
/* eslint-enable no-console */
|
||||
@@ -11,7 +11,7 @@ import GeneralSettings from './views/generalSettings/GeneralSettings';
|
||||
import UserSettings from './views/userSettings/UserSettings';
|
||||
import JobMutation from './views/jobs/mutation/JobMutation';
|
||||
import UserMutator from './views/user/mutation/UserMutator';
|
||||
import { useActions, useSelector } from './services/state/store';
|
||||
import { useActions, useSelector, useFredyState } from './services/state/store';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import Login from './views/login/Login';
|
||||
import Users from './views/user/Users';
|
||||
@@ -29,6 +29,7 @@ import FredyFooter from './components/footer/FredyFooter.jsx';
|
||||
import WatchlistManagement from './views/listings/management/WatchlistManagement.jsx';
|
||||
import Dashboard from './views/dashboard/Dashboard.jsx';
|
||||
import ListingDetail from './views/listings/ListingDetail.jsx';
|
||||
import NewsModal from './components/news/NewsModal.jsx';
|
||||
|
||||
export default function FredyApp() {
|
||||
const actions = useActions();
|
||||
@@ -40,20 +41,24 @@ export default function FredyApp() {
|
||||
useEffect(() => {
|
||||
async function init() {
|
||||
await actions.user.getCurrentUser();
|
||||
if (!needsLogin()) {
|
||||
await actions.provider.getProvider();
|
||||
await actions.jobsData.getJobs();
|
||||
await actions.jobsData.getSharableUserList();
|
||||
await actions.notificationAdapter.getAdapter();
|
||||
await actions.generalSettings.getGeneralSettings();
|
||||
await actions.userSettings.getUserSettings();
|
||||
await actions.versionUpdate.getVersionUpdate();
|
||||
const user = useFredyState.getState().user.currentUser;
|
||||
if (!user || Object.keys(user).length === 0) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
await actions.provider.getProvider();
|
||||
await actions.jobsData.getJobs();
|
||||
await actions.jobsData.getSharableUserList();
|
||||
await actions.notificationAdapter.getAdapter();
|
||||
await actions.generalSettings.getGeneralSettings();
|
||||
await actions.userSettings.getUserSettings();
|
||||
await actions.versionUpdate.getVersionUpdate();
|
||||
await actions.tracking.getTrackingPois();
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
init();
|
||||
}, [currentUser?.userId]);
|
||||
}, []);
|
||||
|
||||
const needsLogin = () => {
|
||||
return currentUser == null || Object.keys(currentUser).length === 0;
|
||||
@@ -63,10 +68,7 @@ export default function FredyApp() {
|
||||
const { Sider, Content } = Layout;
|
||||
|
||||
return loading ? null : needsLogin() ? (
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||
</Routes>
|
||||
<Login />
|
||||
) : (
|
||||
<Layout className="app">
|
||||
<Sider>
|
||||
@@ -88,6 +90,7 @@ export default function FredyApp() {
|
||||
</>
|
||||
)}
|
||||
{settings.analyticsEnabled === null && !settings.demoMode && <TrackingModal />}
|
||||
<NewsModal />
|
||||
<Routes>
|
||||
<Route path="/403" element={<InsufficientPermission />} />
|
||||
<Route path="/jobs/new" element={<JobMutation />} />
|
||||
@@ -134,6 +137,7 @@ export default function FredyApp() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Authenticated fallbacks */}
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
</Content>
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { HashRouter } from 'react-router-dom';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import en_US from '@douyinfe/semi-ui-19/lib/es/locale/source/en_US';
|
||||
|
||||
BIN
ui/src/assets/news/1.png
Normal file
BIN
ui/src/assets/news/1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 531 KiB |
BIN
ui/src/assets/news/2.png
Normal file
BIN
ui/src/assets/news/2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.8 MiB |
BIN
ui/src/assets/news/3.png
Normal file
BIN
ui/src/assets/news/3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.7 MiB |
21
ui/src/assets/news/news.json
Normal file
21
ui/src/assets/news/news.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"key": "00e6b81777a275f5a140fc9101cb943810db6a69f6eb3927319c5aee0c876509",
|
||||
"content":
|
||||
[
|
||||
{
|
||||
"title": "Welcome to Fredy",
|
||||
"text": "Thanks for choosing Fredy!<br/>With Fredy you can quickly set up scraping jobs that run automatically every few minutes and continuously search platforms like ImmoScout and similar sites for new listings that match your criteria. Instead of manually refreshing pages and risking to miss out on good offers, Fredy monitors the market in the background and notifies you as soon as something relevant appears.",
|
||||
"image": "1.png"
|
||||
},
|
||||
{
|
||||
"title": "Stay in Control with the Grid View",
|
||||
"text": "The grid view gives you a clean and structured overview of all matching listings. You can instantly compare prices, sizes, locations and key details without leaving Fredy. Open listings in a focused detail view, bookmark interesting ones and keep track of what you have already checked.",
|
||||
"image": "2.png"
|
||||
},
|
||||
{
|
||||
"title": "Explore Listings on the Map",
|
||||
"text": "Switch to the map view to understand the bigger picture. See exactly where listings are located and evaluate neighborhoods at a glance. This helps you spot hidden gems, avoid unwanted areas and make faster, better decisions based on location.<br/>If you want Fredy to calculate commute times for you, head over to the User-Specific Settings and add your home address.",
|
||||
"image": "3.png"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Modal, Radio, RadioGroup, Typography } from '@douyinfe/semi-ui-19';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -2,30 +2,29 @@
|
||||
width: 100%;
|
||||
height: 140px;
|
||||
margin-bottom: 16px;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
transition: transform 0.2s;
|
||||
background-color: rgba(36, 36, 36, 0.9);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--semi-color-border);
|
||||
--pulse-color: rgba(255, 255, 255, 0.1);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
overflow: visible;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
background-color: rgba(36, 36, 36, 1);
|
||||
|
||||
&.blue {
|
||||
box-shadow: 0 8px 24px -5px var(--semi-color-primary);
|
||||
}
|
||||
&.orange {
|
||||
box-shadow: 0 8px 24px -5px var(--semi-color-warning);
|
||||
}
|
||||
&.green {
|
||||
box-shadow: 0 8px 24px -5px var(--semi-color-success);
|
||||
}
|
||||
&.purple {
|
||||
box-shadow: 0 8px 24px -5px var(--semi-color-info);
|
||||
}
|
||||
&.gray {
|
||||
box-shadow: 0 8px 24px -5px rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
border-radius: inherit;
|
||||
box-shadow: 0 4px 25px -2px var(--pulse-color);
|
||||
opacity: 0;
|
||||
animation: pulse 5s infinite ease-in-out;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
will-change: opacity;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
@@ -46,22 +45,36 @@
|
||||
}
|
||||
|
||||
&.blue {
|
||||
box-shadow: 0 4px 20px -5px var(--semi-color-primary);
|
||||
--pulse-color: var(--semi-color-primary);
|
||||
box-shadow: 0 4px 20px -5px var(--pulse-color);
|
||||
}
|
||||
|
||||
&.orange {
|
||||
box-shadow: 0 4px 20px -5px var(--semi-color-warning);
|
||||
--pulse-color: var(--semi-color-warning);
|
||||
box-shadow: 0 4px 20px -5px var(--pulse-color);
|
||||
}
|
||||
|
||||
&.green {
|
||||
box-shadow: 0 4px 20px -5px var(--semi-color-success);
|
||||
--pulse-color: var(--semi-color-success);
|
||||
box-shadow: 0 4px 20px -5px var(--pulse-color);
|
||||
}
|
||||
|
||||
&.purple {
|
||||
box-shadow: 0 4px 20px -5px var(--semi-color-info);
|
||||
--pulse-color: var(--semi-color-info);
|
||||
box-shadow: 0 4px 20px -5px var(--pulse-color);
|
||||
}
|
||||
|
||||
&.gray {
|
||||
box-shadow: 0 4px 20px -5px rgba(255, 255, 255, 0.2);
|
||||
--pulse-color: rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 4px 20px -5px var(--pulse-color);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 0.1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Card, Typography, Space } from '@douyinfe/semi-ui-19';
|
||||
import './DashboardCard.less';
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import './FredyFooter.less';
|
||||
import { useSelector } from '../../services/state/store.js';
|
||||
import { Typography, Layout, Space, Divider } from '@douyinfe/semi-ui-19';
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Col,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Col,
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Typography } from '@douyinfe/semi-ui-19';
|
||||
|
||||
export default function Headline({ text, size = 3 } = {}) {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import logo from '../../assets/logo.png';
|
||||
import logoWhite from '../../assets/logo_white.png';
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Button } from '@douyinfe/semi-ui-19';
|
||||
import { xhrPost } from '../../services/xhr';
|
||||
import { IconUser } from '@douyinfe/semi-icons';
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Nav } from '@douyinfe/semi-ui-19';
|
||||
import { IconStar, IconSetting, IconTerminal, IconHistogram, IconSidebar } from '@douyinfe/semi-icons';
|
||||
import logoWhite from '../../assets/logo_white.png';
|
||||
|
||||
68
ui/src/components/news/NewsModal.jsx
Normal file
68
ui/src/components/news/NewsModal.jsx
Normal file
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { UserGuide } from '@douyinfe/semi-ui-19';
|
||||
import { useScreenWidth } from '../../hooks/screenWidth';
|
||||
import heart from '../../assets/heart.png';
|
||||
import newsConfig from '../../assets/news/news.json';
|
||||
import { useActions, useSelector } from '../../services/state/store';
|
||||
|
||||
import './NewsModal.less';
|
||||
|
||||
const NewsModal = () => {
|
||||
const screenWidth = useScreenWidth();
|
||||
const newsHash = useSelector((state) => state.userSettings.settings.news_hash);
|
||||
const userSettingsLoaded = useSelector((state) => state.userSettings.loaded);
|
||||
const pois = useSelector((state) => state.tracking.pois);
|
||||
const actions = useActions();
|
||||
|
||||
if (newsConfig == null || newsConfig.length === 0 || screenWidth <= 768) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const steps = newsConfig.content.map((item) => ({
|
||||
title: (
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<img src={heart} width="30" alt="Fredy Logo" style={{ marginRight: '10px' }} />
|
||||
<b>{item.title}</b>
|
||||
</div>
|
||||
),
|
||||
description: (
|
||||
<div style={{ textAlign: 'left' }}>
|
||||
{item.image && (
|
||||
<img
|
||||
src={`/ui/src/assets/news/${item.image}`}
|
||||
alt={item.title}
|
||||
style={{ width: '100%', marginBottom: 10, borderRadius: 4 }}
|
||||
/>
|
||||
)}
|
||||
<p dangerouslySetInnerHTML={{ __html: item.text }} />
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const handleClose = (poi) => {
|
||||
actions.userSettings.setNewsHash(newsConfig.key);
|
||||
if (poi) {
|
||||
actions.tracking.trackPoi(poi);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<UserGuide
|
||||
mode="modal"
|
||||
mask={true}
|
||||
steps={steps}
|
||||
visible={userSettingsLoaded && newsHash !== newsConfig.key}
|
||||
onFinish={() => handleClose(pois.WELCOME_FINISHED)}
|
||||
onSkip={() => handleClose(pois.WELCOME_SKIPPED)}
|
||||
modalProps={{
|
||||
width: '10rem',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewsModal;
|
||||
3
ui/src/components/news/NewsModal.less
Normal file
3
ui/src/components/news/NewsModal.less
Normal file
@@ -0,0 +1,3 @@
|
||||
.semi-userGuide-modal-body-title {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import insufficientPermission from '../../assets/insufficient_permission.png';
|
||||
|
||||
export default function InsufficientPermission() {
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { Navigate } from 'react-router-dom';
|
||||
|
||||
export default function PermissionAwareRoute({ currentUser, children }) {
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import './Placeholder.less';
|
||||
|
||||
function getPlaceholder(rowCount, className) {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Card } from '@douyinfe/semi-ui-19';
|
||||
|
||||
import './SegmentParts.less';
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { Empty, Table, Button } from '@douyinfe/semi-ui-19';
|
||||
import { IconDelete, IconEdit } from '@douyinfe/semi-icons';
|
||||
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { Empty, Table, Button } from '@douyinfe/semi-ui-19';
|
||||
import { IconDelete, IconEdit } from '@douyinfe/semi-icons';
|
||||
import { Typography } from '@douyinfe/semi-ui';
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
||||
import { format } from '../../services/time/timeService';
|
||||
import { Table, Button, Empty } from '@douyinfe/semi-ui-19';
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Modal } from '@douyinfe/semi-ui-19';
|
||||
import Logo from '../logo/Logo.jsx';
|
||||
import { xhrPost } from '../../services/xhr.js';
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Collapse, Descriptions } from '@douyinfe/semi-ui-19';
|
||||
import { useSelector } from '../../services/state/store.js';
|
||||
import { MarkdownRender } from '@douyinfe/semi-ui-19';
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
import { create } from 'zustand';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
import { xhrGet } from '../xhr.js';
|
||||
import { xhrGet, xhrPost } from '../xhr.js';
|
||||
import queryString from 'query-string';
|
||||
|
||||
const logger = (config) => (set, get, api) =>
|
||||
@@ -27,10 +27,21 @@ const logger = (config) => (set, get, api) =>
|
||||
api,
|
||||
);
|
||||
|
||||
/**
|
||||
* Middleware to track loading state of async actions.
|
||||
*/
|
||||
const loadingTracker = (config) => (set, get, api) => {
|
||||
const wrappedSet = (partial, replace) => {
|
||||
set(partial, replace);
|
||||
};
|
||||
|
||||
return config(wrappedSet, get, api);
|
||||
};
|
||||
|
||||
// Create the Zustand store with slices and actions
|
||||
export const useFredyState = create(
|
||||
logger(
|
||||
(set) => {
|
||||
loadingTracker((set) => {
|
||||
// Async actions that directly set state (no separate reducer concept)
|
||||
const effects = {
|
||||
dashboard: {
|
||||
@@ -169,6 +180,23 @@ export const useFredyState = create(
|
||||
}
|
||||
},
|
||||
},
|
||||
tracking: {
|
||||
async getTrackingPois() {
|
||||
try {
|
||||
const response = await xhrGet('/api/tracking/trackingPois');
|
||||
set((state) => ({ tracking: { ...state.tracking, pois: Object.freeze(response.json) } }));
|
||||
} catch (Exception) {
|
||||
console.error('Error while trying to get resource for api/tracking. Error:', Exception);
|
||||
}
|
||||
},
|
||||
async trackPoi(poi) {
|
||||
try {
|
||||
await xhrPost('/api/tracking/poi', { poi });
|
||||
} catch (Exception) {
|
||||
console.error('Error while trying to track poi. Error:', Exception);
|
||||
}
|
||||
},
|
||||
},
|
||||
listingsData: {
|
||||
async getListingsData({
|
||||
page = 1,
|
||||
@@ -234,9 +262,46 @@ export const useFredyState = create(
|
||||
async getUserSettings() {
|
||||
try {
|
||||
const response = await xhrGet('/api/user/settings');
|
||||
set((state) => ({ userSettings: { ...state.userSettings, settings: response.json } }));
|
||||
set((state) => ({ userSettings: { ...state.userSettings, settings: response.json, loaded: true } }));
|
||||
} catch (Exception) {
|
||||
console.error('Error while trying to get resource for api/user/settings. Error:', Exception);
|
||||
// Mark as loaded even on error to prevent blocking the UI
|
||||
set((state) => ({ userSettings: { ...state.userSettings, loaded: true } }));
|
||||
}
|
||||
},
|
||||
async setNewsHash(newsHash) {
|
||||
try {
|
||||
await xhrPost('/api/user/settings/news-hash', { news_hash: newsHash });
|
||||
set((state) => ({
|
||||
userSettings: {
|
||||
...state.userSettings,
|
||||
settings: { ...state.userSettings.settings, news_hash: newsHash },
|
||||
},
|
||||
}));
|
||||
} catch (Exception) {
|
||||
console.error('Error while trying to update news hash. Error:', Exception);
|
||||
throw Exception;
|
||||
}
|
||||
},
|
||||
async setHomeAddress(address) {
|
||||
try {
|
||||
const response = await xhrPost('/api/user/settings/home-address', { home_address: address });
|
||||
if (response.status === 200) {
|
||||
set((state) => ({
|
||||
userSettings: {
|
||||
...state.userSettings,
|
||||
settings: {
|
||||
...state.userSettings.settings,
|
||||
home_address: { address, coords: response.json.coords },
|
||||
},
|
||||
},
|
||||
}));
|
||||
return response.json;
|
||||
}
|
||||
throw response;
|
||||
} catch (Exception) {
|
||||
console.error('Error while trying to update home address. Error:', Exception);
|
||||
throw Exception;
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -255,9 +320,10 @@ export const useFredyState = create(
|
||||
maxPrice: 0,
|
||||
},
|
||||
generalSettings: { settings: {} },
|
||||
userSettings: { settings: {} },
|
||||
userSettings: { settings: {}, loaded: false },
|
||||
demoMode: { demoMode: false },
|
||||
versionUpdate: {},
|
||||
tracking: { pois: {} },
|
||||
provider: [],
|
||||
jobsData: {
|
||||
jobs: [],
|
||||
@@ -276,6 +342,7 @@ export const useFredyState = create(
|
||||
generalSettings: { ...effects.generalSettings },
|
||||
demoMode: { ...effects.demoMode },
|
||||
versionUpdate: { ...effects.versionUpdate },
|
||||
tracking: { ...effects.tracking },
|
||||
listingsData: { ...effects.listingsData },
|
||||
provider: { ...effects.provider },
|
||||
jobsData: { ...effects.jobsData },
|
||||
@@ -283,12 +350,34 @@ export const useFredyState = create(
|
||||
userSettings: { ...effects.userSettings },
|
||||
};
|
||||
|
||||
// Wrap actions to track loading state
|
||||
const wrappedActions = {};
|
||||
Object.keys(actions).forEach((slice) => {
|
||||
wrappedActions[slice] = {};
|
||||
Object.keys(actions[slice]).forEach((actionName) => {
|
||||
const originalAction = actions[slice][actionName];
|
||||
if (typeof originalAction === 'function') {
|
||||
wrappedActions[slice][actionName] = async (...args) => {
|
||||
const fullActionName = `${slice}.${actionName}`;
|
||||
set((state) => ({ loading: { ...state.loading, [fullActionName]: true } }));
|
||||
try {
|
||||
return await originalAction(...args);
|
||||
} finally {
|
||||
set((state) => ({ loading: { ...state.loading, [fullActionName]: false } }));
|
||||
}
|
||||
};
|
||||
} else {
|
||||
wrappedActions[slice][actionName] = originalAction;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
...initial,
|
||||
__actions: { actions },
|
||||
loading: {},
|
||||
__actions: { actions: wrappedActions },
|
||||
};
|
||||
},
|
||||
{ name: 'fredy' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -312,3 +401,27 @@ export function useSelector(selector, equalityFn = shallow) {
|
||||
export function useActions() {
|
||||
return useFredyState((s) => s.__actions.actions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to check if a specific action is currently loading.
|
||||
* @param {Function} action - The action function from useActions()
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function useIsLoading(action) {
|
||||
const actions = useActions();
|
||||
const loading = useSelector((state) => state.loading);
|
||||
|
||||
// Find the action name by comparing the function
|
||||
let actionPath = null;
|
||||
for (const slice in actions) {
|
||||
for (const name in actions[slice]) {
|
||||
if (actions[slice][name] === action) {
|
||||
actionPath = `${slice}.${name}`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (actionPath) break;
|
||||
}
|
||||
|
||||
return !!loading[actionPath];
|
||||
}
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import JobGrid from '../../components/grid/jobs/JobGrid.jsx';
|
||||
import './Jobs.less';
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React, { Fragment, useState } from 'react';
|
||||
import { Fragment, useState } from 'react';
|
||||
|
||||
import NotificationAdapterMutator from './components/notificationAdapter/NotificationAdapterMutator';
|
||||
import NotificationAdapterTable from '../../../components/table/NotificationAdapterTable';
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { transform } from '../../../../../services/transformer/notificationAdapterTransformer';
|
||||
import { xhrPost } from '../../../../../services/xhr';
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Banner, MarkdownRender } from '@douyinfe/semi-ui-19';
|
||||
|
||||
export default function Help({ readme }) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
import { Banner, Modal, Select, Input } from '@douyinfe/semi-ui-19';
|
||||
import { transform } from '../../../../../services/transformer/providerTransformer';
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useSelector, useActions } from '../../services/state/store.js';
|
||||
import {
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import ListingsGrid from '../../components/grid/listings/ListingsGrid.jsx';
|
||||
|
||||
export default function Listings() {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { renderToString } from 'react-dom/server';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { 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-19';
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Modal } from '@douyinfe/semi-ui-19';
|
||||
const UserRemovalModal = function UserRemovalModal({ onOk, onCancel }) {
|
||||
return (
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { Divider, Button, AutoComplete, Toast, Banner } from '@douyinfe/semi-ui-19';
|
||||
import { IconSave, IconHome } from '@douyinfe/semi-icons';
|
||||
import { useSelector, useActions } from '../../services/state/store';
|
||||
import { xhrGet, xhrPost } from '../../services/xhr';
|
||||
import { useSelector, useActions, useIsLoading } from '../../services/state/store';
|
||||
import { xhrGet } from '../../services/xhr';
|
||||
import { SegmentPart } from '../../components/segment/SegmentPart';
|
||||
import debounce from 'lodash/debounce';
|
||||
|
||||
@@ -16,7 +16,7 @@ const UserSettings = () => {
|
||||
const homeAddress = useSelector((state) => state.userSettings.settings.home_address);
|
||||
const [address, setAddress] = useState(homeAddress?.address || '');
|
||||
const [coords, setCoords] = useState(homeAddress?.coords || null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const saving = useIsLoading(actions.userSettings.setHomeAddress);
|
||||
const [dataSource, setDataSource] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -25,22 +25,15 @@ const UserSettings = () => {
|
||||
}, [homeAddress]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const response = await xhrPost('/api/user/settings/home-address', { home_address: address });
|
||||
if (response.status === 200) {
|
||||
setCoords(response.json.coords);
|
||||
await actions.userSettings.getUserSettings();
|
||||
Toast.success(
|
||||
'Settings saved successfully. We will now start calculating distances for you. This may take a while and runs in the background.',
|
||||
);
|
||||
} else {
|
||||
Toast.error(response.json.error || 'Failed to save settings');
|
||||
}
|
||||
const responseJson = await actions.userSettings.setHomeAddress(address);
|
||||
setCoords(responseJson.coords);
|
||||
await actions.userSettings.getUserSettings();
|
||||
Toast.success(
|
||||
'Settings saved successfully. We will now start calculating distances for you. This may take a while and runs in the background.',
|
||||
);
|
||||
} catch (error) {
|
||||
Toast.error(error.json?.error || 'Error while saving settings');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user