mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fd8be07a2 | ||
|
|
2926ee7e08 | ||
|
|
9506d1a9db | ||
|
|
feaa06c132 | ||
|
|
ad46500d4e | ||
|
|
3c209a8f97 |
2
LICENSE
2
LICENSE
@@ -210,5 +210,5 @@ different name or branding without the explicit written permission of the
|
||||
original copyright holder.
|
||||
|
||||
|
||||
Copyright (c) 2025 Christian Kellner
|
||||
Copyright (c) 2026 Christian Kellner
|
||||
Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 197 KiB After Width: | Height: | Size: 248 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 512 KiB After Width: | Height: | Size: 4.7 MiB |
@@ -50,6 +50,46 @@ jobRouter.get('/', async (req, res) => {
|
||||
res.send();
|
||||
});
|
||||
|
||||
jobRouter.get('/data', async (req, res) => {
|
||||
const { page, pageSize = 50, activityFilter, sortfield = null, sortdir = 'asc', freeTextFilter } = req.query || {};
|
||||
|
||||
// normalize booleans
|
||||
const toBool = (v) => {
|
||||
if (v === true || v === 'true' || v === 1 || v === '1') return true;
|
||||
if (v === false || v === 'false' || v === 0 || v === '0') return false;
|
||||
return null;
|
||||
};
|
||||
const normalizedActivity = toBool(activityFilter);
|
||||
|
||||
const queryResult = jobStorage.queryJobs({
|
||||
page: page ? parseInt(page, 10) : 1,
|
||||
pageSize: pageSize ? parseInt(pageSize, 10) : 50,
|
||||
freeTextFilter: freeTextFilter || null,
|
||||
activityFilter: normalizedActivity,
|
||||
sortField: sortfield || null,
|
||||
sortDir: sortdir === 'desc' ? 'desc' : 'asc',
|
||||
userId: req.session.currentUser,
|
||||
isAdmin: isAdmin(req),
|
||||
});
|
||||
|
||||
const isUserAdmin = isAdmin(req);
|
||||
|
||||
// Map result to include runtime status
|
||||
queryResult.result = queryResult.result.map((job) => {
|
||||
return {
|
||||
...job,
|
||||
running: isJobRunning(job.id),
|
||||
isOnlyShared:
|
||||
!isUserAdmin &&
|
||||
job.userId !== req.session.currentUser &&
|
||||
job.shared_with_user.includes(req.session.currentUser),
|
||||
};
|
||||
});
|
||||
|
||||
res.body = queryResult;
|
||||
res.send();
|
||||
});
|
||||
|
||||
// Server-Sent Events for job status updates
|
||||
jobRouter.get('/events', async (req, res) => {
|
||||
const userId = req.session.currentUser;
|
||||
|
||||
@@ -28,10 +28,14 @@ listingsRouter.get('/table', async (req, res) => {
|
||||
freeTextFilter,
|
||||
} = req.query || {};
|
||||
|
||||
// normalize booleans (accept true, 'true', 1, '1')
|
||||
const toBool = (v) => v === true || v === 'true' || v === 1 || v === '1';
|
||||
const normalizedActivity = toBool(activityFilter) ? true : null;
|
||||
const normalizedWatch = toBool(watchListFilter) ? true : null;
|
||||
// normalize booleans (accept true, 'true', 1, '1' for true; false, 'false', 0, '0' for false)
|
||||
const toBool = (v) => {
|
||||
if (v === true || v === 'true' || v === 1 || v === '1') return true;
|
||||
if (v === false || v === 'false' || v === 0 || v === '0') return false;
|
||||
return null;
|
||||
};
|
||||
const normalizedActivity = toBool(activityFilter);
|
||||
const normalizedWatch = toBool(watchListFilter);
|
||||
|
||||
let jobFilter = null;
|
||||
let jobIdFilter = null;
|
||||
|
||||
@@ -44,7 +44,7 @@ export const init = (sourceConfig, blacklist) => {
|
||||
|
||||
export const metaInformation = {
|
||||
name: 'OhneMakler',
|
||||
baseUrl: 'https://www.ohne-makler.net/immobilien',
|
||||
baseUrl: 'https://www.ohne-makler.net',
|
||||
id: 'ohneMakler',
|
||||
};
|
||||
export { config };
|
||||
|
||||
56
lib/provider/wohnungsboerse.js
Normal file
56
lib/provider/wohnungsboerse.js
Normal file
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import * as utils from '../utils.js';
|
||||
|
||||
let appliedBlackList = [];
|
||||
|
||||
function normalize(o) {
|
||||
const id = o.link.split('/').pop();
|
||||
const price = o.price;
|
||||
const size = o.size;
|
||||
const rooms = o.rooms;
|
||||
const [city = '', part = ''] = (o.description || '').split('-').map((v) => v.trim());
|
||||
const address = `${part}, ${city}`;
|
||||
return Object.assign(o, { id, price, size, rooms, address });
|
||||
}
|
||||
|
||||
function applyBlacklist(o) {
|
||||
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
|
||||
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
|
||||
return o.id != null && o.title != null && titleNotBlacklisted && descNotBlacklisted && o.link.startsWith(o.link);
|
||||
}
|
||||
|
||||
const config = {
|
||||
url: null,
|
||||
sortByDateParam: null,
|
||||
waitForSelector: 'body',
|
||||
crawlContainer: '.search_result_container > a',
|
||||
crawlFields: {
|
||||
id: '*',
|
||||
title: 'h3 | trim',
|
||||
price: 'dl:nth-of-type(1) dd | removeNewline | trim',
|
||||
rooms: 'dl:nth-of-type(2) dd | removeNewline | trim',
|
||||
size: 'dl:nth-of-type(3) dd | removeNewline | trim',
|
||||
description: 'div.before\\:icon-location_marker | trim',
|
||||
link: '@href',
|
||||
imageUrl: 'img@src',
|
||||
},
|
||||
normalize: normalize,
|
||||
filter: applyBlacklist,
|
||||
};
|
||||
|
||||
export const init = (sourceConfig, blacklistTerms) => {
|
||||
config.url = sourceConfig.url;
|
||||
appliedBlackList = blacklistTerms || [];
|
||||
};
|
||||
|
||||
export const metaInformation = {
|
||||
name: 'Wohnungsboerse',
|
||||
baseUrl: 'https://www.wohnungsboerse.net',
|
||||
id: 'wohnungsboerse',
|
||||
};
|
||||
|
||||
export { config };
|
||||
@@ -104,7 +104,11 @@ export default async function execute(url, waitForSelector, options) {
|
||||
result = pageSource || (await page.content());
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Error executing with puppeteer executor', error);
|
||||
if (error?.message?.includes('Timeout')) {
|
||||
logger.debug('Error executing with puppeteer executor', error);
|
||||
} else {
|
||||
logger.warn('Error executing with puppeteer executor', error);
|
||||
}
|
||||
result = null;
|
||||
} finally {
|
||||
try {
|
||||
|
||||
@@ -40,11 +40,3 @@ export function markRunning(jobId) {
|
||||
export function markFinished(jobId) {
|
||||
running.delete(jobId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve all currently running job IDs.
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function getRunningJobIds() {
|
||||
return Array.from(running);
|
||||
}
|
||||
|
||||
@@ -163,3 +163,109 @@ export const getJobs = () => {
|
||||
notificationAdapter: fromJson(row.notificationAdapter, []),
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Query jobs with pagination, filtering and sorting.
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {number} [params.pageSize=50]
|
||||
* @param {number} [params.page=1]
|
||||
* @param {string} [params.freeTextFilter]
|
||||
* @param {object} [params.activityFilter]
|
||||
* @param {string|null} [params.sortField=null]
|
||||
* @param {('asc'|'desc')} [params.sortDir='asc']
|
||||
* @param {string} [params.userId] - Current user id used to scope jobs (ignored for admins).
|
||||
* @param {boolean} [params.isAdmin=false] - When true, returns all jobs.
|
||||
* @returns {{ totalNumber:number, page:number, result:Object[] }}
|
||||
*/
|
||||
export const queryJobs = ({
|
||||
pageSize = 50,
|
||||
page = 1,
|
||||
activityFilter,
|
||||
freeTextFilter,
|
||||
sortField = null,
|
||||
sortDir = 'asc',
|
||||
userId = null,
|
||||
isAdmin = false,
|
||||
} = {}) => {
|
||||
// sanitize inputs
|
||||
const safePageSize = Number.isFinite(pageSize) && pageSize > 0 ? Math.min(500, Math.floor(pageSize)) : 50;
|
||||
const safePage = Number.isFinite(page) && page > 0 ? Math.floor(page) : 1;
|
||||
const offset = (safePage - 1) * safePageSize;
|
||||
|
||||
// build WHERE filter
|
||||
const whereParts = [];
|
||||
const params = { limit: safePageSize, offset };
|
||||
params.userId = userId || '__NO_USER__';
|
||||
|
||||
if (!isAdmin) {
|
||||
whereParts.push(
|
||||
`(j.user_id = @userId OR EXISTS (SELECT 1 FROM json_each(j.shared_with_user) AS sw WHERE sw.value = @userId))`,
|
||||
);
|
||||
}
|
||||
|
||||
if (freeTextFilter && String(freeTextFilter).trim().length > 0) {
|
||||
params.filter = `%${String(freeTextFilter).trim()}%`;
|
||||
whereParts.push(`(j.name LIKE @filter)`);
|
||||
}
|
||||
|
||||
if (activityFilter === true) {
|
||||
whereParts.push('(j.enabled = 1)');
|
||||
} else if (activityFilter === false) {
|
||||
whereParts.push('(j.enabled = 0)');
|
||||
}
|
||||
|
||||
const whereSql = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : '';
|
||||
|
||||
// whitelist sortable fields
|
||||
const sortable = new Set(['name', 'numberOfFoundListings', 'enabled']);
|
||||
const safeSortField = sortField && sortable.has(sortField) ? sortField : null;
|
||||
const safeSortDir = String(sortDir).toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
||||
|
||||
let orderSql = 'ORDER BY j.name IS NULL, j.name ASC';
|
||||
if (safeSortField) {
|
||||
if (safeSortField === 'numberOfFoundListings') {
|
||||
orderSql = `ORDER BY numberOfFoundListings ${safeSortDir}`;
|
||||
} else {
|
||||
orderSql = `ORDER BY j.${safeSortField} ${safeSortDir}`;
|
||||
}
|
||||
}
|
||||
|
||||
// count total
|
||||
const countRow = SqliteConnection.query(
|
||||
`SELECT COUNT(1) as cnt
|
||||
FROM jobs j
|
||||
${whereSql}`,
|
||||
params,
|
||||
);
|
||||
const totalNumber = countRow?.[0]?.cnt ?? 0;
|
||||
|
||||
// fetch page
|
||||
const rows = SqliteConnection.query(
|
||||
`SELECT j.id,
|
||||
j.user_id AS userId,
|
||||
j.enabled,
|
||||
j.name,
|
||||
j.blacklist,
|
||||
j.provider,
|
||||
j.shared_with_user,
|
||||
j.notification_adapter AS notificationAdapter,
|
||||
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id) AS numberOfFoundListings
|
||||
FROM jobs j
|
||||
${whereSql}
|
||||
${orderSql}
|
||||
LIMIT @limit OFFSET @offset`,
|
||||
params,
|
||||
);
|
||||
|
||||
const result = rows.map((row) => ({
|
||||
...row,
|
||||
enabled: !!row.enabled,
|
||||
blacklist: fromJson(row.blacklist, []),
|
||||
provider: fromJson(row.provider, []),
|
||||
shared_with_user: fromJson(row.shared_with_user, []),
|
||||
notificationAdapter: fromJson(row.notificationAdapter, []),
|
||||
}));
|
||||
|
||||
return { totalNumber, page: safePage, result };
|
||||
};
|
||||
|
||||
@@ -277,9 +277,11 @@ export const queryListings = ({
|
||||
params.filter = `%${String(freeTextFilter).trim()}%`;
|
||||
whereParts.push(`(title LIKE @filter OR address LIKE @filter OR provider LIKE @filter OR link LIKE @filter)`);
|
||||
}
|
||||
// activityFilter: when true -> only active listings (is_active = 1)
|
||||
// activityFilter: when true -> only active listings (is_active = 1), false -> only inactive
|
||||
if (activityFilter === true) {
|
||||
whereParts.push('(is_active = 1)');
|
||||
} else if (activityFilter === false) {
|
||||
whereParts.push('(is_active = 0)');
|
||||
}
|
||||
// Prefer filtering by job id when provided (unambiguous and robust)
|
||||
if (jobIdFilter && String(jobIdFilter).trim().length > 0) {
|
||||
@@ -295,9 +297,11 @@ export const queryListings = ({
|
||||
params.providerName = String(providerFilter).trim();
|
||||
whereParts.push('(provider = @providerName)');
|
||||
}
|
||||
// watchListFilter: when true -> only watched listings
|
||||
// watchListFilter: when true -> only watched listings, false -> only unwatched
|
||||
if (watchListFilter === true) {
|
||||
whereParts.push('(wl.id IS NOT NULL)');
|
||||
} else if (watchListFilter === false) {
|
||||
whereParts.push('(wl.id IS NULL)');
|
||||
}
|
||||
|
||||
const whereSql = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : '';
|
||||
|
||||
20
package.json
20
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "fredy",
|
||||
"version": "16.3.0",
|
||||
"version": "17.1.0",
|
||||
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
||||
"scripts": {
|
||||
"prepare": "husky",
|
||||
@@ -12,7 +12,7 @@
|
||||
"format": "prettier --write \"**/*.js\"",
|
||||
"format:check": "prettier --check \"**/*.js\"",
|
||||
"test": "node --import ./test/esmock-loader.mjs ./node_modules/mocha/bin/mocha.js --timeout 60000 test/**/*.test.js",
|
||||
"testGH": "node --import ./test/esmock-loader.mjs ./node_modules/mocha/bin/mocha.js --timeout 60000 --exclude test/provider/immonet.test.js --exclude test/provider/immowelt.test.js test/**/*.test.js",
|
||||
"testGH": "node --import ./test/esmock-loader.mjs ./node_modules/mocha/bin/mocha.js --timeout 60000 --exclude test/provider/immonet.test.js --exclude test/provider/immobilienDe.test.js --exclude test/provider/immowelt.test.js test/**/*.test.js",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "yarn lint --fix",
|
||||
"migratedb": "node lib/services/storage/migrations/migrate.js",
|
||||
@@ -60,12 +60,12 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"adm-zip": "^0.5.16",
|
||||
"@douyinfe/semi-icons": "^2.89.1",
|
||||
"@douyinfe/semi-ui": "2.89.1",
|
||||
"@douyinfe/semi-icons": "^2.90.10",
|
||||
"@douyinfe/semi-ui": "2.90.10",
|
||||
"@sendgrid/mail": "8.1.6",
|
||||
"@vitejs/plugin-react": "5.1.2",
|
||||
"better-sqlite3": "^12.5.0",
|
||||
"body-parser": "2.2.1",
|
||||
"body-parser": "2.2.2",
|
||||
"chart.js": "^4.5.1",
|
||||
"cheerio": "^1.1.2",
|
||||
"cookie-session": "2.1.1",
|
||||
@@ -77,20 +77,20 @@
|
||||
"node-mailjet": "6.0.11",
|
||||
"p-throttle": "^8.1.0",
|
||||
"package-up": "^5.0.0",
|
||||
"puppeteer": "^24.33.1",
|
||||
"puppeteer": "^24.34.0",
|
||||
"puppeteer-extra": "^3.3.6",
|
||||
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
||||
"query-string": "9.3.1",
|
||||
"react": "18.3.1",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-router": "7.11.0",
|
||||
"react-router-dom": "7.11.0",
|
||||
"react-router": "7.12.0",
|
||||
"react-router-dom": "7.12.0",
|
||||
"restana": "5.1.0",
|
||||
"semver": "^7.7.3",
|
||||
"serve-static": "2.2.1",
|
||||
"slack": "11.0.2",
|
||||
"vite": "7.3.0",
|
||||
"vite": "7.3.1",
|
||||
"x-var": "^3.0.1",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
@@ -99,7 +99,7 @@
|
||||
"@babel/eslint-parser": "7.28.5",
|
||||
"@babel/preset-env": "7.28.5",
|
||||
"@babel/preset-react": "7.28.5",
|
||||
"chai": "6.2.1",
|
||||
"chai": "6.2.2",
|
||||
"eslint": "9.39.2",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-plugin-react": "7.37.5",
|
||||
|
||||
@@ -51,5 +51,9 @@
|
||||
"wgGesucht": {
|
||||
"url": "https://www.wg-gesucht.de/wg-zimmer-in-Duesseldorf.30.0.1.0.html",
|
||||
"enabled": true
|
||||
},
|
||||
"wohnungsboerse": {
|
||||
"url": "https://www.wohnungsboerse.net/searches/index?estate_marketing_types=kauf%2C1&marketing_type=kauf&estate_types%5B0%5D=1&is_rendite=0&estate_id=&zipcodes%5B%5D=&cities%5B%5D=Duesseldorf&districts%5B%5D=&term=D%C3%BCsseldorf&umkreiskm=&pricetext=&minprice=&maxprice=&sizetext=&minsize=&maxsize=&roomstext=&minrooms=&maxrooms=",
|
||||
"IsActive": true
|
||||
}
|
||||
}
|
||||
|
||||
45
test/provider/wohnungsboerse.test.js
Normal file
45
test/provider/wohnungsboerse.test.js
Normal file
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||
import { get } from '../mocks/mockNotification.js';
|
||||
import { providerConfig, mockFredy } from '../utils.js';
|
||||
import { expect } from 'chai';
|
||||
import * as provider from '../../lib/provider/wohnungsboerse.js';
|
||||
|
||||
describe('#wohnungsboerse testsuite()', () => {
|
||||
provider.init(providerConfig.wohnungsboerse, [], []);
|
||||
it('should test wohnungsboerse provider', async () => {
|
||||
const Fredy = await mockFredy();
|
||||
return await new Promise((resolve) => {
|
||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'wohnungsboerse', similarityCache);
|
||||
fredy.execute().then((listings) => {
|
||||
expect(listings).to.be.a('array');
|
||||
const notificationObj = get();
|
||||
expect(notificationObj).to.be.a('object');
|
||||
expect(notificationObj.serviceName).to.equal('wohnungsboerse');
|
||||
notificationObj.payload.forEach((notify) => {
|
||||
/** check the actual structure **/
|
||||
expect(notify.id).to.be.a('string');
|
||||
expect(notify.price).to.be.a('string');
|
||||
expect(notify.size).to.be.a('string');
|
||||
expect(notify.title).to.be.a('string');
|
||||
expect(notify.link).to.be.a('string');
|
||||
expect(notify.address).to.be.a('string');
|
||||
/** check the values if possible **/
|
||||
expect(notify.size).to.be.not.empty;
|
||||
expect(notify.title).to.be.not.empty;
|
||||
expect(notify.link).that.does.include('https://www.wohnungsboerse.net');
|
||||
});
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -40,8 +40,8 @@ export default function FredyApp() {
|
||||
if (!needsLogin()) {
|
||||
await actions.features.getFeatures();
|
||||
await actions.provider.getProvider();
|
||||
await actions.jobs.getJobs();
|
||||
await actions.jobs.getSharableUserList();
|
||||
await actions.jobsData.getJobs();
|
||||
await actions.jobsData.getSharableUserList();
|
||||
await actions.notificationAdapter.getAdapter();
|
||||
await actions.generalSettings.getGeneralSettings();
|
||||
await actions.versionUpdate.getVersionUpdate();
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
@color-blue-text: #60a5fa;
|
||||
|
||||
@color-orange-bg: rgba(250, 91, 5, 0.12);
|
||||
@color-orange-border: #d33601;
|
||||
@color-orange-border: #992f0c;
|
||||
@color-orange-text: #FB923CFF;
|
||||
|
||||
@color-green-bg: rgba(38, 250, 5, 0.12);
|
||||
@color-green-border: #00c316;
|
||||
@color-green-border: #278832;
|
||||
@color-green-text: #33f308;
|
||||
|
||||
@color-purple-bg: rgba(91, 3, 218, 0.38);
|
||||
|
||||
402
ui/src/components/grid/jobs/JobGrid.jsx
Normal file
402
ui/src/components/grid/jobs/JobGrid.jsx
Normal file
@@ -0,0 +1,402 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Col,
|
||||
Row,
|
||||
Button,
|
||||
Space,
|
||||
Typography,
|
||||
Divider,
|
||||
Switch,
|
||||
Popover,
|
||||
Tag,
|
||||
Input,
|
||||
Select,
|
||||
Pagination,
|
||||
Toast,
|
||||
Empty,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconDelete,
|
||||
IconDescend2,
|
||||
IconEdit,
|
||||
IconPlayCircle,
|
||||
IconBriefcase,
|
||||
IconBell,
|
||||
IconSearch,
|
||||
IconFilter,
|
||||
IconPlusCircle,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useActions, useSelector } from '../../../services/state/store.js';
|
||||
import { xhrDelete, xhrPut, xhrPost } from '../../../services/xhr.js';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
||||
|
||||
import './JobGrid.less';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
const getPopoverContent = (text) => <article className="jobPopoverContent">{text}</article>;
|
||||
|
||||
const JobGrid = () => {
|
||||
const jobsData = useSelector((state) => state.jobsData);
|
||||
const actions = useActions();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 12;
|
||||
|
||||
const [sortField, setSortField] = useState('name');
|
||||
const [sortDir, setSortDir] = useState('asc');
|
||||
const [freeTextFilter, setFreeTextFilter] = useState(null);
|
||||
const [activityFilter, setActivityFilter] = useState(null);
|
||||
const [showFilterBar, setShowFilterBar] = useState(false);
|
||||
|
||||
const pendingJobIdRef = useRef(null);
|
||||
const evtSourceRef = useRef(null);
|
||||
|
||||
const loadData = () => {
|
||||
actions.jobsData.getJobsData({
|
||||
page,
|
||||
pageSize,
|
||||
sortfield: sortField,
|
||||
sortdir: sortDir,
|
||||
freeTextFilter,
|
||||
filter: { activityFilter },
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [page, sortField, sortDir, freeTextFilter, activityFilter]);
|
||||
|
||||
// SSE connection for live job status updates
|
||||
useEffect(() => {
|
||||
// establish SSE connection
|
||||
const src = new EventSource('/api/jobs/events');
|
||||
evtSourceRef.current = src;
|
||||
|
||||
const onJobStatus = (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data || '{}');
|
||||
if (data && data.jobId) {
|
||||
actions.jobsData.setJobRunning(data.jobId, !!data.running);
|
||||
// notify finish if it was triggered by this view
|
||||
if (pendingJobIdRef.current === data.jobId && data.running === false) {
|
||||
Toast.success('Job finished');
|
||||
pendingJobIdRef.current = null;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed events
|
||||
}
|
||||
};
|
||||
|
||||
src.addEventListener('jobStatus', onJobStatus);
|
||||
src.onerror = () => {
|
||||
// Let browser auto-reconnect
|
||||
};
|
||||
|
||||
return () => {
|
||||
try {
|
||||
src.removeEventListener('jobStatus', onJobStatus);
|
||||
src.close();
|
||||
} catch {
|
||||
//noop
|
||||
}
|
||||
evtSourceRef.current = null;
|
||||
pendingJobIdRef.current = null;
|
||||
};
|
||||
}, [actions.jobsData]);
|
||||
|
||||
const handleFilterChange = useMemo(() => debounce((value) => setFreeTextFilter(value), 500), []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
handleFilterChange.cancel && handleFilterChange.cancel();
|
||||
};
|
||||
}, [handleFilterChange]);
|
||||
|
||||
const onJobRemoval = async (jobId) => {
|
||||
try {
|
||||
await xhrDelete('/api/jobs', { jobId });
|
||||
Toast.success('Job successfully removed');
|
||||
loadData();
|
||||
actions.jobsData.getJobs(); // refresh select list too
|
||||
} catch (error) {
|
||||
Toast.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const onListingRemoval = async (jobId) => {
|
||||
try {
|
||||
await xhrDelete('/api/listings/job', { jobId });
|
||||
Toast.success('Listings successfully removed');
|
||||
loadData();
|
||||
} catch (error) {
|
||||
Toast.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const onJobStatusChanged = async (jobId, status) => {
|
||||
try {
|
||||
await xhrPut(`/api/jobs/${jobId}/status`, { status });
|
||||
Toast.success('Job status successfully changed');
|
||||
loadData();
|
||||
} catch (error) {
|
||||
Toast.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const onJobRun = async (jobId) => {
|
||||
try {
|
||||
const response = await xhrPost(`/api/jobs/${jobId}/run`);
|
||||
if (response.status === 202) {
|
||||
Toast.success('Job run started');
|
||||
} else {
|
||||
Toast.info('Job run requested');
|
||||
}
|
||||
pendingJobIdRef.current = jobId;
|
||||
loadData();
|
||||
} catch (error) {
|
||||
if (error?.status === 409) {
|
||||
Toast.warning(error?.json?.message || 'Job is already running');
|
||||
} else if (error?.status === 403) {
|
||||
Toast.error('You are not allowed to run this job');
|
||||
} else if (error?.status === 404) {
|
||||
Toast.error('Job not found');
|
||||
} else {
|
||||
Toast.error('Failed to trigger job');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePageChange = (_page) => {
|
||||
setPage(_page);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="jobGrid">
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Button
|
||||
style={{ width: '7rem', margin: 0 }}
|
||||
type="primary"
|
||||
icon={<IconPlusCircle />}
|
||||
className="jobs__newButton"
|
||||
onClick={() => navigate('/jobs/new')}
|
||||
>
|
||||
New Job
|
||||
</Button>
|
||||
|
||||
<div className="jobGrid__searchbar">
|
||||
<Input prefix={<IconSearch />} showClear placeholder="Search" onChange={handleFilterChange} />
|
||||
<Popover content="Filter / Sort Results" style={{ color: 'white', padding: '.5rem' }}>
|
||||
<Button
|
||||
icon={<IconFilter />}
|
||||
onClick={() => {
|
||||
setShowFilterBar(!showFilterBar);
|
||||
}}
|
||||
/>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showFilterBar && (
|
||||
<div className="jobGrid__toolbar">
|
||||
<Space wrap style={{ marginBottom: '1rem' }}>
|
||||
<div className="jobGrid__toolbar__card">
|
||||
<div>
|
||||
<Text strong>Filter by:</Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '.3rem' }}>
|
||||
<Select
|
||||
placeholder="Status"
|
||||
showClear
|
||||
onChange={(val) => setActivityFilter(val)}
|
||||
value={activityFilter}
|
||||
style={{ width: 140 }}
|
||||
>
|
||||
<Select.Option value={true}>Active</Select.Option>
|
||||
<Select.Option value={false}>Not Active</Select.Option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<Divider layout="vertical" />
|
||||
<div className="jobGrid__toolbar__card">
|
||||
<div>
|
||||
<Text strong>Sort by:</Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '.3rem' }}>
|
||||
<Select
|
||||
placeholder="Sort By"
|
||||
style={{ width: 160 }}
|
||||
value={sortField}
|
||||
onChange={(val) => setSortField(val)}
|
||||
>
|
||||
<Select.Option value="name">Name</Select.Option>
|
||||
<Select.Option value="numberOfFoundListings">Number of Listings</Select.Option>
|
||||
<Select.Option value="enabled">Status</Select.Option>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
placeholder="Direction"
|
||||
style={{ width: 120 }}
|
||||
value={sortDir}
|
||||
onChange={(val) => setSortDir(val)}
|
||||
>
|
||||
<Select.Option value="asc">Ascending</Select.Option>
|
||||
<Select.Option value="desc">Descending</Select.Option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(jobsData?.result || []).length === 0 && (
|
||||
<Empty
|
||||
image={<IllustrationNoResult />}
|
||||
darkModeImage={<IllustrationNoResultDark />}
|
||||
description="No jobs available yet..."
|
||||
/>
|
||||
)}
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
{(jobsData?.result || []).map((job) => (
|
||||
<Col key={job.id} xs={24} sm={12} md={8} lg={6} xl={4} xxl={6}>
|
||||
<Card
|
||||
className="jobGrid__card"
|
||||
bodyStyle={{ padding: '16px' }}
|
||||
headerLine={true}
|
||||
title={
|
||||
<div className="jobGrid__header">
|
||||
<Title heading={5} ellipsis={{ showTooltip: true }} className="jobGrid__title">
|
||||
{job.name}
|
||||
</Title>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
{job.isOnlyShared && (
|
||||
<Popover
|
||||
content={getPopoverContent(
|
||||
'This job has been shared with you by another user, therefor it is read-only.',
|
||||
)}
|
||||
>
|
||||
<IconAlertTriangle style={{ color: 'rgba(var(--semi-yellow-7), 1)', marginLeft: '8px' }} />
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
{job.running && (
|
||||
<Tag color="green" variant="light" size="small">
|
||||
RUNNING
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="jobGrid__content">
|
||||
<Space vertical align="start" spacing={4} style={{ width: '100%', marginTop: 12 }}>
|
||||
<div className="jobGrid__infoItem">
|
||||
<Text type="secondary" icon={<IconSearch />} size="small">
|
||||
Is active:
|
||||
</Text>
|
||||
<Switch
|
||||
onChange={(checked) => onJobStatusChanged(job.id, checked)}
|
||||
style={{ marginLeft: 'auto' }}
|
||||
checked={job.enabled}
|
||||
disabled={job.isOnlyShared}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
<div className="jobGrid__infoItem">
|
||||
<Text type="secondary" icon={<IconSearch />} size="small">
|
||||
Listings:
|
||||
</Text>
|
||||
<Tag color="blue" size="small" style={{ marginLeft: 'auto' }}>
|
||||
{job.numberOfFoundListings || 0}
|
||||
</Tag>
|
||||
</div>
|
||||
<div className="jobGrid__infoItem">
|
||||
<Text type="secondary" icon={<IconBriefcase />} size="small">
|
||||
Providers:
|
||||
</Text>
|
||||
<Tag color="cyan" size="small" style={{ marginLeft: 'auto' }}>
|
||||
{job.provider.length || 0}
|
||||
</Tag>
|
||||
</div>
|
||||
<div className="jobGrid__infoItem">
|
||||
<Text type="secondary" icon={<IconBell />} size="small">
|
||||
Adapters:
|
||||
</Text>
|
||||
<Tag color="purple" size="small" style={{ marginLeft: 'auto' }}>
|
||||
{job.notificationAdapter.length || 0}
|
||||
</Tag>
|
||||
</div>
|
||||
</Space>
|
||||
|
||||
<Divider margin="12px" />
|
||||
|
||||
<div className="jobGrid__actions">
|
||||
<Popover content={getPopoverContent('Run Job')}>
|
||||
<Button
|
||||
type="primary"
|
||||
theme="solid"
|
||||
icon={<IconPlayCircle />}
|
||||
disabled={job.isOnlyShared || job.running}
|
||||
onClick={() => onJobRun(job.id)}
|
||||
/>
|
||||
</Popover>
|
||||
<Popover content={getPopoverContent('Edit a Job')}>
|
||||
<Button
|
||||
type="secondary"
|
||||
theme="solid"
|
||||
icon={<IconEdit />}
|
||||
disabled={job.isOnlyShared}
|
||||
onClick={() => navigate(`/jobs/edit/${job.id}`)}
|
||||
/>
|
||||
</Popover>
|
||||
<Popover content={getPopoverContent('Delete all found Listings of this Job')}>
|
||||
<Button
|
||||
type="danger"
|
||||
theme="solid"
|
||||
icon={<IconDescend2 />}
|
||||
disabled={job.isOnlyShared}
|
||||
onClick={() => onListingRemoval(job.id)}
|
||||
/>
|
||||
</Popover>
|
||||
<Popover content={getPopoverContent('Delete Job')}>
|
||||
<Button
|
||||
type="danger"
|
||||
theme="solid"
|
||||
icon={<IconDelete />}
|
||||
disabled={job.isOnlyShared}
|
||||
onClick={() => onJobRemoval(job.id)}
|
||||
/>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
{(jobsData?.result || []).length > 0 && jobsData?.totalNumber > 12 && (
|
||||
<div className="jobGrid__pagination">
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
pageSize={pageSize}
|
||||
total={jobsData?.totalNumber || 0}
|
||||
onPageChange={handlePageChange}
|
||||
showSizeChanger={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default JobGrid;
|
||||
69
ui/src/components/grid/jobs/JobGrid.less
Normal file
69
ui/src/components/grid/jobs/JobGrid.less
Normal file
@@ -0,0 +1,69 @@
|
||||
.jobGrid {
|
||||
&__card {
|
||||
height: 100%;
|
||||
transition: transform 0.2s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--semi-shadow-elevated);
|
||||
}
|
||||
}
|
||||
|
||||
&__searchbar {
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
&__toolbar {
|
||||
&__card {
|
||||
border-radius: 5px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .3rem;
|
||||
background: #232429;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
&__infoItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
|
||||
.semi-typography {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__pagination {
|
||||
margin-top: 2rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.jobPopoverContent {
|
||||
padding: .4rem;
|
||||
color: var(--semi-color-white);
|
||||
}
|
||||
324
ui/src/components/grid/listings/ListingsGrid.jsx
Normal file
324
ui/src/components/grid/listings/ListingsGrid.jsx
Normal file
@@ -0,0 +1,324 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Col,
|
||||
Row,
|
||||
Image,
|
||||
Button,
|
||||
Space,
|
||||
Typography,
|
||||
Pagination,
|
||||
Toast,
|
||||
Divider,
|
||||
Input,
|
||||
Select,
|
||||
Popover,
|
||||
Empty,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconBriefcase,
|
||||
IconCart,
|
||||
IconClock,
|
||||
IconDelete,
|
||||
IconLink,
|
||||
IconMapPin,
|
||||
IconStar,
|
||||
IconStarStroked,
|
||||
IconSearch,
|
||||
IconFilter,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import no_image from '../../../assets/no_image.jpg';
|
||||
import * as timeService from '../../../services/time/timeService.js';
|
||||
import { xhrDelete, xhrPost } from '../../../services/xhr.js';
|
||||
import { useActions, useSelector } from '../../../services/state/store.js';
|
||||
import debounce from 'lodash/debounce';
|
||||
|
||||
import './ListingsGrid.less';
|
||||
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const ListingsGrid = () => {
|
||||
const listingsData = useSelector((state) => state.listingsData);
|
||||
const providers = useSelector((state) => state.provider);
|
||||
const jobs = useSelector((state) => state.jobsData.jobs);
|
||||
const actions = useActions();
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 40;
|
||||
|
||||
const [sortField, setSortField] = useState('created_at');
|
||||
const [sortDir, setSortDir] = useState('desc');
|
||||
const [freeTextFilter, setFreeTextFilter] = useState(null);
|
||||
const [watchListFilter, setWatchListFilter] = useState(null);
|
||||
const [jobNameFilter, setJobNameFilter] = useState(null);
|
||||
const [activityFilter, setActivityFilter] = useState(null);
|
||||
const [providerFilter, setProviderFilter] = useState(null);
|
||||
const [showFilterBar, setShowFilterBar] = useState(false);
|
||||
|
||||
const loadData = () => {
|
||||
actions.listingsData.getListingsData({
|
||||
page,
|
||||
pageSize,
|
||||
sortfield: sortField,
|
||||
sortdir: sortDir,
|
||||
freeTextFilter,
|
||||
filter: { watchListFilter, jobNameFilter, activityFilter, providerFilter },
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [page, sortField, sortDir, freeTextFilter, providerFilter, activityFilter, jobNameFilter, watchListFilter]);
|
||||
|
||||
const handleFilterChange = useMemo(() => debounce((value) => setFreeTextFilter(value), 500), []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// cleanup debounced handler to avoid memory leaks
|
||||
handleFilterChange.cancel && handleFilterChange.cancel();
|
||||
};
|
||||
}, [handleFilterChange]);
|
||||
|
||||
const handleWatch = async (e, item) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await xhrPost('/api/listings/watch', { listingId: item.id });
|
||||
Toast.success(item.isWatched === 1 ? 'Listing removed from Watchlist' : 'Listing added to Watchlist');
|
||||
loadData();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
Toast.error('Failed to operate Watchlist');
|
||||
}
|
||||
};
|
||||
|
||||
const handlePageChange = (_page) => {
|
||||
setPage(_page);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="listingsGrid">
|
||||
<div className="listingsGrid__searchbar">
|
||||
<Input prefix={<IconSearch />} showClear placeholder="Search" onChange={handleFilterChange} />
|
||||
<Popover content="Filter / Sort Results" style={{ color: 'white', padding: '.5rem' }}>
|
||||
<Button
|
||||
icon={<IconFilter />}
|
||||
onClick={() => {
|
||||
setShowFilterBar(!showFilterBar);
|
||||
}}
|
||||
/>
|
||||
</Popover>
|
||||
</div>
|
||||
{showFilterBar && (
|
||||
<div className="listingsGrid__toolbar">
|
||||
<Space wrap style={{ marginBottom: '1rem' }}>
|
||||
<div className="listingsGrid__toolbar__card">
|
||||
<div>
|
||||
<Text strong>Filter by:</Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '.3rem' }}>
|
||||
<Select
|
||||
placeholder="Status"
|
||||
showClear
|
||||
onChange={(val) => setActivityFilter(val)}
|
||||
value={activityFilter}
|
||||
>
|
||||
<Select.Option value={true}>Active</Select.Option>
|
||||
<Select.Option value={false}>Not Active</Select.Option>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
placeholder="Watchlist"
|
||||
showClear
|
||||
onChange={(val) => setWatchListFilter(val)}
|
||||
value={watchListFilter}
|
||||
>
|
||||
<Select.Option value={true}>Watched</Select.Option>
|
||||
<Select.Option value={false}>Not Watched</Select.Option>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
placeholder="Provider"
|
||||
showClear
|
||||
onChange={(val) => setProviderFilter(val)}
|
||||
value={providerFilter}
|
||||
>
|
||||
{providers?.map((p) => (
|
||||
<Select.Option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
placeholder="Job Name"
|
||||
showClear
|
||||
onChange={(val) => setJobNameFilter(val)}
|
||||
value={jobNameFilter}
|
||||
>
|
||||
{jobs?.map((j) => (
|
||||
<Select.Option key={j.id} value={j.id}>
|
||||
{j.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<Divider layout="vertical" />
|
||||
|
||||
<div className="listingsGrid__toolbar__card">
|
||||
<div>
|
||||
<Text strong>Sort by:</Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '.3rem' }}>
|
||||
<Select
|
||||
placeholder="Sort By"
|
||||
style={{ width: 140 }}
|
||||
value={sortField}
|
||||
onChange={(val) => setSortField(val)}
|
||||
>
|
||||
<Select.Option value="job_name">Job Name</Select.Option>
|
||||
<Select.Option value="created_at">Listing Date</Select.Option>
|
||||
<Select.Option value="price">Price</Select.Option>
|
||||
<Select.Option value="provider">Provider</Select.Option>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
placeholder="Direction"
|
||||
style={{ width: 120 }}
|
||||
value={sortDir}
|
||||
onChange={(val) => setSortDir(val)}
|
||||
>
|
||||
<Select.Option value="asc">Ascending</Select.Option>
|
||||
<Select.Option value="desc">Descending</Select.Option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(listingsData?.result || []).length === 0 && (
|
||||
<Empty
|
||||
image={<IllustrationNoResult />}
|
||||
darkModeImage={<IllustrationNoResultDark />}
|
||||
description="No listings available yet..."
|
||||
/>
|
||||
)}
|
||||
<Row gutter={[16, 16]}>
|
||||
{(listingsData?.result || []).map((item) => (
|
||||
<Col key={item.id} xs={24} sm={12} md={8} lg={6} xl={4} xxl={6}>
|
||||
<Card
|
||||
className={`listingsGrid__card ${!item.is_active ? 'listingsGrid__card--inactive' : ''}`}
|
||||
cover={
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div className="listingsGrid__imageContainer">
|
||||
<Image
|
||||
src={item.image_url || no_image}
|
||||
fallback={no_image}
|
||||
width="100%"
|
||||
height={180}
|
||||
style={{ objectFit: 'cover' }}
|
||||
preview={false}
|
||||
/>
|
||||
<Button
|
||||
icon={
|
||||
item.isWatched === 1 ? (
|
||||
<IconStar style={{ color: 'rgba(var(--semi-green-5), 1)' }} />
|
||||
) : (
|
||||
<IconStarStroked />
|
||||
)
|
||||
}
|
||||
theme="light"
|
||||
shape="circle"
|
||||
size="small"
|
||||
className="listingsGrid__watchButton"
|
||||
onClick={(e) => handleWatch(e, item)}
|
||||
/>
|
||||
</div>
|
||||
{!item.is_active && <div className="listingsGrid__inactiveOverlay">Inactive</div>}
|
||||
</div>
|
||||
}
|
||||
bodyStyle={{ padding: '12px' }}
|
||||
>
|
||||
<div className="listingsGrid__content">
|
||||
<a href={item.url} target="_blank" rel="noopener noreferrer" className="listingsGrid__titleLink">
|
||||
<Text strong ellipsis={{ showTooltip: true }} className="listingsGrid__title">
|
||||
{item.title}
|
||||
</Text>
|
||||
</a>
|
||||
<Space vertical align="start" spacing={2} style={{ width: '100%', marginTop: 8 }}>
|
||||
<Text type="secondary" icon={<IconCart />} size="small">
|
||||
{item.price} €
|
||||
</Text>
|
||||
<Text
|
||||
type="secondary"
|
||||
icon={<IconMapPin />}
|
||||
size="small"
|
||||
ellipsis={{ showTooltip: true }}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{item.address || 'No address provided'}
|
||||
</Text>
|
||||
<Text type="tertiary" size="small" icon={<IconClock />}>
|
||||
{timeService.format(item.created_at, false)}
|
||||
</Text>
|
||||
<Text type="tertiary" size="small" icon={<IconBriefcase />}>
|
||||
{item.provider.charAt(0).toUpperCase() + item.provider.slice(1)}
|
||||
</Text>
|
||||
</Space>
|
||||
<Divider margin=".6rem" />
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Button
|
||||
title="Link to listing"
|
||||
type="primary"
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
window.open(item.link);
|
||||
}}
|
||||
icon={<IconLink />}
|
||||
/>
|
||||
|
||||
<Button
|
||||
title="Remove"
|
||||
type="danger"
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await xhrDelete('/api/listings/', { ids: [item.id] });
|
||||
Toast.success('Listing(s) successfully removed');
|
||||
loadData();
|
||||
} catch (error) {
|
||||
Toast.error(error);
|
||||
}
|
||||
}}
|
||||
icon={<IconDelete />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
{(listingsData?.result || []).length > 0 && (
|
||||
<div className="listingsGrid__pagination">
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
pageSize={pageSize}
|
||||
total={listingsData?.totalNumber || 0}
|
||||
onPageChange={handlePageChange}
|
||||
showSizeChanger={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListingsGrid;
|
||||
106
ui/src/components/grid/listings/ListingsGrid.less
Normal file
106
ui/src/components/grid/listings/ListingsGrid.less
Normal file
@@ -0,0 +1,106 @@
|
||||
.listingsGrid {
|
||||
&__imageContainer {
|
||||
position: relative;
|
||||
height: 180px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__searchbar {
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
&__watchButton {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
background-color: white !important;
|
||||
box-shadow: var(--semi-shadow-elevated);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--semi-color-fill-0) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&__statusTag {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
left: 8px;
|
||||
}
|
||||
|
||||
&__card {
|
||||
height: 100%;
|
||||
transition: transform 0.2s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--semi-shadow-elevated);
|
||||
}
|
||||
|
||||
&--inactive {
|
||||
.listingsGrid__imageContainer,
|
||||
.listingsGrid__content {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__inactiveOverlay {
|
||||
position: absolute;
|
||||
top: 70px;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
color: var(--semi-color-danger);
|
||||
font-weight: bold;
|
||||
font-size: 1.3rem;
|
||||
text-transform: uppercase;
|
||||
transform: rotate(-30deg);
|
||||
padding: 5px;
|
||||
max-height: fit-content;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
&__titleLink {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
color: var(--semi-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
display: block;
|
||||
height: 1.5em;
|
||||
}
|
||||
|
||||
&__pagination {
|
||||
margin-top: 2rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&__toolbar {
|
||||
|
||||
&__card {
|
||||
border-radius: 5px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .3rem;
|
||||
background: #232429;
|
||||
}
|
||||
}
|
||||
|
||||
&__setupButton {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { Button, Empty, Table, Switch, Popover } from '@douyinfe/semi-ui';
|
||||
import { IconAlertTriangle, IconDelete, IconDescend2, IconEdit, IconPlayCircle } from '@douyinfe/semi-icons';
|
||||
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
||||
|
||||
import './JobTable.less';
|
||||
|
||||
const empty = (
|
||||
<Empty
|
||||
image={<IllustrationNoResult />}
|
||||
darkModeImage={<IllustrationNoResultDark />}
|
||||
description="No jobs available. Why don't you create one? ;)"
|
||||
/>
|
||||
);
|
||||
|
||||
const getPopoverContent = (text) => <article className="jobPopoverContent">{text}</article>;
|
||||
|
||||
export default function JobTable({
|
||||
jobs = {},
|
||||
onJobRemoval,
|
||||
onJobStatusChanged,
|
||||
onJobEdit,
|
||||
onListingRemoval,
|
||||
onJobRun,
|
||||
} = {}) {
|
||||
return (
|
||||
<Table
|
||||
pagination={false}
|
||||
empty={empty}
|
||||
columns={[
|
||||
{
|
||||
title: '',
|
||||
dataIndex: '',
|
||||
render: (job) => {
|
||||
return (
|
||||
<Switch
|
||||
onChange={(checked) => onJobStatusChanged(job.id, checked)}
|
||||
checked={job.enabled}
|
||||
disabled={job.isOnlyShared}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Name',
|
||||
dataIndex: 'name',
|
||||
render: (name, job) => {
|
||||
if (job.isOnlyShared) {
|
||||
return (
|
||||
<Popover
|
||||
content={getPopoverContent(
|
||||
'This job has been shared with you by another user, therefor it is read-only.',
|
||||
)}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: '.3rem' }}>
|
||||
<div style={{ color: 'rgba(var(--semi-yellow-7), 1)' }}>
|
||||
<IconAlertTriangle />
|
||||
</div>
|
||||
{name}
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
} else {
|
||||
return name;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Listings',
|
||||
dataIndex: 'numberOfFoundListings',
|
||||
render: (value) => {
|
||||
return value || 0;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Provider',
|
||||
dataIndex: 'provider',
|
||||
render: (value) => {
|
||||
return value.length || 0;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Notification Adapter',
|
||||
dataIndex: 'notificationAdapter',
|
||||
render: (value) => {
|
||||
return value.length || 0;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
dataIndex: 'tools',
|
||||
render: (_, job) => {
|
||||
return (
|
||||
<div className="interactions">
|
||||
<Popover content={getPopoverContent('Run Job')}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<IconPlayCircle />}
|
||||
disabled={job.isOnlyShared || job.running}
|
||||
onClick={() => onJobRun && onJobRun(job.id)}
|
||||
/>
|
||||
</Popover>
|
||||
<Popover content={getPopoverContent('Edit a Job')}>
|
||||
<Button
|
||||
type="secondary"
|
||||
icon={<IconEdit />}
|
||||
disabled={job.isOnlyShared}
|
||||
onClick={() => onJobEdit(job.id)}
|
||||
/>
|
||||
</Popover>
|
||||
<Popover content={getPopoverContent('Delete all found Listings of this Job')}>
|
||||
<Button
|
||||
type="danger"
|
||||
icon={<IconDescend2 />}
|
||||
disabled={job.isOnlyShared}
|
||||
onClick={() => onListingRemoval(job.id)}
|
||||
/>
|
||||
</Popover>
|
||||
<Popover content={getPopoverContent('Delete Job')}>
|
||||
<Button
|
||||
type="danger"
|
||||
icon={<IconDelete />}
|
||||
disabled={job.isOnlyShared}
|
||||
onClick={() => onJobRemoval(job.id)}
|
||||
/>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
dataSource={jobs}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
.interactions {
|
||||
float: right;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.jobPopoverContent {
|
||||
padding: 1rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.interactions {
|
||||
flex-direction: initial;
|
||||
}
|
||||
}
|
||||
@@ -1,417 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Popover,
|
||||
Input,
|
||||
Descriptions,
|
||||
Tag,
|
||||
Image,
|
||||
Empty,
|
||||
Button,
|
||||
Toast,
|
||||
Divider,
|
||||
Space,
|
||||
Select,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { useActions, useSelector } from '../../../services/state/store.js';
|
||||
import { IconClose, IconDelete, IconSearch, IconStar, IconStarStroked, IconTick } from '@douyinfe/semi-icons';
|
||||
import * as timeService from '../../../services/time/timeService.js';
|
||||
import debounce from 'lodash/debounce';
|
||||
import no_image from '../../../assets/no_image.jpg';
|
||||
|
||||
import './ListingsTable.less';
|
||||
import { format } from '../../../services/time/timeService.js';
|
||||
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
||||
import { xhrDelete, xhrPost } from '../../../services/xhr.js';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useFeature } from '../../../hooks/featureHook.js';
|
||||
|
||||
const getColumns = (provider, setProviderFilter, jobs, setJobNameFilter) => {
|
||||
return [
|
||||
{
|
||||
title: 'Watchlist',
|
||||
width: 133,
|
||||
dataIndex: 'isWatched',
|
||||
sorter: true,
|
||||
filters: [
|
||||
{
|
||||
text: 'Show only watched listings',
|
||||
value: 'watchList',
|
||||
},
|
||||
],
|
||||
render: (id, row) => {
|
||||
return (
|
||||
<div>
|
||||
<Popover
|
||||
style={{
|
||||
padding: '.4rem',
|
||||
color: 'var(--semi-color-white)',
|
||||
}}
|
||||
content={row.isWatched === 1 ? 'Unwatch Listing' : 'Watch Listing'}
|
||||
>
|
||||
<Button
|
||||
icon={
|
||||
row.isWatched === 1 ? (
|
||||
<IconStar style={{ color: 'rgba(var(--semi-green-5), 1)' }} />
|
||||
) : (
|
||||
<IconStarStroked />
|
||||
)
|
||||
}
|
||||
theme="borderless"
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await xhrPost('/api/listings/watch', { listingId: row.id });
|
||||
Toast.success(
|
||||
row.isWatched === 1 ? 'Listing removed from Watchlist' : 'Listing added to Watchlist',
|
||||
);
|
||||
row.reloadTable();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
Toast.error('Failed to operate Watchlist');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Popover>
|
||||
<Divider layout="vertical" margin="4px" />
|
||||
<Popover
|
||||
style={{
|
||||
padding: '.4rem',
|
||||
color: 'var(--semi-color-white)',
|
||||
}}
|
||||
content="Delete Listing"
|
||||
>
|
||||
<Button
|
||||
icon={<IconDelete />}
|
||||
theme="borderless"
|
||||
size="small"
|
||||
type="danger"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await xhrDelete('/api/listings/', { ids: [row.id] });
|
||||
Toast.success('Listing(s) successfully removed');
|
||||
row.reloadTable();
|
||||
} catch (error) {
|
||||
Toast.error(error);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Active',
|
||||
dataIndex: 'is_active',
|
||||
width: 110,
|
||||
sorter: true,
|
||||
filters: [
|
||||
{
|
||||
text: 'Show only active listings',
|
||||
value: 'activityStatus',
|
||||
},
|
||||
],
|
||||
render: (value) => {
|
||||
return value ? (
|
||||
<div style={{ color: 'rgba(var(--semi-green-6), 1)' }}>
|
||||
<Popover
|
||||
style={{
|
||||
padding: '.4rem',
|
||||
color: 'var(--semi-color-white)',
|
||||
}}
|
||||
content="Listing is still active"
|
||||
>
|
||||
<IconTick />
|
||||
</Popover>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ color: 'rgba(var(--semi-red-5), 1)' }}>
|
||||
<Popover
|
||||
style={{
|
||||
padding: '.4rem',
|
||||
color: 'var(--semi-color-white)',
|
||||
}}
|
||||
content="Listing is inactive"
|
||||
>
|
||||
<IconClose />
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Job-Name',
|
||||
sorter: true,
|
||||
ellipsis: true,
|
||||
dataIndex: 'job_name',
|
||||
width: 150,
|
||||
onFilter: () => true,
|
||||
renderFilterDropdown: () => {
|
||||
return (
|
||||
<Space vertical style={{ padding: 8 }}>
|
||||
<Select showClear placeholder="Select Job to Filter" onChange={(val) => setJobNameFilter(val)}>
|
||||
{jobs != null &&
|
||||
jobs.length > 0 &&
|
||||
jobs.map((job) => {
|
||||
return (
|
||||
<Select.Option value={job.id} key={job.id}>
|
||||
{job.name}
|
||||
</Select.Option>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Listing date',
|
||||
width: 130,
|
||||
dataIndex: 'created_at',
|
||||
sorter: true,
|
||||
render: (text) => timeService.format(text, false),
|
||||
},
|
||||
{
|
||||
title: 'Provider',
|
||||
width: 130,
|
||||
dataIndex: 'provider',
|
||||
sorter: true,
|
||||
render: (text) => text.charAt(0).toUpperCase() + text.slice(1),
|
||||
onFilter: () => true,
|
||||
renderFilterDropdown: () => {
|
||||
return (
|
||||
<Space vertical style={{ padding: 8 }}>
|
||||
<Select showClear placeholder="Select Provider to Filter" onChange={(val) => setProviderFilter(val)}>
|
||||
{provider != null &&
|
||||
provider.length > 0 &&
|
||||
provider.map((prov) => {
|
||||
return (
|
||||
<Select.Option value={prov.id} key={prov.id}>
|
||||
{prov.name}
|
||||
</Select.Option>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Price',
|
||||
width: 110,
|
||||
dataIndex: 'price',
|
||||
sorter: true,
|
||||
render: (text) => text + ' €',
|
||||
},
|
||||
{
|
||||
title: 'Address',
|
||||
width: 150,
|
||||
dataIndex: 'address',
|
||||
sorter: true,
|
||||
},
|
||||
{
|
||||
title: 'Title',
|
||||
dataIndex: 'title',
|
||||
sorter: true,
|
||||
ellipsis: true,
|
||||
render: (text, row) => {
|
||||
return (
|
||||
<a href={row.url} target="_blank" rel="noopener noreferrer">
|
||||
{text}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const empty = (
|
||||
<Empty
|
||||
image={<IllustrationNoResult />}
|
||||
darkModeImage={<IllustrationNoResultDark />}
|
||||
description="No listings found."
|
||||
/>
|
||||
);
|
||||
|
||||
export default function ListingsTable() {
|
||||
const tableData = useSelector((state) => state.listingsTable);
|
||||
const provider = useSelector((state) => state.provider);
|
||||
const jobs = useSelector((state) => state.jobs.jobs);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const watchlistFeature = useFeature('WATCHLIST_MANAGEMENT') || false;
|
||||
const actions = useActions();
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 10;
|
||||
const [sortData, setSortData] = useState({});
|
||||
const [freeTextFilter, setFreeTextFilter] = useState(null);
|
||||
const [watchListFilter, setWatchListFilter] = useState(null);
|
||||
const [jobNameFilter, setJobNameFilter] = useState(null);
|
||||
const [activityFilter, setActivityFilter] = useState(null);
|
||||
const [providerFilter, setProviderFilter] = useState(null);
|
||||
const [allFilters, setAllFilters] = useState([]);
|
||||
|
||||
const [imageWidth, setImageWidth] = useState('100%');
|
||||
const handlePageChange = (_page) => {
|
||||
setPage(_page);
|
||||
};
|
||||
|
||||
const columns = getColumns(provider, setProviderFilter, jobs, setJobNameFilter);
|
||||
const loadTable = () => {
|
||||
let sortfield = null;
|
||||
let sortdir = null;
|
||||
|
||||
if (sortData != null && Object.keys(sortData).length > 0) {
|
||||
sortfield = sortData.field;
|
||||
sortdir = sortData.direction;
|
||||
}
|
||||
actions.listingsTable.getListingsTable({
|
||||
page,
|
||||
pageSize,
|
||||
sortfield,
|
||||
sortdir,
|
||||
freeTextFilter,
|
||||
filter: { watchListFilter, jobNameFilter, activityFilter, providerFilter },
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadTable();
|
||||
}, [page, sortData, freeTextFilter, providerFilter, activityFilter, jobNameFilter, watchListFilter]);
|
||||
|
||||
const handleFilterChange = useMemo(() => debounce((value) => setFreeTextFilter(value), 500), []);
|
||||
|
||||
const diffArrays = (primary, secondary) => {
|
||||
const result = {};
|
||||
|
||||
for (const item of secondary) {
|
||||
if (!primary.includes(item)) result[item] = true;
|
||||
}
|
||||
|
||||
for (const item of primary) {
|
||||
if (!secondary.includes(item)) result[item] = false;
|
||||
}
|
||||
|
||||
return [result];
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// cleanup debounced handler to avoid memory leaks
|
||||
handleFilterChange.cancel && handleFilterChange.cancel();
|
||||
};
|
||||
}, [handleFilterChange]);
|
||||
|
||||
const expandRowRender = (record) => {
|
||||
return (
|
||||
<div className="listingsTable__expanded">
|
||||
<div>
|
||||
{record.image_url == null ? (
|
||||
<Image height={200} width={180} src={no_image} />
|
||||
) : (
|
||||
<Image
|
||||
height={200}
|
||||
width={imageWidth}
|
||||
src={record.image_url}
|
||||
onError={() => {
|
||||
setImageWidth('180px');
|
||||
}}
|
||||
fallback={<Image height={200} src={no_image} />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Descriptions align="justify">
|
||||
<Descriptions.Item itemKey="Listing still online">
|
||||
<Tag size="small" shape="circle" color={record.is_active ? 'green' : 'red'}>
|
||||
{record.is_active ? 'Yes' : 'No'}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item itemKey="Link">
|
||||
<a href={record.link} target="_blank" rel="noopener noreferrer">
|
||||
Link to Listing
|
||||
</a>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item itemKey="Listing date">{format(record.created_at)}</Descriptions.Item>
|
||||
<Descriptions.Item itemKey="Price">{record.price} €</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<b>{record.title}</b>
|
||||
<p>{record.description == null ? 'No description available' : record.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Input
|
||||
prefix={<IconSearch />}
|
||||
showClear
|
||||
className="listingsTable__search"
|
||||
placeholder="Search"
|
||||
onChange={handleFilterChange}
|
||||
/>
|
||||
{watchlistFeature && (
|
||||
<Button
|
||||
className="listingsTable__setupButton"
|
||||
onClick={() => {
|
||||
navigate('/watchlistManagement');
|
||||
}}
|
||||
>
|
||||
Setup notifications on watchlist changes
|
||||
</Button>
|
||||
)}
|
||||
<Table
|
||||
rowKey="id"
|
||||
empty={empty}
|
||||
hideExpandedColumn={false}
|
||||
sticky={{ top: 5 }}
|
||||
columns={columns}
|
||||
expandedRowRender={expandRowRender}
|
||||
dataSource={(tableData?.result || []).map((row) => {
|
||||
return {
|
||||
...row,
|
||||
reloadTable: loadTable,
|
||||
};
|
||||
})}
|
||||
onChange={(changeSet) => {
|
||||
if (changeSet?.extra?.changeType === 'filter') {
|
||||
const transformed = changeSet.filters.map((f) => f.dataIndex);
|
||||
const diff = diffArrays(allFilters, transformed);
|
||||
setAllFilters(transformed);
|
||||
diff.forEach((filter) => {
|
||||
switch (Object.keys(filter)[0]) {
|
||||
case 'isWatched':
|
||||
setWatchListFilter(Object.values(filter)[0]);
|
||||
break;
|
||||
case 'is_active':
|
||||
setActivityFilter(Object.values(filter)[0]);
|
||||
break;
|
||||
default:
|
||||
console.error('Unknown filter: ', filter.dataIndex);
|
||||
}
|
||||
});
|
||||
} else if (changeSet?.extra?.changeType === 'sorter') {
|
||||
setSortData({
|
||||
field: changeSet.sorter.dataIndex,
|
||||
direction: changeSet.sorter.sortOrder === 'ascend' ? 'asc' : 'desc',
|
||||
});
|
||||
}
|
||||
}}
|
||||
pagination={{
|
||||
currentPage: page,
|
||||
//for now fixed
|
||||
pageSize,
|
||||
total: tableData?.totalNumber || 0,
|
||||
onPageChange: handlePageChange,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
.listingsTable {
|
||||
&__search {
|
||||
margin-bottom: 1rem !important;
|
||||
}
|
||||
|
||||
&__expanded {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
&__toolbar {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
&__setupButton {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
@@ -83,19 +83,44 @@ export const useFredyState = create(
|
||||
}
|
||||
},
|
||||
},
|
||||
jobs: {
|
||||
jobsData: {
|
||||
async getJobs() {
|
||||
try {
|
||||
const response = await xhrGet('/api/jobs');
|
||||
set((state) => ({ jobs: { ...state.jobs, jobs: Object.freeze(response.json) } }));
|
||||
set((state) => ({ jobsData: { ...state.jobsData, jobs: Object.freeze(response.json) } }));
|
||||
} catch (Exception) {
|
||||
console.error(`Error while trying to get resource for api/jobs. Error:`, Exception);
|
||||
}
|
||||
},
|
||||
async getJobsData({
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
freeTextFilter = null,
|
||||
sortfield = null,
|
||||
sortdir = 'asc',
|
||||
filter,
|
||||
} = {}) {
|
||||
try {
|
||||
const qryString = queryString.stringify({
|
||||
page,
|
||||
pageSize,
|
||||
freeTextFilter,
|
||||
sortfield,
|
||||
sortdir,
|
||||
...filter,
|
||||
});
|
||||
const response = await xhrGet(`/api/jobs/data?${qryString}`);
|
||||
set((state) => ({
|
||||
jobsData: { ...state.jobsData, ...response.json },
|
||||
}));
|
||||
} catch (Exception) {
|
||||
console.error('Error while trying to get resource for api/jobs/data. Error:', Exception);
|
||||
}
|
||||
},
|
||||
async getSharableUserList() {
|
||||
try {
|
||||
const response = await xhrGet('/api/jobs/shareableUserList');
|
||||
set((state) => ({ jobs: { ...state.jobs, shareableUserList: Object.freeze(response.json) } }));
|
||||
set((state) => ({ jobsData: { ...state.jobsData, shareableUserList: Object.freeze(response.json) } }));
|
||||
} catch (Exception) {
|
||||
console.error(`Error while trying to get resource for api/jobs. Error:`, Exception);
|
||||
}
|
||||
@@ -103,9 +128,12 @@ export const useFredyState = create(
|
||||
setJobRunning(jobId, running) {
|
||||
if (!jobId) return;
|
||||
set((state) => {
|
||||
const list = state.jobs.jobs || [];
|
||||
const list = state.jobsData.jobs || [];
|
||||
const updated = list.map((j) => (j.id === jobId ? { ...j, running: !!running } : j));
|
||||
return { jobs: { ...state.jobs, jobs: Object.freeze(updated) } };
|
||||
const result = (state.jobsData.result || []).map((j) =>
|
||||
j.id === jobId ? { ...j, running: !!running } : j,
|
||||
);
|
||||
return { jobsData: { ...state.jobsData, jobs: Object.freeze(updated), result: Object.freeze(result) } };
|
||||
});
|
||||
},
|
||||
},
|
||||
@@ -151,8 +179,8 @@ export const useFredyState = create(
|
||||
}
|
||||
},
|
||||
},
|
||||
listingsTable: {
|
||||
async getListingsTable({
|
||||
listingsData: {
|
||||
async getListingsData({
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
freeTextFilter = null,
|
||||
@@ -171,7 +199,7 @@ export const useFredyState = create(
|
||||
});
|
||||
const response = await xhrGet(`/api/listings/table?${qryString}`);
|
||||
set((state) => ({
|
||||
listingsTable: { ...state.listingsTable, ...response.json },
|
||||
listingsData: { ...state.listingsData, ...response.json },
|
||||
}));
|
||||
} catch (Exception) {
|
||||
console.error('Error while trying to get resource for api/listings. Error:', Exception);
|
||||
@@ -184,7 +212,7 @@ export const useFredyState = create(
|
||||
const initial = {
|
||||
dashboard: { data: null },
|
||||
notificationAdapter: [],
|
||||
listingsTable: {
|
||||
listingsData: {
|
||||
totalNumber: 0,
|
||||
page: 1,
|
||||
result: [],
|
||||
@@ -194,7 +222,13 @@ export const useFredyState = create(
|
||||
demoMode: { demoMode: false },
|
||||
versionUpdate: {},
|
||||
provider: [],
|
||||
jobs: { jobs: [], shareableUserList: [] },
|
||||
jobsData: {
|
||||
jobs: [],
|
||||
shareableUserList: [],
|
||||
totalNumber: 0,
|
||||
page: 1,
|
||||
result: [],
|
||||
},
|
||||
user: { users: [], currentUser: null },
|
||||
};
|
||||
|
||||
@@ -205,10 +239,10 @@ export const useFredyState = create(
|
||||
generalSettings: { ...effects.generalSettings },
|
||||
demoMode: { ...effects.demoMode },
|
||||
versionUpdate: { ...effects.versionUpdate },
|
||||
listingsTable: { ...effects.listingsTable },
|
||||
listingsData: { ...effects.listingsData },
|
||||
provider: { ...effects.provider },
|
||||
features: { ...effects.features },
|
||||
jobs: { ...effects.jobs },
|
||||
jobsData: { ...effects.jobsData },
|
||||
user: { ...effects.user },
|
||||
};
|
||||
|
||||
|
||||
@@ -5,136 +5,13 @@
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import JobTable from '../../components/table/JobTable';
|
||||
import { useSelector, useActions } from '../../services/state/store';
|
||||
import { xhrDelete, xhrPut, xhrPost } from '../../services/xhr';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button, Toast } from '@douyinfe/semi-ui';
|
||||
import { IconPlusCircle } from '@douyinfe/semi-icons';
|
||||
import JobGrid from '../../components/grid/jobs/JobGrid.jsx';
|
||||
import './Jobs.less';
|
||||
|
||||
export default function Jobs() {
|
||||
const jobs = useSelector((state) => state.jobs.jobs);
|
||||
const navigate = useNavigate();
|
||||
const actions = useActions();
|
||||
const pendingJobIdRef = React.useRef(null);
|
||||
const evtSourceRef = React.useRef(null);
|
||||
|
||||
// SSE connection for live job status updates
|
||||
React.useEffect(() => {
|
||||
// establish SSE connection
|
||||
const src = new EventSource('/api/jobs/events');
|
||||
evtSourceRef.current = src;
|
||||
|
||||
const onJobStatus = (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data || '{}');
|
||||
if (data && data.jobId) {
|
||||
actions.jobs.setJobRunning(data.jobId, !!data.running);
|
||||
// notify finish if it was triggered by this view
|
||||
if (pendingJobIdRef.current === data.jobId && data.running === false) {
|
||||
Toast.success('Job finished');
|
||||
pendingJobIdRef.current = null;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed events
|
||||
}
|
||||
};
|
||||
|
||||
src.addEventListener('jobStatus', onJobStatus);
|
||||
src.onerror = () => {
|
||||
// Let browser auto-reconnect; optionally log
|
||||
};
|
||||
|
||||
return () => {
|
||||
try {
|
||||
src.removeEventListener('jobStatus', onJobStatus);
|
||||
src.close();
|
||||
} catch {
|
||||
//noop
|
||||
}
|
||||
evtSourceRef.current = null;
|
||||
pendingJobIdRef.current = null;
|
||||
};
|
||||
}, [actions.jobs]);
|
||||
|
||||
const onJobRemoval = async (jobId) => {
|
||||
try {
|
||||
await xhrDelete('/api/jobs', { jobId });
|
||||
Toast.success('Job successfully removed');
|
||||
await actions.jobs.getJobs();
|
||||
} catch (error) {
|
||||
Toast.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const onListingRemoval = async (jobId) => {
|
||||
try {
|
||||
await xhrDelete('/api/listings/job', { jobId });
|
||||
Toast.success('Listings successfully removed');
|
||||
await actions.jobs.getJobs();
|
||||
} catch (error) {
|
||||
Toast.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const onJobStatusChanged = async (jobId, status) => {
|
||||
try {
|
||||
await xhrPut(`/api/jobs/${jobId}/status`, { status });
|
||||
Toast.success('Job status successfully changed');
|
||||
await actions.jobs.getJobs();
|
||||
} catch (error) {
|
||||
Toast.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const onJobRun = async (jobId) => {
|
||||
try {
|
||||
const response = await xhrPost(`/api/jobs/${jobId}/run`);
|
||||
if (response.status === 202) {
|
||||
Toast.success('Job run started');
|
||||
} else {
|
||||
Toast.info('Job run requested');
|
||||
}
|
||||
// remember so we can show a finish toast when SSE says it's done
|
||||
pendingJobIdRef.current = jobId;
|
||||
// optional: one initial refresh in case SSE arrives late
|
||||
await actions.jobs.getJobs();
|
||||
} catch (error) {
|
||||
if (error?.status === 409) {
|
||||
Toast.warning(error?.json?.message || 'Job is already running');
|
||||
} else if (error?.status === 403) {
|
||||
Toast.error('You are not allowed to run this job');
|
||||
} else if (error?.status === 404) {
|
||||
Toast.error('Job not found');
|
||||
} else {
|
||||
Toast.error('Failed to trigger job');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<IconPlusCircle />}
|
||||
className="jobs__newButton"
|
||||
onClick={() => navigate('/jobs/new')}
|
||||
>
|
||||
New Job
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<JobTable
|
||||
jobs={jobs || []}
|
||||
onJobRemoval={onJobRemoval}
|
||||
onListingRemoval={onListingRemoval}
|
||||
onJobStatusChanged={onJobStatusChanged}
|
||||
onJobRun={onJobRun}
|
||||
onJobEdit={(jobId) => navigate(`/jobs/edit/${jobId}`)}
|
||||
/>
|
||||
<div className="jobs">
|
||||
<JobGrid />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,8 +27,8 @@ import {
|
||||
} from '@douyinfe/semi-icons';
|
||||
|
||||
export default function JobMutator() {
|
||||
const jobs = useSelector((state) => state.jobs.jobs);
|
||||
const shareableUserList = useSelector((state) => state.jobs.shareableUserList);
|
||||
const jobs = useSelector((state) => state.jobsData.jobs);
|
||||
const shareableUserList = useSelector((state) => state.jobsData.shareableUserList);
|
||||
const params = useParams();
|
||||
|
||||
const jobToBeEdit = params.jobId == null ? null : jobs.find((job) => job.id === params.jobId);
|
||||
@@ -73,7 +73,7 @@ export default function JobMutator() {
|
||||
enabled,
|
||||
jobId: jobToBeEdit?.id || null,
|
||||
});
|
||||
await actions.jobs.getJobs();
|
||||
await actions.jobsData.getJobs();
|
||||
Toast.success('Job successfully saved...');
|
||||
navigate('/jobs');
|
||||
} catch (Exception) {
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import ListingsTable from '../../components/table/listings/ListingsTable.jsx';
|
||||
import ListingsGrid from '../../components/grid/listings/ListingsGrid.jsx';
|
||||
|
||||
export default function Listings() {
|
||||
return <ListingsTable />;
|
||||
return <ListingsGrid />;
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ const Users = function Users() {
|
||||
await xhrDelete('/api/admin/users', { userId: userIdToBeRemoved });
|
||||
Toast.success('User successfully remove');
|
||||
setUserIdToBeRemoved(null);
|
||||
await actions.jobs.getJobs();
|
||||
await actions.jobsData.getJobs();
|
||||
await actions.user.getUsers();
|
||||
} catch (error) {
|
||||
Toast.error(error);
|
||||
|
||||
162
yarn.lock
162
yarn.lock
@@ -997,34 +997,34 @@
|
||||
dependencies:
|
||||
tslib "^2.0.0"
|
||||
|
||||
"@douyinfe/semi-animation-react@2.89.1":
|
||||
version "2.89.1"
|
||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation-react/-/semi-animation-react-2.89.1.tgz#cbbf6f5cfdfb870c20418441ab5f0ce6a5844146"
|
||||
integrity sha512-SNEy7kujmLp7jUNzxsjEurkYmnd54OQRxedWJ8Taq3R/AhOGq6x1mP6E8EXHcBl3DgWMDdLv7YF9or/6Pom4XQ==
|
||||
"@douyinfe/semi-animation-react@2.90.10":
|
||||
version "2.90.10"
|
||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation-react/-/semi-animation-react-2.90.10.tgz#46c489052cdb4219a21f7d4c4dca44dd40fb6f5d"
|
||||
integrity sha512-xajEqbF1nZnFsocYJGhhOFPn9w1lOy7J6GBITKsMKpuBKq03TSKvZr9Sj7cMALEbPb8Ebp2y44t3e/9fQYpwlw==
|
||||
dependencies:
|
||||
"@douyinfe/semi-animation" "2.89.1"
|
||||
"@douyinfe/semi-animation-styled" "2.89.1"
|
||||
"@douyinfe/semi-animation" "2.90.10"
|
||||
"@douyinfe/semi-animation-styled" "2.90.10"
|
||||
classnames "^2.2.6"
|
||||
|
||||
"@douyinfe/semi-animation-styled@2.89.1":
|
||||
version "2.89.1"
|
||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation-styled/-/semi-animation-styled-2.89.1.tgz#9a8b5b66a0065f1b15573b85c911aaaa1e05dcf3"
|
||||
integrity sha512-Zwl4EHn8IXWiqTAl3IIfbAWtmxH82B/BH2YqX88ASXqja8omJqMQ5ho1yYxzVFiurswz45RtoVm5tdt6M0d+MA==
|
||||
"@douyinfe/semi-animation-styled@2.90.10":
|
||||
version "2.90.10"
|
||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation-styled/-/semi-animation-styled-2.90.10.tgz#4d1cbebf90c60b1e79394dd87a79372114760cad"
|
||||
integrity sha512-o1Oq/yMdX7DfLr1KiK/aDEEK/ZNa/WNmOh79wAyQe+jGVBBOFuyygfwZhYM1Gjp+vy0m/D+wb2S9L40+8JeWSw==
|
||||
|
||||
"@douyinfe/semi-animation@2.89.1":
|
||||
version "2.89.1"
|
||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation/-/semi-animation-2.89.1.tgz#bb8c341895a7439838f654a9b43a65ba256e2a04"
|
||||
integrity sha512-7hKxLO//Ggxr7lYxIwJSQx+WQt9uYfuUnybn7Td/80/Jk3eIs+OJF9rN/8zmECANcxzZxHIuWjX5lfcQDDGhoA==
|
||||
"@douyinfe/semi-animation@2.90.10":
|
||||
version "2.90.10"
|
||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation/-/semi-animation-2.90.10.tgz#b8f1f61ea8b3f548fc7c74cd6c93f196b3b9b0fb"
|
||||
integrity sha512-eHG9LLtPtQfZP3shCkDXvixx8aHQ9wAeN8rrOFEMjJbApuAYtpArcm9nwmjTfYCWce1GZS64jrSt4apuSIKCHA==
|
||||
dependencies:
|
||||
bezier-easing "^2.1.0"
|
||||
|
||||
"@douyinfe/semi-foundation@2.89.1":
|
||||
version "2.89.1"
|
||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-foundation/-/semi-foundation-2.89.1.tgz#9bce2d137974da975c7e44cf3620924efd10bee4"
|
||||
integrity sha512-LP8vLQ/IyKGkzUoUX5/15A2SjTmCEWl/kiouvBeRpPvVyy/worTZe5XVd6ojlbFl0VIYWv4Jwy1w3kJHq4Ltvg==
|
||||
"@douyinfe/semi-foundation@2.90.10":
|
||||
version "2.90.10"
|
||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-foundation/-/semi-foundation-2.90.10.tgz#4aeecd3afd675ba9fc64bb14592de130e1896e5e"
|
||||
integrity sha512-4CARsSG9tghXkdBNyHT1G207vyIr+Y2RZS9zm37LpgrUezesw+znB8Z6mQIfxKrTDb7KezutubGJB/VUFNgj1w==
|
||||
dependencies:
|
||||
"@douyinfe/semi-animation" "2.89.1"
|
||||
"@douyinfe/semi-json-viewer-core" "2.89.1"
|
||||
"@douyinfe/semi-animation" "2.90.10"
|
||||
"@douyinfe/semi-json-viewer-core" "2.90.10"
|
||||
"@mdx-js/mdx" "^3.0.1"
|
||||
async-validator "^3.5.0"
|
||||
classnames "^2.2.6"
|
||||
@@ -1038,44 +1038,44 @@
|
||||
remark-gfm "^4.0.0"
|
||||
scroll-into-view-if-needed "^2.2.24"
|
||||
|
||||
"@douyinfe/semi-icons@2.89.1", "@douyinfe/semi-icons@^2.89.1":
|
||||
version "2.89.1"
|
||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-icons/-/semi-icons-2.89.1.tgz#34635b5d515750ea281ec78190de71522847e2a4"
|
||||
integrity sha512-K1xNbscHkGdx91xP5I6M/JKks/Xd2UzKGhLxBmyQ2D1Oboj95mJ+Dt15A8OGCcx2/n8shaJa2Ldw9JBPiXbNJg==
|
||||
"@douyinfe/semi-icons@2.90.10", "@douyinfe/semi-icons@^2.90.10":
|
||||
version "2.90.10"
|
||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-icons/-/semi-icons-2.90.10.tgz#7115167226ea2c7c70aad030f3df674668878c7c"
|
||||
integrity sha512-8QY24bjsqW3Vpm3RCfsmYo8NBLa/9mJmRe2ycPazHq6YRM2xVgWyqkd7HWlJiRJy/4HDGVKxIwPlujDEHyDWLg==
|
||||
dependencies:
|
||||
classnames "^2.2.6"
|
||||
|
||||
"@douyinfe/semi-illustrations@2.89.1":
|
||||
version "2.89.1"
|
||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-illustrations/-/semi-illustrations-2.89.1.tgz#9ca1508af325d5362dbf802035022cb54e424c9c"
|
||||
integrity sha512-3XEm3JIlCLZCKl/RmysYtIlqe6itbWf6u9nxaE6TWpSKJtSfA+wbyO2F3W2fvXkCJZopjk65BFLIFx/CAEqc5g==
|
||||
"@douyinfe/semi-illustrations@2.90.10":
|
||||
version "2.90.10"
|
||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-illustrations/-/semi-illustrations-2.90.10.tgz#c74cbc2cc96370dba4784b1870000aff0b1abe86"
|
||||
integrity sha512-xZ3MJLrW4YcdSyps/h83AEvrxggcXOZnc5ZwGE+K3IvCV7jjye/0L7dMG7iJEoOMaSDNF2MqQJSVUoDnHV+jBA==
|
||||
|
||||
"@douyinfe/semi-json-viewer-core@2.89.1":
|
||||
version "2.89.1"
|
||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-json-viewer-core/-/semi-json-viewer-core-2.89.1.tgz#582a3c49a06913c287be531670e87120a2220af2"
|
||||
integrity sha512-MSkbeoADyD1d4mERdmMNeqL0SBI2d8b9j/em7LDQp6tLzmfQEIHgPjjwvPthefHBPjUiikPSiSc9Iv/gPayhIQ==
|
||||
"@douyinfe/semi-json-viewer-core@2.90.10":
|
||||
version "2.90.10"
|
||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-json-viewer-core/-/semi-json-viewer-core-2.90.10.tgz#659bcbb485ac1bd42e45e462b38abf4acc4ead03"
|
||||
integrity sha512-0sLzUqR9JF5AxVWDjfpeZDqAi+olcb7ZM/hVKCQxPZEcF4VTQwB/cseg55Zbx3oMkC+xtLjicZy4ymfCLgW22Q==
|
||||
dependencies:
|
||||
jsonc-parser "^3.3.1"
|
||||
|
||||
"@douyinfe/semi-theme-default@2.89.1":
|
||||
version "2.89.1"
|
||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-theme-default/-/semi-theme-default-2.89.1.tgz#57f596e610269d31e021fe6472440970305f9750"
|
||||
integrity sha512-8GGPU4mfhUg9dwyVS+xEhXp7iNEaqEslyRwbaiUImjOwLZmpEbYST/gvYpXkpRltaWGZ7n82P3uVPyQZkIBLAg==
|
||||
"@douyinfe/semi-theme-default@2.90.10":
|
||||
version "2.90.10"
|
||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-theme-default/-/semi-theme-default-2.90.10.tgz#b1f3e0fb3b5bf8d52c2492cd4393a68df0b07c70"
|
||||
integrity sha512-q8liKStjQBuhGcADO5b86rvSW/ZxOhi5Ydgcr7D2/HyTsvkZA7R4spROAfwVGKGkc8juJvGqn2NirU2zwxAuMA==
|
||||
|
||||
"@douyinfe/semi-ui@2.89.1":
|
||||
version "2.89.1"
|
||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-ui/-/semi-ui-2.89.1.tgz#c0573f2531fe24d4830db9331e99295d75af2207"
|
||||
integrity sha512-1jh9c1NwPEHYIPt+vCSF/TeXrhJ6g6/5745h8x2VZMA6+Epexv61eucTiMEz/HZzi9GC/u+K0GuwSS3CjJdEFA==
|
||||
"@douyinfe/semi-ui@2.90.10":
|
||||
version "2.90.10"
|
||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-ui/-/semi-ui-2.90.10.tgz#486c1551a7616a8a073b4343756be25147c73ab0"
|
||||
integrity sha512-BclA5Ir1OEzZpS965T/y51KLIxEwRpJlyV/E2Pljsm98keSpc/kkoh9fKFiPMqTafgiuuey8XWStyC88tYZGEg==
|
||||
dependencies:
|
||||
"@dnd-kit/core" "^6.0.8"
|
||||
"@dnd-kit/sortable" "^7.0.2"
|
||||
"@dnd-kit/utilities" "^3.2.1"
|
||||
"@douyinfe/semi-animation" "2.89.1"
|
||||
"@douyinfe/semi-animation-react" "2.89.1"
|
||||
"@douyinfe/semi-foundation" "2.89.1"
|
||||
"@douyinfe/semi-icons" "2.89.1"
|
||||
"@douyinfe/semi-illustrations" "2.89.1"
|
||||
"@douyinfe/semi-theme-default" "2.89.1"
|
||||
"@douyinfe/semi-animation" "2.90.10"
|
||||
"@douyinfe/semi-animation-react" "2.90.10"
|
||||
"@douyinfe/semi-foundation" "2.90.10"
|
||||
"@douyinfe/semi-icons" "2.90.10"
|
||||
"@douyinfe/semi-illustrations" "2.90.10"
|
||||
"@douyinfe/semi-theme-default" "2.90.10"
|
||||
"@tiptap/core" "^3.10.7"
|
||||
"@tiptap/extension-document" "^3.10.7"
|
||||
"@tiptap/extension-hard-break" "^3.10.7"
|
||||
@@ -2146,10 +2146,10 @@ bl@^4.0.3:
|
||||
inherits "^2.0.4"
|
||||
readable-stream "^3.4.0"
|
||||
|
||||
body-parser@2.2.1:
|
||||
version "2.2.1"
|
||||
resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-2.2.1.tgz#6df606b0eb0a6e3f783dde91dde182c24c82438c"
|
||||
integrity sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==
|
||||
body-parser@2.2.2:
|
||||
version "2.2.2"
|
||||
resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-2.2.2.tgz#1a32cdb966beaf68de50a9dfbe5b58f83cb8890c"
|
||||
integrity sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==
|
||||
dependencies:
|
||||
bytes "^3.1.2"
|
||||
content-type "^1.0.5"
|
||||
@@ -2157,7 +2157,7 @@ body-parser@2.2.1:
|
||||
http-errors "^2.0.0"
|
||||
iconv-lite "^0.7.0"
|
||||
on-finished "^2.4.1"
|
||||
qs "^6.14.0"
|
||||
qs "^6.14.1"
|
||||
raw-body "^3.0.1"
|
||||
type-is "^2.0.1"
|
||||
|
||||
@@ -2267,10 +2267,10 @@ ccount@^2.0.0:
|
||||
resolved "https://registry.yarnpkg.com/ccount/-/ccount-2.0.1.tgz#17a3bf82302e0870d6da43a01311a8bc02a3ecf5"
|
||||
integrity sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==
|
||||
|
||||
chai@6.2.1:
|
||||
version "6.2.1"
|
||||
resolved "https://registry.yarnpkg.com/chai/-/chai-6.2.1.tgz#d1e64bc42433fbee6175ad5346799682060b5b6a"
|
||||
integrity sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==
|
||||
chai@6.2.2:
|
||||
version "6.2.2"
|
||||
resolved "https://registry.yarnpkg.com/chai/-/chai-6.2.2.tgz#ae41b52c9aca87734505362717f3255facda360e"
|
||||
integrity sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==
|
||||
|
||||
chalk@^4.0.0, chalk@^4.1.0:
|
||||
version "4.1.2"
|
||||
@@ -5874,10 +5874,10 @@ punycode@^2.1.0:
|
||||
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
|
||||
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
|
||||
|
||||
puppeteer-core@24.33.1:
|
||||
version "24.33.1"
|
||||
resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-24.33.1.tgz#80b9d79dd0597fd2b1de265aae4dd0d95df81c66"
|
||||
integrity sha512-MZjFLeGMBFbSkc1xKfcv6hjFlfNi1bmQly++HyqxGPYzLIMY0mSYyjqkAzT1PtomTYHq7SEonciIKkeyHExA1g==
|
||||
puppeteer-core@24.34.0:
|
||||
version "24.34.0"
|
||||
resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-24.34.0.tgz#00c7f63b4a83d4ca2ec5ea3a234588fb2ce7c994"
|
||||
integrity sha512-24evawO+mUGW4mvS2a2ivwLdX3gk8zRLZr9HP+7+VT2vBQnm0oh9jJEZmUE3ePJhRkYlZ93i7OMpdcoi2qNCLg==
|
||||
dependencies:
|
||||
"@puppeteer/browsers" "2.11.0"
|
||||
chromium-bidi "12.0.1"
|
||||
@@ -5934,22 +5934,22 @@ puppeteer-extra@^3.3.6:
|
||||
debug "^4.1.1"
|
||||
deepmerge "^4.2.2"
|
||||
|
||||
puppeteer@^24.33.1:
|
||||
version "24.33.1"
|
||||
resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-24.33.1.tgz#c84c9545633bc731b92caead463e77928dcf183e"
|
||||
integrity sha512-2KiSIXk+zFzmYsScv+hx/I3TODFGPcNpyJsWMQk1EQ2y8KZ2X6225/NingyqYxekzceSUnq5qX39dUezVDZ9EQ==
|
||||
puppeteer@^24.34.0:
|
||||
version "24.34.0"
|
||||
resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-24.34.0.tgz#061f6e97ce9511863ec83cd6f17a27253c68b5e9"
|
||||
integrity sha512-Sdpl/zsYOsagZ4ICoZJPGZw8d9gZmK5DcxVal11dXi/1/t2eIXHjCf5NfmhDg5XnG9Nye+yo/LqMzIxie2rHTw==
|
||||
dependencies:
|
||||
"@puppeteer/browsers" "2.11.0"
|
||||
chromium-bidi "12.0.1"
|
||||
cosmiconfig "^9.0.0"
|
||||
devtools-protocol "0.0.1534754"
|
||||
puppeteer-core "24.33.1"
|
||||
puppeteer-core "24.34.0"
|
||||
typed-query-selector "^2.12.0"
|
||||
|
||||
qs@^6.14.0:
|
||||
version "6.14.0"
|
||||
resolved "https://registry.yarnpkg.com/qs/-/qs-6.14.0.tgz#c63fa40680d2c5c941412a0e899c89af60c0a930"
|
||||
integrity sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==
|
||||
qs@^6.14.1:
|
||||
version "6.14.1"
|
||||
resolved "https://registry.yarnpkg.com/qs/-/qs-6.14.1.tgz#a41d85b9d3902f31d27861790506294881871159"
|
||||
integrity sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==
|
||||
dependencies:
|
||||
side-channel "^1.1.0"
|
||||
|
||||
@@ -6033,17 +6033,17 @@ react-resizable@^3.0.5:
|
||||
prop-types "15.x"
|
||||
react-draggable "^4.0.3"
|
||||
|
||||
react-router-dom@7.11.0:
|
||||
version "7.11.0"
|
||||
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.11.0.tgz#2165f63e52798bd0eb138480c098ad058cdf3413"
|
||||
integrity sha512-e49Ir/kMGRzFOOrYQBdoitq3ULigw4lKbAyKusnvtDu2t4dBX4AGYPrzNvorXmVuOyeakai6FUPW5MmibvVG8g==
|
||||
react-router-dom@7.12.0:
|
||||
version "7.12.0"
|
||||
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.12.0.tgz#0f2a059c6b2c4ae04474fe4171c59fb48b9fb8cf"
|
||||
integrity sha512-pfO9fiBcpEfX4Tx+iTYKDtPbrSLLCbwJ5EqP+SPYQu1VYCXdy79GSj0wttR0U4cikVdlImZuEZ/9ZNCgoaxwBA==
|
||||
dependencies:
|
||||
react-router "7.11.0"
|
||||
react-router "7.12.0"
|
||||
|
||||
react-router@7.11.0:
|
||||
version "7.11.0"
|
||||
resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.11.0.tgz#d3b91567fdbe910caf9064ea69b7b4d9264f2945"
|
||||
integrity sha512-uI4JkMmjbWCZc01WVP2cH7ZfSzH91JAZUDd7/nIprDgWxBV1TkkmLToFh7EbMTcMak8URFRa2YoBL/W8GWnCTQ==
|
||||
react-router@7.12.0:
|
||||
version "7.12.0"
|
||||
resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.12.0.tgz#459a86862abbedd02e76e686751fe71f9fd73a4f"
|
||||
integrity sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==
|
||||
dependencies:
|
||||
cookie "^1.0.1"
|
||||
set-cookie-parser "^2.6.0"
|
||||
@@ -7234,10 +7234,10 @@ vfile@^6.0.0:
|
||||
"@types/unist" "^3.0.0"
|
||||
vfile-message "^4.0.0"
|
||||
|
||||
vite@7.3.0:
|
||||
version "7.3.0"
|
||||
resolved "https://registry.yarnpkg.com/vite/-/vite-7.3.0.tgz#066c7a835993a66e82004eac3e185d0d157fd658"
|
||||
integrity sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==
|
||||
vite@7.3.1:
|
||||
version "7.3.1"
|
||||
resolved "https://registry.yarnpkg.com/vite/-/vite-7.3.1.tgz#7f6cfe8fb9074138605e822a75d9d30b814d6507"
|
||||
integrity sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==
|
||||
dependencies:
|
||||
esbuild "^0.27.0"
|
||||
fdir "^6.5.0"
|
||||
|
||||
Reference in New Issue
Block a user