mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b86e351007 | ||
|
|
19c4860da7 | ||
|
|
d98e06cfdf | ||
|
|
6ae0c9749b | ||
|
|
10e40e038e | ||
|
|
4ba6828939 | ||
|
|
d09770dae2 | ||
|
|
248e4d2562 | ||
|
|
7b8e961b49 | ||
|
|
f66ceccbb4 | ||
|
|
a3db725af6 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,3 +6,4 @@ npm-debug.log
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.idea
|
.idea
|
||||||
.vscode
|
.vscode
|
||||||
|
tools/release/config.json
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ WORKDIR /fredy
|
|||||||
RUN apk add --no-cache chromium curl
|
RUN apk add --no-cache chromium curl
|
||||||
|
|
||||||
ENV NODE_ENV=production \
|
ENV NODE_ENV=production \
|
||||||
|
IS_DOCKER=true \
|
||||||
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
|
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
|
||||||
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
|
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
|
||||||
|
|
||||||
|
|||||||
@@ -8,20 +8,20 @@ import js from '@eslint/js';
|
|||||||
import prettier from 'eslint-config-prettier';
|
import prettier from 'eslint-config-prettier';
|
||||||
import globals from 'globals';
|
import globals from 'globals';
|
||||||
import react from 'eslint-plugin-react';
|
import react from 'eslint-plugin-react';
|
||||||
import babelParser from '@babel/eslint-parser';
|
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
{
|
{
|
||||||
files: ['**/*.{js,jsx,ts,tsx}'],
|
|
||||||
ignores: ['**/node_modules/**', '**/dist/**', '**/build/**', '**/public/**', 'db/**', 'conf/**'],
|
ignores: ['**/node_modules/**', '**/dist/**', '**/build/**', '**/public/**', 'db/**', 'conf/**'],
|
||||||
},
|
},
|
||||||
js.configs.recommended,
|
|
||||||
prettier,
|
|
||||||
{
|
{
|
||||||
|
files: ['**/*.{js,jsx}'],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
parser: babelParser,
|
ecmaVersion: 'latest',
|
||||||
sourceType: 'module',
|
sourceType: 'module',
|
||||||
ecmaVersion: 2021,
|
parserOptions: {
|
||||||
|
ecmaFeatures: { jsx: true },
|
||||||
|
},
|
||||||
globals: {
|
globals: {
|
||||||
...globals.browser,
|
...globals.browser,
|
||||||
...globals.node,
|
...globals.node,
|
||||||
@@ -32,70 +32,14 @@ export default [
|
|||||||
after: 'readonly',
|
after: 'readonly',
|
||||||
it: 'readonly',
|
it: 'readonly',
|
||||||
},
|
},
|
||||||
parserOptions: { requireConfigFile: false },
|
|
||||||
},
|
},
|
||||||
plugins: { react },
|
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' } },
|
settings: { react: { version: 'detect' } },
|
||||||
|
rules: {
|
||||||
|
...js.configs.recommended.rules,
|
||||||
|
'no-console': ['error', { allow: ['warn', 'error'] }],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
prettier,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ listingsRouter.post('/watch', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
listingsRouter.delete('/job', async (req, res) => {
|
listingsRouter.delete('/job', async (req, res) => {
|
||||||
const { jobId } = req.body;
|
const { jobId, hardDelete = false } = req.body;
|
||||||
const settings = await getSettings();
|
const settings = await getSettings();
|
||||||
try {
|
try {
|
||||||
if (settings.demoMode) {
|
if (settings.demoMode) {
|
||||||
@@ -115,7 +115,7 @@ listingsRouter.delete('/job', async (req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
listingStorage.deleteListingsByJobId(jobId);
|
listingStorage.deleteListingsByJobId(jobId, hardDelete);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.send(new Error(error));
|
res.send(new Error(error));
|
||||||
logger.error(error);
|
logger.error(error);
|
||||||
@@ -124,10 +124,10 @@ listingsRouter.delete('/job', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
listingsRouter.delete('/', async (req, res) => {
|
listingsRouter.delete('/', async (req, res) => {
|
||||||
const { ids } = req.body;
|
const { ids, hardDelete = false } = req.body;
|
||||||
try {
|
try {
|
||||||
if (Array.isArray(ids) && ids.length > 0) {
|
if (Array.isArray(ids) && ids.length > 0) {
|
||||||
listingStorage.deleteListingsById(ids);
|
listingStorage.deleteListingsById(ids, hardDelete);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.send(new Error(error));
|
res.send(new Error(error));
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ puppeteer.use(StealthPlugin());
|
|||||||
export default async function execute(url, waitForSelector, options) {
|
export default async function execute(url, waitForSelector, options) {
|
||||||
let browser;
|
let browser;
|
||||||
let page;
|
let page;
|
||||||
let result = null;
|
let result;
|
||||||
let userDataDir;
|
let userDataDir;
|
||||||
let removeUserDataDir = false;
|
let removeUserDataDir = false;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -37,12 +37,12 @@ export const upsertJob = ({
|
|||||||
if (existing) {
|
if (existing) {
|
||||||
SqliteConnection.execute(
|
SqliteConnection.execute(
|
||||||
`UPDATE jobs
|
`UPDATE jobs
|
||||||
SET enabled = @enabled,
|
SET enabled = @enabled,
|
||||||
name = @name,
|
name = @name,
|
||||||
blacklist = @blacklist,
|
blacklist = @blacklist,
|
||||||
provider = @provider,
|
provider = @provider,
|
||||||
notification_adapter = @notification_adapter,
|
notification_adapter = @notification_adapter,
|
||||||
shared_with_user = @shareWithUsers
|
shared_with_user = @shareWithUsers
|
||||||
WHERE id = @id`,
|
WHERE id = @id`,
|
||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
@@ -87,10 +87,10 @@ export const getJob = (jobId) => {
|
|||||||
j.provider,
|
j.provider,
|
||||||
j.shared_with_user,
|
j.shared_with_user,
|
||||||
j.notification_adapter AS notificationAdapter,
|
j.notification_adapter AS notificationAdapter,
|
||||||
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id) AS numberOfFoundListings
|
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id AND l.is_active = 1 AND l.manually_deleted = 0) AS numberOfFoundListings
|
||||||
FROM jobs j
|
FROM jobs j
|
||||||
WHERE j.id = @id
|
WHERE j.id = @id
|
||||||
LIMIT 1`,
|
LIMIT 1`,
|
||||||
{ id: jobId },
|
{ id: jobId },
|
||||||
)[0];
|
)[0];
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
@@ -150,9 +150,10 @@ export const getJobs = () => {
|
|||||||
j.provider,
|
j.provider,
|
||||||
j.shared_with_user,
|
j.shared_with_user,
|
||||||
j.notification_adapter AS notificationAdapter,
|
j.notification_adapter AS notificationAdapter,
|
||||||
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id) AS numberOfFoundListings
|
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id AND l.is_active = 1 AND l.manually_deleted = 0) AS numberOfFoundListings
|
||||||
FROM jobs j
|
FROM jobs j
|
||||||
ORDER BY j.name IS NULL, j.name`,
|
WHERE j.enabled = 1
|
||||||
|
ORDER BY j.name IS NULL, j.name`,
|
||||||
);
|
);
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
...row,
|
...row,
|
||||||
@@ -250,11 +251,11 @@ export const queryJobs = ({
|
|||||||
j.provider,
|
j.provider,
|
||||||
j.shared_with_user,
|
j.shared_with_user,
|
||||||
j.notification_adapter AS notificationAdapter,
|
j.notification_adapter AS notificationAdapter,
|
||||||
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id) AS numberOfFoundListings
|
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id AND l.is_active = 1 AND l.manually_deleted = 0) AS numberOfFoundListings
|
||||||
FROM jobs j
|
FROM jobs j
|
||||||
${whereSql}
|
${whereSql}
|
||||||
${orderSql}
|
${orderSql}
|
||||||
LIMIT @limit OFFSET @offset`,
|
LIMIT @limit OFFSET @offset`,
|
||||||
params,
|
params,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -370,10 +370,18 @@ export const queryListings = ({
|
|||||||
* Delete all listings for a given job id.
|
* Delete all listings for a given job id.
|
||||||
*
|
*
|
||||||
* @param {string} jobId - The job identifier whose listings should be removed.
|
* @param {string} jobId - The job identifier whose listings should be removed.
|
||||||
* @returns {any} The result from SqliteConnection.execute (may contain changes count).
|
* @param {boolean} [hardDelete=false] - Whether to hard delete from DB or just mark as deleted.
|
||||||
|
* @returns {any} The result from SqliteConnection.execute.
|
||||||
*/
|
*/
|
||||||
export const deleteListingsByJobId = (jobId) => {
|
export const deleteListingsByJobId = (jobId, hardDelete = false) => {
|
||||||
if (!jobId) return;
|
if (!jobId) return;
|
||||||
|
if (hardDelete) {
|
||||||
|
return SqliteConnection.execute(
|
||||||
|
`DELETE FROM listings
|
||||||
|
WHERE job_id = @jobId`,
|
||||||
|
{ jobId },
|
||||||
|
);
|
||||||
|
}
|
||||||
return SqliteConnection.execute(
|
return SqliteConnection.execute(
|
||||||
`UPDATE listings
|
`UPDATE listings
|
||||||
SET manually_deleted = 1
|
SET manually_deleted = 1
|
||||||
@@ -386,11 +394,19 @@ export const deleteListingsByJobId = (jobId) => {
|
|||||||
* Delete listings by a list of listing IDs.
|
* Delete listings by a list of listing IDs.
|
||||||
*
|
*
|
||||||
* @param {string[]} ids - Array of listing IDs to delete.
|
* @param {string[]} ids - Array of listing IDs to delete.
|
||||||
|
* @param {boolean} [hardDelete=false] - Whether to hard delete from DB or just mark as deleted.
|
||||||
* @returns {any} The result from SqliteConnection.execute.
|
* @returns {any} The result from SqliteConnection.execute.
|
||||||
*/
|
*/
|
||||||
export const deleteListingsById = (ids) => {
|
export const deleteListingsById = (ids, hardDelete = false) => {
|
||||||
if (!Array.isArray(ids) || ids.length === 0) return;
|
if (!Array.isArray(ids) || ids.length === 0) return;
|
||||||
const placeholders = ids.map(() => '?').join(',');
|
const placeholders = ids.map(() => '?').join(',');
|
||||||
|
if (hardDelete) {
|
||||||
|
return SqliteConnection.execute(
|
||||||
|
`DELETE FROM listings
|
||||||
|
WHERE id IN (${placeholders})`,
|
||||||
|
ids,
|
||||||
|
);
|
||||||
|
}
|
||||||
return SqliteConnection.execute(
|
return SqliteConnection.execute(
|
||||||
`UPDATE listings
|
`UPDATE listings
|
||||||
SET manually_deleted = 1
|
SET manually_deleted = 1
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export function up(db) {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// If parsing fails, let it throw to rollback the migration
|
// 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) {
|
} 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,56 +14,70 @@ import { getSettings } from '../storage/settingsStorage.js';
|
|||||||
const deviceId = getUniqueId() || 'N/A';
|
const deviceId = getUniqueId() || 'N/A';
|
||||||
const version = await getPackageVersion();
|
const version = await getPackageVersion();
|
||||||
const FREDY_TRACKING_URL = 'https://fredy.orange-coding.net/tracking';
|
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 {
|
try {
|
||||||
const settings = await getSettings();
|
const response = await fetch(`${FREDY_TRACKING_URL}${endpoint}`, {
|
||||||
if (settings.analyticsEnabled && !inDevMode()) {
|
method: 'POST',
|
||||||
const activeProvider = new Set();
|
headers: { 'Content-Type': 'application/json' },
|
||||||
const activeAdapter = new Set();
|
body: payload ? JSON.stringify(payload) : undefined,
|
||||||
|
});
|
||||||
const jobs = getJobs();
|
if (!response.ok) {
|
||||||
|
logger.warn(`Error sending tracking data to ${endpoint}. Status: ${response.status}`);
|
||||||
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),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn('Error sending tracking data', error);
|
logger.warn(`Error sending tracking data to ${endpoint}`, error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const trackMainEvent = async () => {
|
||||||
|
if (!(await shouldTrack())) return;
|
||||||
|
|
||||||
|
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 trackFeature = async (feature) => {
|
export const trackFeature = async (feature) => {
|
||||||
try {
|
if (!(await shouldTrack())) return;
|
||||||
const settings = await getSettings();
|
|
||||||
if (settings.analyticsEnabled && !inDevMode()) {
|
|
||||||
const trackingObj = await enrichTrackingObject({
|
|
||||||
feature,
|
|
||||||
});
|
|
||||||
|
|
||||||
await fetch(`${FREDY_TRACKING_URL}/feature`, {
|
const trackingObj = await enrichTrackingObject({
|
||||||
method: 'POST',
|
feature,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
});
|
||||||
body: JSON.stringify(trackingObj),
|
|
||||||
});
|
await sendTrackingData('/feature', trackingObj);
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn('Error tracking feature', error);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -72,34 +86,17 @@ export const trackFeature = async (feature) => {
|
|||||||
export async function trackDemoAccessed() {
|
export async function trackDemoAccessed() {
|
||||||
const settings = await getSettings();
|
const settings = await getSettings();
|
||||||
if (settings.analyticsEnabled && !inDevMode() && settings.demoMode) {
|
if (settings.analyticsEnabled && !inDevMode() && settings.demoMode) {
|
||||||
try {
|
const trackingObj = await enrichTrackingObject({});
|
||||||
await fetch(`${FREDY_TRACKING_URL}/demo/accessed`, {
|
await sendTrackingData('/demo/accessed', trackingObj);
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn('Error sending tracking data', error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function enrichTrackingObject(trackingObject) {
|
async function enrichTrackingObject(trackingObject) {
|
||||||
const settings = await getSettings();
|
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 {
|
return {
|
||||||
...trackingObject,
|
...trackingObject,
|
||||||
|
...staticTrackingData,
|
||||||
isDemo: settings.demoMode,
|
isDemo: settings.demoMode,
|
||||||
operatingSystem,
|
|
||||||
osVersion,
|
|
||||||
arch,
|
|
||||||
nodeVersion,
|
|
||||||
language,
|
|
||||||
deviceId,
|
|
||||||
version,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
30
package.json
30
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "fredy",
|
"name": "fredy",
|
||||||
"version": "19.3.3",
|
"version": "19.3.9",
|
||||||
"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",
|
||||||
@@ -17,7 +17,8 @@
|
|||||||
"lint:fix": "yarn lint --fix",
|
"lint:fix": "yarn lint --fix",
|
||||||
"migratedb": "node lib/services/storage/migrations/migrate.js",
|
"migratedb": "node lib/services/storage/migrations/migrate.js",
|
||||||
"migratedb:overwrite": "x-var MIGRATION_ALLOW_CHECKSUM_UPDATE=true 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",
|
"type": "module",
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
@@ -59,11 +60,11 @@
|
|||||||
"Firefox ESR"
|
"Firefox ESR"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@douyinfe/semi-icons": "^2.90.13",
|
"@douyinfe/semi-icons": "^2.91.0",
|
||||||
"@douyinfe/semi-ui": "2.90.13",
|
"@douyinfe/semi-ui": "2.91.0",
|
||||||
"@douyinfe/semi-ui-19": "^2.90.13",
|
"@douyinfe/semi-ui-19": "^2.91.0",
|
||||||
"@sendgrid/mail": "8.1.6",
|
"@sendgrid/mail": "8.1.6",
|
||||||
"@vitejs/plugin-react": "5.1.2",
|
"@vitejs/plugin-react": "5.1.4",
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.16",
|
||||||
"better-sqlite3": "^12.6.2",
|
"better-sqlite3": "^12.6.2",
|
||||||
"body-parser": "2.2.2",
|
"body-parser": "2.2.2",
|
||||||
@@ -72,14 +73,14 @@
|
|||||||
"cookie-session": "2.1.1",
|
"cookie-session": "2.1.1",
|
||||||
"handlebars": "4.7.8",
|
"handlebars": "4.7.8",
|
||||||
"lodash": "4.17.23",
|
"lodash": "4.17.23",
|
||||||
"maplibre-gl": "^5.17.0",
|
"maplibre-gl": "^5.18.0",
|
||||||
"nanoid": "5.1.6",
|
"nanoid": "5.1.6",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"node-fetch": "3.3.2",
|
"node-fetch": "3.3.2",
|
||||||
"node-mailjet": "6.0.11",
|
"node-mailjet": "6.0.11",
|
||||||
"p-throttle": "^8.1.0",
|
"p-throttle": "^8.1.0",
|
||||||
"package-up": "^5.0.0",
|
"package-up": "^5.0.0",
|
||||||
"puppeteer": "^24.36.1",
|
"puppeteer": "^24.37.2",
|
||||||
"puppeteer-extra": "^3.3.6",
|
"puppeteer-extra": "^3.3.6",
|
||||||
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
||||||
"query-string": "9.3.1",
|
"query-string": "9.3.1",
|
||||||
@@ -90,23 +91,26 @@
|
|||||||
"react-router": "7.13.0",
|
"react-router": "7.13.0",
|
||||||
"react-router-dom": "7.13.0",
|
"react-router-dom": "7.13.0",
|
||||||
"restana": "5.1.0",
|
"restana": "5.1.0",
|
||||||
"semver": "^7.7.3",
|
"semver": "^7.7.4",
|
||||||
"serve-static": "2.2.1",
|
"serve-static": "2.2.1",
|
||||||
"slack": "11.0.2",
|
"slack": "11.0.2",
|
||||||
"vite": "7.3.1",
|
"vite": "7.3.1",
|
||||||
"x-var": "^3.0.1",
|
"x-var": "^3.0.1",
|
||||||
"zustand": "^5.0.10"
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "7.28.6",
|
"@babel/core": "7.29.0",
|
||||||
"@babel/eslint-parser": "7.28.6",
|
"@babel/eslint-parser": "7.28.6",
|
||||||
"@babel/preset-env": "7.28.6",
|
"@babel/preset-env": "7.29.0",
|
||||||
"@babel/preset-react": "7.28.5",
|
"@babel/preset-react": "7.28.5",
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
"chai": "6.2.2",
|
"chai": "6.2.2",
|
||||||
"eslint": "9.39.2",
|
"chalk": "^5.6.2",
|
||||||
|
"eslint": "10.0.0",
|
||||||
"eslint-config-prettier": "10.1.8",
|
"eslint-config-prettier": "10.1.8",
|
||||||
"eslint-plugin-react": "7.37.5",
|
"eslint-plugin-react": "7.37.5",
|
||||||
"esmock": "2.7.3",
|
"esmock": "2.7.3",
|
||||||
|
"globals": "^17.3.0",
|
||||||
"history": "5.3.0",
|
"history": "5.3.0",
|
||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"less": "4.5.1",
|
"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 */
|
||||||
@@ -3,8 +3,6 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { HashRouter } from 'react-router-dom';
|
import { HashRouter } from 'react-router-dom';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import en_US from '@douyinfe/semi-ui-19/lib/es/locale/source/en_US';
|
import en_US from '@douyinfe/semi-ui-19/lib/es/locale/source/en_US';
|
||||||
|
|||||||
70
ui/src/components/ListingDeletionModal.jsx
Normal file
70
ui/src/components/ListingDeletionModal.jsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Modal, Radio, RadioGroup, Typography } from '@douyinfe/semi-ui-19';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
const ListingDeletionModal = ({
|
||||||
|
visible,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
title = 'Delete Listings',
|
||||||
|
showOptions = true,
|
||||||
|
message = 'How would you like to delete the selected listing(s)?',
|
||||||
|
}) => {
|
||||||
|
const [deleteType, setDeleteType] = useState('soft');
|
||||||
|
|
||||||
|
const handleOk = () => {
|
||||||
|
onConfirm(!showOptions || deleteType === 'hard');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={title}
|
||||||
|
visible={visible}
|
||||||
|
onOk={handleOk}
|
||||||
|
onCancel={onCancel}
|
||||||
|
okText="Confirm"
|
||||||
|
cancelText="Cancel"
|
||||||
|
style={{ maxWidth: '500px' }}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<Text>{message}</Text>
|
||||||
|
</div>
|
||||||
|
{showOptions && (
|
||||||
|
<RadioGroup value={deleteType} onChange={(e) => setDeleteType(e.target.value)} style={{ width: '100%' }}>
|
||||||
|
<Radio value="soft" style={{ alignItems: 'flex-start', width: '100%' }}>
|
||||||
|
<div style={{ marginLeft: 8 }}>
|
||||||
|
<Text strong>Mark as deleted (Soft Delete)</Text>
|
||||||
|
<br />
|
||||||
|
<Text type="secondary">
|
||||||
|
Listings are kept in the database but marked as hidden. They will <b>not</b> re-appear during the next
|
||||||
|
scraping session.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Radio>
|
||||||
|
<Radio value="hard" style={{ marginTop: 16, alignItems: 'flex-start', width: '100%' }}>
|
||||||
|
<div style={{ marginLeft: 8 }}>
|
||||||
|
<Text strong>Remove from database (Hard Delete)</Text>
|
||||||
|
<br />
|
||||||
|
<Text type="secondary">
|
||||||
|
Listings are completely removed from the database.
|
||||||
|
<br />
|
||||||
|
<Text type="warning">
|
||||||
|
Consequence: They might re-appear when scraping the next time because Fredy won't know they were
|
||||||
|
previously found.
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Radio>
|
||||||
|
</RadioGroup>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ListingDeletionModal;
|
||||||
@@ -2,30 +2,29 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 140px;
|
height: 140px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
transition: transform 0.2s, box-shadow 0.2s;
|
transition: transform 0.2s;
|
||||||
background-color: rgba(36, 36, 36, 0.9);
|
background-color: rgba(36, 36, 36, 0.9);
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
border: 1px solid var(--semi-color-border);
|
border: 1px solid var(--semi-color-border);
|
||||||
|
--pulse-color: rgba(255, 255, 255, 0.1);
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
overflow: visible;
|
||||||
|
|
||||||
&:hover {
|
&::after {
|
||||||
transform: translateY(-4px);
|
content: '';
|
||||||
background-color: rgba(36, 36, 36, 1);
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
&.blue {
|
left: 0;
|
||||||
box-shadow: 0 8px 24px -5px var(--semi-color-primary);
|
right: 0;
|
||||||
}
|
bottom: 0;
|
||||||
&.orange {
|
border-radius: inherit;
|
||||||
box-shadow: 0 8px 24px -5px var(--semi-color-warning);
|
box-shadow: 0 4px 25px -2px var(--pulse-color);
|
||||||
}
|
opacity: 0;
|
||||||
&.green {
|
animation: pulse 5s infinite ease-in-out;
|
||||||
box-shadow: 0 8px 24px -5px var(--semi-color-success);
|
pointer-events: none;
|
||||||
}
|
z-index: -1;
|
||||||
&.purple {
|
will-change: opacity;
|
||||||
box-shadow: 0 8px 24px -5px var(--semi-color-info);
|
|
||||||
}
|
|
||||||
&.gray {
|
|
||||||
box-shadow: 0 8px 24px -5px rgba(255, 255, 255, 0.4);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__icon {
|
&__icon {
|
||||||
@@ -46,22 +45,36 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.blue {
|
&.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 {
|
&.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 {
|
&.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 {
|
&.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 {
|
&.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
|
* 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 { Card, Typography, Space } from '@douyinfe/semi-ui-19';
|
||||||
import './DashboardCard.less';
|
import './DashboardCard.less';
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import './FredyFooter.less';
|
import './FredyFooter.less';
|
||||||
import { useSelector } from '../../services/state/store.js';
|
import { useSelector } from '../../services/state/store.js';
|
||||||
import { Typography, Layout, Space, Divider } from '@douyinfe/semi-ui-19';
|
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
|
* 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 {
|
import {
|
||||||
Card,
|
Card,
|
||||||
Col,
|
Col,
|
||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
IconPlusCircle,
|
IconPlusCircle,
|
||||||
} from '@douyinfe/semi-icons';
|
} from '@douyinfe/semi-icons';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import ListingDeletionModal from '../../ListingDeletionModal.jsx';
|
||||||
import { useActions, useSelector } from '../../../services/state/store.js';
|
import { useActions, useSelector } from '../../../services/state/store.js';
|
||||||
import { xhrDelete, xhrPut, xhrPost } from '../../../services/xhr.js';
|
import { xhrDelete, xhrPut, xhrPost } from '../../../services/xhr.js';
|
||||||
import debounce from 'lodash/debounce';
|
import debounce from 'lodash/debounce';
|
||||||
@@ -60,6 +61,9 @@ const JobGrid = () => {
|
|||||||
const [activityFilter, setActivityFilter] = useState(null);
|
const [activityFilter, setActivityFilter] = useState(null);
|
||||||
const [showFilterBar, setShowFilterBar] = useState(false);
|
const [showFilterBar, setShowFilterBar] = useState(false);
|
||||||
|
|
||||||
|
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
||||||
|
const [pendingDeletion, setPendingDeletion] = useState(null); // { type: 'job'|'listings', jobId }
|
||||||
|
|
||||||
const pendingJobIdRef = useRef(null);
|
const pendingJobIdRef = useRef(null);
|
||||||
const evtSourceRef = useRef(null);
|
const evtSourceRef = useRef(null);
|
||||||
|
|
||||||
@@ -125,24 +129,35 @@ const JobGrid = () => {
|
|||||||
};
|
};
|
||||||
}, [handleFilterChange]);
|
}, [handleFilterChange]);
|
||||||
|
|
||||||
const onJobRemoval = async (jobId) => {
|
const onJobRemoval = (jobId) => {
|
||||||
try {
|
setPendingDeletion({ type: 'job', jobId });
|
||||||
await xhrDelete('/api/jobs', { jobId });
|
setDeleteModalVisible(true);
|
||||||
Toast.success('Job successfully removed');
|
|
||||||
loadData();
|
|
||||||
actions.jobsData.getJobs(); // refresh select list too
|
|
||||||
} catch (error) {
|
|
||||||
Toast.error(error);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onListingRemoval = async (jobId) => {
|
const onListingRemoval = (jobId) => {
|
||||||
|
setPendingDeletion({ type: 'listings', jobId });
|
||||||
|
setDeleteModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDeletion = async (hardDelete) => {
|
||||||
|
const { type, jobId } = pendingDeletion;
|
||||||
try {
|
try {
|
||||||
await xhrDelete('/api/listings/job', { jobId });
|
if (type === 'job') {
|
||||||
Toast.success('Listings successfully removed');
|
await xhrDelete('/api/jobs', { jobId });
|
||||||
|
Toast.success('Job and listings successfully removed');
|
||||||
|
} else if (type === 'listings') {
|
||||||
|
await xhrDelete('/api/listings/job', { jobId, hardDelete });
|
||||||
|
Toast.success('Listings successfully removed');
|
||||||
|
}
|
||||||
loadData();
|
loadData();
|
||||||
|
if (type === 'job') {
|
||||||
|
actions.jobsData.getJobs(); // refresh select list too
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Toast.error(error);
|
Toast.error(error.message || 'Error performing deletion');
|
||||||
|
} finally {
|
||||||
|
setDeleteModalVisible(false);
|
||||||
|
setPendingDeletion(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -410,6 +425,21 @@ const JobGrid = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<ListingDeletionModal
|
||||||
|
visible={deleteModalVisible}
|
||||||
|
title={pendingDeletion?.type === 'job' ? 'Delete Job' : 'Delete Listings'}
|
||||||
|
showOptions={pendingDeletion?.type !== 'job'}
|
||||||
|
message={
|
||||||
|
pendingDeletion?.type === 'job'
|
||||||
|
? 'Are you sure you want to delete this job? All associated listings will be removed from the database.'
|
||||||
|
: 'How would you like to delete the selected listing(s)?'
|
||||||
|
}
|
||||||
|
onConfirm={confirmDeletion}
|
||||||
|
onCancel={() => {
|
||||||
|
setDeleteModalVisible(false);
|
||||||
|
setPendingDeletion(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* 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 {
|
import {
|
||||||
Card,
|
Card,
|
||||||
Col,
|
Col,
|
||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
IconEyeOpened,
|
IconEyeOpened,
|
||||||
} from '@douyinfe/semi-icons';
|
} from '@douyinfe/semi-icons';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import ListingDeletionModal from '../../ListingDeletionModal.jsx';
|
||||||
import no_image from '../../../assets/no_image.jpg';
|
import no_image from '../../../assets/no_image.jpg';
|
||||||
import * as timeService from '../../../services/time/timeService.js';
|
import * as timeService from '../../../services/time/timeService.js';
|
||||||
import { xhrDelete, xhrPost } from '../../../services/xhr.js';
|
import { xhrDelete, xhrPost } from '../../../services/xhr.js';
|
||||||
@@ -65,6 +66,9 @@ const ListingsGrid = () => {
|
|||||||
const [providerFilter, setProviderFilter] = useState(null);
|
const [providerFilter, setProviderFilter] = useState(null);
|
||||||
const [showFilterBar, setShowFilterBar] = useState(false);
|
const [showFilterBar, setShowFilterBar] = useState(false);
|
||||||
|
|
||||||
|
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
||||||
|
const [listingToDelete, setListingToDelete] = useState(null);
|
||||||
|
|
||||||
const loadData = () => {
|
const loadData = () => {
|
||||||
actions.listingsData.getListingsData({
|
actions.listingsData.getListingsData({
|
||||||
page,
|
page,
|
||||||
@@ -106,6 +110,19 @@ const ListingsGrid = () => {
|
|||||||
setPage(_page);
|
setPage(_page);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const confirmDeletion = async (hardDelete) => {
|
||||||
|
try {
|
||||||
|
await xhrDelete('/api/listings/', { ids: [listingToDelete], hardDelete });
|
||||||
|
Toast.success('Listing successfully removed');
|
||||||
|
loadData();
|
||||||
|
} catch (error) {
|
||||||
|
Toast.error(error.message || 'Error deleting listing');
|
||||||
|
} finally {
|
||||||
|
setDeleteModalVisible(false);
|
||||||
|
setListingToDelete(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const cap = (val) => {
|
const cap = (val) => {
|
||||||
return String(val).charAt(0).toUpperCase() + String(val).slice(1);
|
return String(val).charAt(0).toUpperCase() + String(val).slice(1);
|
||||||
};
|
};
|
||||||
@@ -312,15 +329,10 @@ const ListingsGrid = () => {
|
|||||||
title="Remove"
|
title="Remove"
|
||||||
type="danger"
|
type="danger"
|
||||||
size="small"
|
size="small"
|
||||||
onClick={async (e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
try {
|
setListingToDelete(item.id);
|
||||||
await xhrDelete('/api/listings/', { ids: [item.id] });
|
setDeleteModalVisible(true);
|
||||||
Toast.success('Listing(s) successfully removed');
|
|
||||||
loadData();
|
|
||||||
} catch (error) {
|
|
||||||
Toast.error(error);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
icon={<IconDelete />}
|
icon={<IconDelete />}
|
||||||
/>
|
/>
|
||||||
@@ -341,6 +353,14 @@ const ListingsGrid = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<ListingDeletionModal
|
||||||
|
visible={deleteModalVisible}
|
||||||
|
onConfirm={confirmDeletion}
|
||||||
|
onCancel={() => {
|
||||||
|
setDeleteModalVisible(false);
|
||||||
|
setListingToDelete(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { Typography } from '@douyinfe/semi-ui-19';
|
import { Typography } from '@douyinfe/semi-ui-19';
|
||||||
|
|
||||||
export default function Headline({ text, size = 3 } = {}) {
|
export default function Headline({ text, size = 3 } = {}) {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import logo from '../../assets/logo.png';
|
import logo from '../../assets/logo.png';
|
||||||
import logoWhite from '../../assets/logo_white.png';
|
import logoWhite from '../../assets/logo_white.png';
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { Button } from '@douyinfe/semi-ui-19';
|
import { Button } from '@douyinfe/semi-ui-19';
|
||||||
import { xhrPost } from '../../services/xhr';
|
import { xhrPost } from '../../services/xhr';
|
||||||
import { IconUser } from '@douyinfe/semi-icons';
|
import { IconUser } from '@douyinfe/semi-icons';
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* 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 { Button, Nav } from '@douyinfe/semi-ui-19';
|
||||||
import { IconStar, IconSetting, IconTerminal, IconHistogram, IconSidebar } from '@douyinfe/semi-icons';
|
import { IconStar, IconSetting, IconTerminal, IconHistogram, IconSidebar } from '@douyinfe/semi-icons';
|
||||||
import logoWhite from '../../assets/logo_white.png';
|
import logoWhite from '../../assets/logo_white.png';
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import insufficientPermission from '../../assets/insufficient_permission.png';
|
import insufficientPermission from '../../assets/insufficient_permission.png';
|
||||||
|
|
||||||
export default function InsufficientPermission() {
|
export default function InsufficientPermission() {
|
||||||
|
|||||||
@@ -3,8 +3,6 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { Navigate } from 'react-router-dom';
|
import { Navigate } from 'react-router-dom';
|
||||||
|
|
||||||
export default function PermissionAwareRoute({ currentUser, children }) {
|
export default function PermissionAwareRoute({ currentUser, children }) {
|
||||||
|
|||||||
@@ -3,8 +3,6 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import './Placeholder.less';
|
import './Placeholder.less';
|
||||||
|
|
||||||
function getPlaceholder(rowCount, className) {
|
function getPlaceholder(rowCount, className) {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { Card } from '@douyinfe/semi-ui-19';
|
import { Card } from '@douyinfe/semi-ui-19';
|
||||||
|
|
||||||
import './SegmentParts.less';
|
import './SegmentParts.less';
|
||||||
|
|||||||
@@ -3,8 +3,6 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* 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 { Empty, Table, Button } from '@douyinfe/semi-ui-19';
|
||||||
import { IconDelete, IconEdit } from '@douyinfe/semi-icons';
|
import { IconDelete, IconEdit } from '@douyinfe/semi-icons';
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,6 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* 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 { Empty, Table, Button } from '@douyinfe/semi-ui-19';
|
||||||
import { IconDelete, IconEdit } from '@douyinfe/semi-icons';
|
import { IconDelete, IconEdit } from '@douyinfe/semi-icons';
|
||||||
import { Typography } from '@douyinfe/semi-ui';
|
import { Typography } from '@douyinfe/semi-ui';
|
||||||
|
|||||||
@@ -3,8 +3,6 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
||||||
import { format } from '../../services/time/timeService';
|
import { format } from '../../services/time/timeService';
|
||||||
import { Table, Button, Empty } from '@douyinfe/semi-ui-19';
|
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
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { Modal } from '@douyinfe/semi-ui-19';
|
import { Modal } from '@douyinfe/semi-ui-19';
|
||||||
import Logo from '../logo/Logo.jsx';
|
import Logo from '../logo/Logo.jsx';
|
||||||
import { xhrPost } from '../../services/xhr.js';
|
import { xhrPost } from '../../services/xhr.js';
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* 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 { Collapse, Descriptions } from '@douyinfe/semi-ui-19';
|
||||||
import { useSelector } from '../../services/state/store.js';
|
import { useSelector } from '../../services/state/store.js';
|
||||||
import { MarkdownRender } from '@douyinfe/semi-ui-19';
|
import { MarkdownRender } from '@douyinfe/semi-ui-19';
|
||||||
|
|||||||
@@ -3,8 +3,6 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* 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 JobGrid from '../../components/grid/jobs/JobGrid.jsx';
|
||||||
import './Jobs.less';
|
import './Jobs.less';
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* 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 NotificationAdapterMutator from './components/notificationAdapter/NotificationAdapterMutator';
|
||||||
import NotificationAdapterTable from '../../../components/table/NotificationAdapterTable';
|
import NotificationAdapterTable from '../../../components/table/NotificationAdapterTable';
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* 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 { transform } from '../../../../../services/transformer/notificationAdapterTransformer';
|
||||||
import { xhrPost } from '../../../../../services/xhr';
|
import { xhrPost } from '../../../../../services/xhr';
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { Banner, MarkdownRender } from '@douyinfe/semi-ui-19';
|
import { Banner, MarkdownRender } from '@douyinfe/semi-ui-19';
|
||||||
|
|
||||||
export default function Help({ readme }) {
|
export default function Help({ readme }) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* 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 { Banner, Modal, Select, Input } from '@douyinfe/semi-ui-19';
|
||||||
import { transform } from '../../../../../services/transformer/providerTransformer';
|
import { transform } from '../../../../../services/transformer/providerTransformer';
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* 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 { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { useSelector, useActions } from '../../services/state/store.js';
|
import { useSelector, useActions } from '../../services/state/store.js';
|
||||||
import {
|
import {
|
||||||
@@ -324,7 +324,12 @@ export default function ListingDetail() {
|
|||||||
<Row>
|
<Row>
|
||||||
<Col span={24} lg={12}>
|
<Col span={24} lg={12}>
|
||||||
<div className="listing-detail__image-container">
|
<div className="listing-detail__image-container">
|
||||||
<Image src={listing.image_url || no_image} fallback={no_image} preview={true} />
|
<Image
|
||||||
|
src={listing.image_url}
|
||||||
|
fallback={no_image}
|
||||||
|
style={{ width: '100%', height: '100%' }}
|
||||||
|
preview={true}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={24} lg={12}>
|
<Col span={24} lg={12}>
|
||||||
|
|||||||
@@ -3,8 +3,6 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import ListingsGrid from '../../components/grid/listings/ListingsGrid.jsx';
|
import ListingsGrid from '../../components/grid/listings/ListingsGrid.jsx';
|
||||||
|
|
||||||
export default function Listings() {
|
export default function Listings() {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* 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 { renderToString } from 'react-dom/server';
|
||||||
import maplibregl from 'maplibre-gl';
|
import maplibregl from 'maplibre-gl';
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||||
@@ -19,6 +19,7 @@ import 'react-range-slider-input/dist/style.css';
|
|||||||
import './Map.less';
|
import './Map.less';
|
||||||
import { xhrDelete } from '../../services/xhr.js';
|
import { xhrDelete } from '../../services/xhr.js';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import ListingDeletionModal from '../../components/ListingDeletionModal.jsx';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
@@ -85,6 +86,22 @@ export default function MapView() {
|
|||||||
const [showFilterBar, setShowFilterBar] = useState(false);
|
const [showFilterBar, setShowFilterBar] = useState(false);
|
||||||
const [distanceFilter, setDistanceFilter] = useState(0);
|
const [distanceFilter, setDistanceFilter] = useState(0);
|
||||||
|
|
||||||
|
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
||||||
|
const [listingToDelete, setListingToDelete] = useState(null);
|
||||||
|
|
||||||
|
const confirmListingDeletion = async (hardDelete) => {
|
||||||
|
try {
|
||||||
|
await xhrDelete('/api/listings/', { ids: [listingToDelete], hardDelete });
|
||||||
|
Toast.success('Listing successfully removed');
|
||||||
|
fetchListings();
|
||||||
|
} catch (error) {
|
||||||
|
Toast.error(error.message || 'Error deleting listing');
|
||||||
|
} finally {
|
||||||
|
setDeleteModalVisible(false);
|
||||||
|
setListingToDelete(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPriceRange([0, getMaxPrice()]);
|
setPriceRange([0, getMaxPrice()]);
|
||||||
}, [listings]);
|
}, [listings]);
|
||||||
@@ -104,14 +121,9 @@ export default function MapView() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.deleteListing = async (id) => {
|
window.deleteListing = (id) => {
|
||||||
try {
|
setListingToDelete(id);
|
||||||
await xhrDelete('/api/listings/', { ids: [id] });
|
setDeleteModalVisible(true);
|
||||||
Toast.success('Listing successfully removed');
|
|
||||||
fetchListings();
|
|
||||||
} catch (error) {
|
|
||||||
Toast.error(error.message || 'Error deleting listing');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
window.viewDetails = (id) => {
|
window.viewDetails = (id) => {
|
||||||
@@ -378,7 +390,10 @@ export default function MapView() {
|
|||||||
|
|
||||||
const popupContent = `
|
const popupContent = `
|
||||||
<div class="map-popup-content">
|
<div class="map-popup-content">
|
||||||
<img src="${listing.image_url || no_image}" alt="${listing.title}" />
|
<img
|
||||||
|
src="${listing.image_url}"
|
||||||
|
onerror="this.onerror=null;this.src='${no_image}'"
|
||||||
|
/>
|
||||||
<h4>${listing.title}</h4>
|
<h4>${listing.title}</h4>
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<span><strong>Price:</strong> ${listing.price ? listing.price + ' €' : 'N/A'}</span>
|
<span><strong>Price:</strong> ${listing.price ? listing.price + ' €' : 'N/A'}</span>
|
||||||
@@ -559,6 +574,14 @@ export default function MapView() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div ref={mapContainer} className="map-container" />
|
<div ref={mapContainer} className="map-container" />
|
||||||
|
<ListingDeletionModal
|
||||||
|
visible={deleteModalVisible}
|
||||||
|
onConfirm={confirmListingDeletion}
|
||||||
|
onCancel={() => {
|
||||||
|
setDeleteModalVisible(false);
|
||||||
|
setListingToDelete(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* 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 { IconHorn } from '@douyinfe/semi-icons';
|
||||||
import { SegmentPart } from '../../../components/segment/SegmentPart.jsx';
|
import { SegmentPart } from '../../../components/segment/SegmentPart.jsx';
|
||||||
import { Banner, Button, Checkbox, Space } from '@douyinfe/semi-ui-19';
|
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
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { Modal } from '@douyinfe/semi-ui-19';
|
import { Modal } from '@douyinfe/semi-ui-19';
|
||||||
const UserRemovalModal = function UserRemovalModal({ onOk, onCancel }) {
|
const UserRemovalModal = function UserRemovalModal({ onOk, onCancel }) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* 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 { Divider, Button, AutoComplete, Toast, Banner } from '@douyinfe/semi-ui-19';
|
||||||
import { IconSave, IconHome } from '@douyinfe/semi-icons';
|
import { IconSave, IconHome } from '@douyinfe/semi-icons';
|
||||||
import { useSelector, useActions } from '../../services/state/store';
|
import { useSelector, useActions } from '../../services/state/store';
|
||||||
|
|||||||
Reference in New Issue
Block a user