Compare commits

..

12 Commits

Author SHA1 Message Date
orangecoding
ebc57702dc next release version 2025-10-03 13:28:09 +02:00
orangecoding
3aa30bc1e2 remove listings from listingstable when clicked 2025-10-03 13:27:44 +02:00
orangecoding
f97fb48e51 Merge branch 'master' of github.com:orangecoding/fredy 2025-10-03 13:04:56 +02:00
orangecoding
4b15894603 adding buttons to remove listings from a given job 2025-10-03 13:04:35 +02:00
orangecoding
31a14a0352 improve footer and upgrade dependencies 2025-10-03 12:45:48 +02:00
Christian Kellner
eecbe91dbd Update README.md 2025-10-02 22:05:49 +02:00
orangecoding
9dd3947cb7 reverting docker file changes, adding script to test things locally 2025-10-02 09:37:01 +02:00
Iaroslav Postovalov
c151f4f76e Use non-root user in Dockerfile (#214) 2025-10-01 20:04:08 +02:00
Christian Kellner
b6755497e4 Ui-Redesign (#203)
* new ui design

* improving ui design

* adding new screenshots

* upgrade dependencies
2025-09-29 20:36:56 +02:00
rugk
412e24b1e3 Add VOLUME to Dockerfile (#208)
Notes/exposes the intended volumes as per best practices.

See https://docs.docker.com/build/building/best-practices/#volume
2025-09-29 12:31:32 +02:00
rugk
0a5785fa1a Specify GitHub image in docker-compose directly (#204)
It's recommend to specify the full "URL" and this aligns with the Readme and default docker would search on Docker Hub, where this is not available: https://hub.docker.com/search?q=fredy%2Ffredy
2025-09-29 12:31:08 +02:00
Thomas Brockmöller
7ebd73c9cf Add new provider McMakler (#201) 2025-09-28 14:16:28 +02:00
43 changed files with 633 additions and 294 deletions

1
.gitignore vendored
View File

@@ -5,3 +5,4 @@ db/*.db*
npm-debug.log
.DS_Store
.idea
.vscode

View File

@@ -31,6 +31,8 @@ RUN mkdir -p /db /conf \
&& ln -s /conf /fredy/conf
EXPOSE 9998
VOLUME /db
VOLUME /conf
# Start application using PM2 runtime
CMD ["pm2-runtime", "index.js"]

View File

@@ -9,10 +9,18 @@
</a>
</p>
![Tests](https://github.com/orangecoding/fredy/actions/workflows/test.yml/badge.svg)
[![Docker](https://github.com/orangecoding/fredy/actions/workflows/docker.yml/badge.svg)](https://github.com/orangecoding/fredy/actions/workflows/docker.yml)
![Source](https://github.com/orangecoding/fredy/actions/workflows/check_source.yml/badge.svg)
![Docker Pulls](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fghcr-badge.elias.eu.org%2Fapi%2Forangecoding%2Ffredy%2Ffredy&query=%24.downloadCount&label=Docker%20Pulls)
<p align="center">
<a href="https://fredy.orange-coding.net/" target="_blank">Website</a>&nbsp;&nbsp;|&nbsp;&nbsp;
<a href="https://demo-fredy.orange-coding.net/" target="_blank">Demo</a>
</p>
<p align="center">
<img src="https://github.com/orangecoding/fredy/actions/workflows/test.yml/badge.svg" alt="Tests" />
<img src="https://github.com/orangecoding/fredy/actions/workflows/docker.yml/badge.svg" alt="Docker" />
<img src="https://github.com/orangecoding/fredy/actions/workflows/check_source.yml/badge.svg" alt="Source" />
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fghcr-badge.elias.eu.org%2Fapi%2Forangecoding%2Ffredy%2Ffredy&query=%24.downloadCount&label=Docker%20Pulls" alt="Docker Pulls" />
</p>
# Fredy 🏡 Your Self-Hosted Real Estate Finder for Germany
@@ -107,9 +115,9 @@ yarn run start:frontend # in another terminal
## 📸 Screenshots
| Job Configuration | Job Analytics | Job Overview |
|-------------------|--------------|--------------|
| ![Screenshot showing job configuration in Fredy](doc/screenshot1.png) | ![Screenshot showing job analytics in Fredy](doc/screenshot_2.png) | ![Screenshot showing job overview in Fredy](doc/screenshot_3.png) |
| Fredy Main Overview | Job Configuration | Found Listings |
|--------------------------------------------------|-----------------------------------------------------------------------|-----------------------------------------------------------------------------|
| ![Screenshot showing Fredy](doc/screenshot1.png) | ![Screenshot showing job configuration in Fredy](doc/screenshot3.png) | ![Screenshot showing found listings in Fredy](doc/screenshot2.png) |
------------------------------------------------------------------------

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 197 KiB

BIN
doc/screenshot2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 KiB

BIN
doc/screenshot3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 323 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -5,7 +5,7 @@ services:
build:
context: .
dockerfile: Dockerfile
image: fredy/fredy
image: ghcr.io/orangecoding/fredy
# map existing config and database
volumes:
- ./conf:/conf

18
docker-test.sh Normal file
View File

@@ -0,0 +1,18 @@
#!/bin/sh
set -e
# Stop and remove old container if it exists
if [ "$(docker ps -aq -f name=fredy)" ]; then
docker stop fredy || true
docker rm fredy || true
fi
# Build image from local Dockerfile
docker build -t fredy:local .
# Run container with volumes and port mapping
docker run -d --name fredy \
-v fredy_conf:/conf \
-v fredy_db:/db \
-p 9998:9998 \
fredy:local

View File

@@ -1,6 +1,8 @@
import restana from 'restana';
import * as listingStorage from '../../services/storage/listingsStorage.js';
import { isAdmin as isAdminFn } from '../security.js';
import logger from '../../services/logger.js';
const service = restana();
const listingsRouter = service.newRouter();
@@ -8,7 +10,7 @@ const listingsRouter = service.newRouter();
listingsRouter.get('/table', async (req, res) => {
const { page, pageSize = 50, filter, sortfield = null, sortdir = 'asc' } = req.query || {};
const result = listingStorage.queryListings({
res.body = listingStorage.queryListings({
page: page ? parseInt(page, 10) : 1,
pageSize: pageSize ? parseInt(pageSize, 10) : 50,
filter: filter || undefined,
@@ -17,7 +19,31 @@ listingsRouter.get('/table', async (req, res) => {
userId: req.session.currentUser,
isAdmin: isAdminFn(req),
});
res.body = result;
res.send();
});
listingsRouter.delete('/job', async (req, res) => {
const { jobId } = req.body;
try {
listingStorage.deleteListingsByJobId(jobId);
} catch (error) {
res.send(new Error(error));
logger.error(error);
}
res.send();
});
listingsRouter.delete('/', async (req, res) => {
const { ids } = req.body;
try {
if (Array.isArray(ids) && ids.length > 0) {
listingStorage.deleteListingsById(ids);
}
} catch (error) {
res.send(new Error(error));
logger.error(error);
}
res.send();
});
export { listingsRouter };

View File

@@ -8,7 +8,14 @@ const versionRouter = service.newRouter();
versionRouter.get('/', async (req, res) => {
const versionPayload = await getCurrentVersionFromGithub();
res.body = versionPayload == null ? { newVersion: false } : versionPayload;
const localFredyVersion = await getPackageVersion();
res.body =
versionPayload == null
? {
newVersion: false,
localFredyVersion,
}
: versionPayload;
res.send();
});

47
lib/provider/mcMakler.js Executable file
View File

@@ -0,0 +1,47 @@
import { isOneOf, buildHash } from '../utils.js';
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
let appliedBlackList = [];
function normalize(o) {
const originalId = o.id.split('/').pop();
const id = buildHash(originalId, o.price);
const size = o.size ?? 'N/A m²';
const title = o.title || 'No title available';
const address = o.address?.replace(' / ', ' ') || null;
const link = o.link != null ? `https://www.mcmakler.de${o.link}` : config.url;
return Object.assign(o, { id, size, title, link, address });
}
function applyBlacklist(o) {
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
return titleNotBlacklisted && descNotBlacklisted;
}
const config = {
url: null,
crawlContainer: 'article[data-testid="propertyCard"]',
sortByDateParam: 'sortBy=DATE&sortOn=DESC',
waitForSelector: 'ul[data-testid="listsContainer"]',
crawlFields: {
id: 'h2 a@href',
title: 'h2 a | removeNewline | trim',
price: 'footer > p:first-of-type | trim',
size: 'footer > p:nth-of-type(2) | trim',
address: 'div > h2 + p | removeNewline | trim',
image: 'img@src',
link: 'h2 a@href',
},
normalize: normalize,
filter: applyBlacklist,
activeTester: checkIfListingIsActive,
};
export const init = (sourceConfig, blacklist) => {
config.enabled = sourceConfig.enabled;
config.url = sourceConfig.url;
appliedBlackList = blacklist || [];
};
export const metaInformation = {
name: 'McMakler',
baseUrl: 'https://www.mcmakler.de/immobilien/',
id: 'mcMakler',
};
export { config };

View File

@@ -251,3 +251,26 @@ export const queryListings = ({
return { totalNumber, page: safePage, result: rows };
};
/**
* Delete all listings for a given job id.
*
* @param {string} jobId - The job identifier whose listings should be removed.
* @returns {any} The result from SqliteConnection.execute (may contain changes count).
*/
export const deleteListingsByJobId = (jobId) => {
if (!jobId) return;
return SqliteConnection.execute(`DELETE FROM listings WHERE job_id = @jobId`, { jobId });
};
/**
* Delete listings by a list of listing IDs.
*
* @param {string[]} ids - Array of listing IDs to delete.
* @returns {any} The result from SqliteConnection.execute.
*/
export const deleteListingsById = (ids) => {
if (!Array.isArray(ids) || ids.length === 0) return;
const placeholders = ids.map(() => '?').join(',');
return SqliteConnection.execute(`DELETE FROM listings WHERE id IN (${placeholders})`, ids);
};

View File

@@ -1,6 +1,6 @@
{
"name": "fredy",
"version": "12.3.2",
"version": "14.0.1",
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
"scripts": {
"prepare": "husky",
@@ -62,7 +62,7 @@
"@visactor/react-vchart": "^2.0.5",
"@visactor/vchart": "^2.0.5",
"@visactor/vchart-semi-theme": "^1.12.2",
"@vitejs/plugin-react": "5.0.3",
"@vitejs/plugin-react": "5.0.4",
"better-sqlite3": "^12.4.1",
"body-parser": "2.2.0",
"cheerio": "^1.1.2",
@@ -76,19 +76,19 @@
"node-mailjet": "6.0.9",
"p-throttle": "^8.0.0",
"package-up": "^5.0.0",
"puppeteer": "^24.22.3",
"puppeteer": "^24.23.0",
"puppeteer-extra": "^3.3.6",
"puppeteer-extra-plugin-stealth": "^2.11.2",
"query-string": "9.3.1",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-router": "7.9.2",
"react-router-dom": "7.9.2",
"react-router": "7.9.3",
"react-router-dom": "7.9.3",
"restana": "5.1.0",
"semver": "^7.7.2",
"serve-static": "2.2.0",
"slack": "11.0.2",
"vite": "7.1.7",
"vite": "7.1.9",
"x-var": "^3.0.1",
"zustand": "^5.0.8"
},
@@ -97,7 +97,7 @@
"@babel/eslint-parser": "7.28.4",
"@babel/preset-env": "7.28.3",
"@babel/preset-react": "7.27.1",
"chai": "6.0.1",
"chai": "6.2.0",
"eslint": "9.36.0",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-react": "7.37.5",
@@ -105,8 +105,8 @@
"history": "5.3.0",
"husky": "9.1.7",
"less": "4.4.1",
"lint-staged": "16.2.1",
"mocha": "11.7.2",
"lint-staged": "16.2.3",
"mocha": "11.7.4",
"nodemon": "^3.1.10",
"prettier": "3.6.2"
}

View File

@@ -0,0 +1,37 @@
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
import { get } from '../mocks/mockNotification.js';
import { mockFredy, providerConfig } from '../utils.js';
import { expect } from 'chai';
import * as provider from '../../lib/provider/mcMakler.js';
describe('#mcMakler testsuite()', () => {
after(() => {
similarityCache.stopCacheCleanup();
});
it('should test mcMakler provider', async () => {
const Fredy = await mockFredy();
provider.init(providerConfig.mcMakler, []);
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'mcMakler', similarityCache);
const listing = await fredy.execute();
expect(listing).to.be.a('array');
const notificationObj = get();
expect(notificationObj).to.be.a('object');
expect(notificationObj.serviceName).to.equal('mcMakler');
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).that.does.include('m²');
expect(notify.title).to.be.not.empty;
expect(notify.address).to.be.not.empty;
});
});
});

View File

@@ -28,6 +28,10 @@
"url": "https://www.kleinanzeigen.de/s-immobilien/duesseldorf/anzeige:angebote/wohnung/k0c195l2068r5",
"enabled": true
},
"mcMakler": {
"url": "https://www.mcmakler.de/immobilien/results?placeId=62649&search=Leipzig%252C+Sachsen&propertyTypes=APARTMENT&page=0",
"enabled": true
},
"neubauKompass": {
"url": "https://www.neubaukompass.de/neubau-immobilien/duesseldorf-region/eigentumswohnung/",
"enabled": true

View File

@@ -8,18 +8,19 @@ import UserMutator from './views/user/mutation/UserMutator';
import JobInsight from './views/jobs/insights/JobInsight.jsx';
import { useActions, useSelector } from './services/state/store';
import { Routes, Route, Navigate } from 'react-router-dom';
import Logout from './components/logout/Logout';
import Logo from './components/logo/Logo';
import Menu from './components/menu/Menu';
import Login from './views/login/Login';
import Users from './views/user/Users';
import Jobs from './views/jobs/Jobs';
import './App.less';
import TrackingModal from './components/tracking/TrackingModal.jsx';
import { Banner } from '@douyinfe/semi-ui';
import { Banner, Divider } from '@douyinfe/semi-ui';
import VersionBanner from './components/version/VersionBanner.jsx';
import Listings from './views/listings/Listings.jsx';
import Navigation from './components/navigation/Navigation.jsx';
import { Layout } from '@douyinfe/semi-ui';
import FredyFooter from './components/footer/FredyFooter.jsx';
import ProcessingTimes from './views/jobs/ProcessingTimes.jsx';
export default function FredyApp() {
const actions = useActions();
@@ -27,6 +28,7 @@ export default function FredyApp() {
const currentUser = useSelector((state) => state.user.currentUser);
const versionUpdate = useSelector((state) => state.versionUpdate.versionUpdate);
const settings = useSelector((state) => state.generalSettings.settings);
const processingTimes = useSelector((state) => state.jobs.processingTimes);
useEffect(() => {
async function init() {
@@ -50,6 +52,7 @@ export default function FredyApp() {
};
const isAdmin = () => currentUser != null && currentUser.isAdmin;
const { Footer, Sider, Content } = Layout;
return loading ? null : needsLogin() ? (
<Routes>
@@ -57,71 +60,80 @@ export default function FredyApp() {
<Route path="*" element={<Navigate to="/login" replace />} />
</Routes>
) : (
<div className="app">
<div className="app__container">
<Logout />
<Logo width={190} white />
<Menu isAdmin={isAdmin()} />
{versionUpdate?.newVersion && <VersionBanner />}
{settings.demoMode && (
<>
<Banner
fullMode={true}
type="info"
bordered
closeIcon={null}
description="You're currently viewing the demo version of Fredy. Jobs won't scrape websites, and any changes you make will be reverted at midnight."
/>
<br />
</>
)}
{settings.analyticsEnabled === null && !settings.demoMode && <TrackingModal />}
<Routes>
<Route path="/403" element={<InsufficientPermission />} />
<Route path="/jobs/new" element={<JobMutation />} />
<Route path="/jobs/edit/:jobId" element={<JobMutation />} />
<Route path="/jobs/insights/:jobId" element={<JobInsight />} />
<Route path="/jobs" element={<Jobs />} />
<Route path="/listings" element={<Listings />} />
<Layout className="app">
<Layout className="app">
<Sider>
<Navigation isAdmin={isAdmin()} />
</Sider>
<Content>
{versionUpdate?.newVersion && <VersionBanner />}
{settings.demoMode && (
<>
<Banner
fullMode={true}
type="info"
bordered
closeIcon={null}
description="You're currently viewing the demo version of Fredy. Jobs won't scrape websites, and any changes you make will be reverted at midnight."
/>
<br />
</>
)}
{settings.analyticsEnabled === null && !settings.demoMode && <TrackingModal />}
{processingTimes != null && <ProcessingTimes processingTimes={processingTimes} />}
<Divider />
<div className="app__content">
<Routes>
<Route path="/403" element={<InsufficientPermission />} />
<Route path="/jobs/new" element={<JobMutation />} />
<Route path="/jobs/edit/:jobId" element={<JobMutation />} />
<Route path="/jobs/insights/:jobId" element={<JobInsight />} />
<Route path="/jobs" element={<Jobs />} />
<Route path="/listings" element={<Listings />} />
{/* Permission-aware routes */}
<Route
path="/users/new"
element={
<PermissionAwareRoute currentUser={currentUser}>
<UserMutator />
</PermissionAwareRoute>
}
/>
<Route
path="/users/edit/:userId"
element={
<PermissionAwareRoute currentUser={currentUser}>
<UserMutator />
</PermissionAwareRoute>
}
/>
<Route
path="/users"
element={
<PermissionAwareRoute currentUser={currentUser}>
<Users />
</PermissionAwareRoute>
}
/>
<Route
path="/generalSettings"
element={
<PermissionAwareRoute currentUser={currentUser}>
<GeneralSettings />
</PermissionAwareRoute>
}
/>
{/* Permission-aware routes */}
<Route
path="/users/new"
element={
<PermissionAwareRoute currentUser={currentUser}>
<UserMutator />
</PermissionAwareRoute>
}
/>
<Route
path="/users/edit/:userId"
element={
<PermissionAwareRoute currentUser={currentUser}>
<UserMutator />
</PermissionAwareRoute>
}
/>
<Route
path="/users"
element={
<PermissionAwareRoute currentUser={currentUser}>
<Users />
</PermissionAwareRoute>
}
/>
<Route
path="/generalSettings"
element={
<PermissionAwareRoute currentUser={currentUser}>
<GeneralSettings />
</PermissionAwareRoute>
}
/>
<Route path="/" element={<Navigate to="/jobs" replace />} />
</Routes>
</div>
</div>
<Route path="/" element={<Navigate to="/jobs" replace />} />
</Routes>
</div>
</Content>
</Layout>
<Footer>
<FredyFooter />
</Footer>
</Layout>
);
}

View File

@@ -1,12 +1,9 @@
.app {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
&__container {
padding: 1rem 1rem;
color: var(--semi-color-text-0);
background-color: #232429;
&__content {
margin: 1rem;
}
}

View File

@@ -0,0 +1,19 @@
import React from 'react';
import './FredyFooter.less';
import { useSelector } from '../../services/state/store.js';
import { Typography } from '@douyinfe/semi-ui';
export default function FredyFooter() {
const { Text } = Typography;
const version = useSelector((state) => state.versionUpdate.versionUpdate);
return (
<div className="fredyFooter">
<div className="fredyFooter__version">
<Text type="tertiary">Fredy V{version?.localFredyVersion || 'N/A'}</Text>
</div>
<div className="fredyFooter__copyRight">
<Text link={{ href: 'https://github.com/orangecoding', target: '_blank' }}>Made with </Text>
</div>
</div>
);
}

View File

@@ -0,0 +1,18 @@
.fredyFooter {
background:rgb(53, 54, 60);
color: white;
display: flex;
justify-content: space-between;
align-items: center;
height: 1.7rem;
&__version {
padding-left: .5rem;
font-size: small;
}
&__copyRight {
padding-right: 1rem;
}
}

View File

@@ -2,19 +2,22 @@ import React from 'react';
import { Button } from '@douyinfe/semi-ui';
import { xhrPost } from '../../services/xhr';
import { IconUser } from '@douyinfe/semi-icons';
const Logout = function Logout() {
const Logout = function Logout({ text }) {
return (
<Button
icon={<IconUser />}
type="danger"
theme="solid"
onClick={async () => {
await xhrPost('/api/login/logout');
location.reload();
}}
>
Logout
</Button>
<div>
<Button
icon={<IconUser />}
type="danger"
theme="solid"
onClick={async () => {
await xhrPost('/api/login/logout');
location.reload();
}}
>
{text && 'Logout'}
</Button>
</div>
);
};

View File

@@ -1,65 +0,0 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { Tabs, TabPane } from '@douyinfe/semi-ui';
import { useLocation } from 'react-router-dom';
import { IconUser, IconTerminal, IconSetting, IconArchive } from '@douyinfe/semi-icons';
import './Menu.less';
function parsePathName(name) {
const split = name.split('/').filter((s) => s.length !== 0);
return '/' + split[0];
}
const TopMenu = function TopMenu({ isAdmin }) {
const navigate = useNavigate();
const location = useLocation();
return (
<Tabs className="menu" type="line" activeKey={parsePathName(location.pathname)} onTabClick={(key) => navigate(key)}>
<TabPane
itemKey="/jobs"
tab={
<span>
<IconTerminal />
Jobs
</span>
}
/>
<TabPane
itemKey="/listings"
tab={
<span>
<IconArchive />
Found listings
</span>
}
/>
{isAdmin && (
<TabPane
itemKey="/users"
tab={
<span>
<IconUser />
User
</span>
}
/>
)}
{isAdmin && (
<TabPane
itemKey="/generalSettings"
tab={
<span>
<IconSetting />
Settings
</span>
}
/>
)}
</Tabs>
);
};
export default TopMenu;

View File

@@ -1,3 +0,0 @@
.menu {
margin-top: 3rem;
}

View File

@@ -0,0 +1,9 @@
.navigate {
&__logout_Button {
align-items: center;
justify-content: center;
width: 100%;
display: flex;
}
}

View File

@@ -0,0 +1,50 @@
import React from 'react';
import { Nav } from '@douyinfe/semi-ui';
import { IconUser, IconStar, IconSetting, IconTerminal } from '@douyinfe/semi-icons';
import logoWhite from '../../assets/logo_white.png';
import Logout from '../logout/Logout.jsx';
import { useLocation, useNavigate } from 'react-router-dom';
import './Navigate.less';
import { useScreenWidth } from '../../hooks/screenWidth.js';
export default function Navigation({ isAdmin }) {
const navigate = useNavigate();
const location = useLocation();
const width = useScreenWidth();
const collapsed = width <= 850;
const items = [
{ itemKey: '/jobs', text: 'Jobs', icon: <IconTerminal /> },
{ itemKey: '/listings', text: 'Found Listings', icon: <IconStar /> },
];
if (isAdmin) {
items.push({ itemKey: '/users', text: 'User Management', icon: <IconUser /> });
items.push({ itemKey: '/generalSettings', text: 'Settings', icon: <IconSetting /> });
}
function parsePathName(name) {
const split = name.split('/').filter((s) => s.length !== 0);
return '/' + split[0];
}
return (
<Nav
style={{ height: '100%', width: collapsed ? '' : '13rem' }}
items={items}
isCollapsed={collapsed}
selectedKeys={[parsePathName(location.pathname)]}
onSelect={(key) => {
navigate(key.itemKey);
}}
header={<img src={logoWhite} width="180" alt="Fredy Logo" />}
footer={
<div className="navigate__logout_Button">
<Logout text={!collapsed} />
</div>
}
/>
);
}

View File

@@ -8,6 +8,7 @@ export const SegmentPart = ({ name, Icon = null, children, helpText }) => {
return (
<Card
className="segmentParts"
title={
<Meta title={name} description={helpText} avatar={Icon == null ? null : <Icon size="extra-extra-small" />} />
}

View File

@@ -1,4 +1,7 @@
.segmentParts {
border: 1px solid #323232 !important;
border-radius: 5px !important;
color: rgba(var(--semi-grey-8), 1);
background: rgb(53, 54, 60);
margin: 2rem;
}

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { Button, Empty, Table, Switch } from '@douyinfe/semi-ui';
import { IconDelete, IconEdit, IconHistogram } from '@douyinfe/semi-icons';
import { Button, Empty, Table, Switch, Popover } from '@douyinfe/semi-ui';
import { IconDelete, IconDescend2, IconEdit, IconHistogram } from '@douyinfe/semi-icons';
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
import './JobTable.less';
@@ -10,11 +10,20 @@ const empty = (
<Empty
image={<IllustrationNoResult />}
darkModeImage={<IllustrationNoResultDark />}
description={'No jobs available.'}
description="No jobs available. Why don't you create one? ;)"
/>
);
export default function JobTable({ jobs = {}, onJobRemoval, onJobStatusChanged, onJobEdit, onJobInsight } = {}) {
const getPopoverContent = (text) => <article className="jobPopoverContent">{text}</article>;
export default function JobTable({
jobs = {},
onJobRemoval,
onJobStatusChanged,
onJobEdit,
onJobInsight,
onListingRemoval,
} = {}) {
return (
<Table
pagination={false}
@@ -58,9 +67,18 @@ export default function JobTable({ jobs = {}, onJobRemoval, onJobStatusChanged,
render: (_, job) => {
return (
<div className="interactions">
<Button type="primary" icon={<IconHistogram />} onClick={() => onJobInsight(job.id)} />
<Button type="secondary" icon={<IconEdit />} onClick={() => onJobEdit(job.id)} />
<Button type="danger" icon={<IconDelete />} onClick={() => onJobRemoval(job.id)} />
<Popover content={getPopoverContent('Job Insights')}>
<Button type="primary" icon={<IconHistogram />} onClick={() => onJobInsight(job.id)} />
</Popover>
<Popover content={getPopoverContent('Edit a Job')}>
<Button type="secondary" icon={<IconEdit />} onClick={() => onJobEdit(job.id)} />
</Popover>
<Popover content={getPopoverContent('Delete all found Listings of this Job')}>
<Button type="danger" icon={<IconDescend2 />} onClick={() => onListingRemoval(job.id)} />
</Popover>
<Popover content={getPopoverContent('Delete Job')}>
<Button type="danger" icon={<IconDelete />} onClick={() => onJobRemoval(job.id)} />
</Popover>
</div>
);
},

View File

@@ -5,6 +5,11 @@
gap: 1rem;
}
.jobPopoverContent {
padding: 1rem;
color: white;
}
@media (min-width: 768px) {
.interactions {
flex-direction: initial;

View File

@@ -1,13 +1,15 @@
import React, { useState, useEffect, useMemo } from 'react';
import { Table, Popover, Input, Descriptions, Tag, Image } from '@douyinfe/semi-ui';
import { Table, Popover, Input, Descriptions, Tag, Image, Empty, Button, Card, Toast } from '@douyinfe/semi-ui';
import { useActions, useSelector } from '../../services/state/store.js';
import { IconClose, IconSearch, IconTick } from '@douyinfe/semi-icons';
import { IconClose, IconDelete, IconSearch, 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 } from '../../services/xhr.js';
const columns = [
{
@@ -65,7 +67,7 @@ const columns = [
},
{
title: 'Price',
width: 100,
width: 110,
dataIndex: 'price',
sorter: true,
render: (text) => text + ' €',
@@ -80,6 +82,7 @@ const columns = [
title: 'Title',
dataIndex: 'title',
sorter: true,
ellipsis: true,
render: (text, row) => {
return (
<a href={row.url} target="_blank" rel="noopener noreferrer">
@@ -90,19 +93,28 @@ const columns = [
},
];
const empty = (
<Empty
image={<IllustrationNoResult />}
darkModeImage={<IllustrationNoResultDark />}
description="No listings available."
/>
);
export default function ListingsTable() {
const tableData = useSelector((state) => state.listingsTable);
const actions = useActions();
const [page, setPage] = useState(1);
const pageSize = 15;
const pageSize = 10;
const [sortData, setSortData] = useState({});
const [filter, setFilter] = useState(null);
const [selectedKeys, setSelectedKeys] = useState([]);
const handlePageChange = (_page) => {
setPage(_page);
};
useEffect(() => {
const loadTable = () => {
let sortfield = null;
let sortdir = null;
@@ -111,10 +123,20 @@ export default function ListingsTable() {
sortdir = sortData.direction;
}
actions.listingsTable.getListingsTable({ page, pageSize, sortfield, sortdir, filter });
};
useEffect(() => {
loadTable();
}, [page, sortData, filter]);
const handleFilterChange = useMemo(() => debounce((value) => setFilter(value), 500), []);
const rowSelection = {
onChange: (selectedRowKeys) => {
setSelectedKeys(selectedRowKeys);
},
};
const expandRowRender = (record) => {
return (
<div className="listingsTable__expanded">
@@ -147,6 +169,18 @@ export default function ListingsTable() {
);
};
const onRemoveSelectedListings = async () => {
if (selectedKeys != null && selectedKeys.length > 0) {
try {
await xhrDelete('/api/listings/', { ids: selectedKeys });
Toast.success('Listing(s) successfully removed');
loadTable();
} catch (error) {
Toast.error(error);
}
}
};
return (
<div>
<Input
@@ -156,11 +190,20 @@ export default function ListingsTable() {
placeholder="Search"
onChange={handleFilterChange}
/>
{selectedKeys != null && selectedKeys.length > 0 && (
<Card className="listingsTable__toolbar">
<Button type="danger" icon={<IconDelete />} onClick={() => onRemoveSelectedListings()}>
Remove selected Listings
</Button>
</Card>
)}
<Table
rowKey="id"
empty={empty}
hideExpandedColumn={false}
sticky={{ top: 5 }}
columns={columns}
rowSelection={rowSelection}
expandedRowRender={expandRowRender}
dataSource={tableData?.result || []}
onChange={(changeSet) => {

View File

@@ -7,4 +7,8 @@
display: flex;
gap: 1rem;
}
&__toolbar {
margin-bottom: 1rem;
}
}

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Banner, Descriptions } from '@douyinfe/semi-ui';
import { Collapse, Descriptions } from '@douyinfe/semi-ui';
import { useSelector } from '../../services/state/store.js';
import { MarkdownRender } from '@douyinfe/semi-ui';
@@ -8,12 +8,9 @@ import './VersionBanner.less';
export default function VersionBanner() {
const versionUpdate = useSelector((state) => state.versionUpdate.versionUpdate);
return (
<Banner
className="versionBanner"
type="success"
icon={null}
description={
<div style={{ overflow: 'auto' }}>
<Collapse>
<Collapse.Panel header="A new version of Fredy is available" itemKey="1" className="versionBanner">
<div className="versionBanner__content">
<p>A new version of Fredy is available. Update now to take advantage of the latest features and bug fixes.</p>
<Descriptions row size="small">
<Descriptions.Item itemKey="Your Version">{versionUpdate.localFredyVersion}</Descriptions.Item>
@@ -29,9 +26,9 @@ export default function VersionBanner() {
<small>Release Notes</small>
</b>
</p>
<MarkdownRender raw={versionUpdate.body} style={{ height: '200px' }} />
<MarkdownRender raw={versionUpdate.body} />
</div>
}
/>
</Collapse.Panel>
</Collapse>
);
}

View File

@@ -1,3 +1,7 @@
.versionBanner {
margin-bottom: 1rem;
background: rgba(var(--semi-teal-1), 1);
&__content {
overflow: auto;
}
}

View File

@@ -0,0 +1,23 @@
import { useState, useEffect } from 'react';
export function useScreenWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
let timeoutId;
const handleResize = () => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => setWidth(window.innerWidth), 100);
};
window.addEventListener('resize', handleResize);
return () => {
clearTimeout(timeoutId);
window.removeEventListener('resize', handleResize);
};
}, []);
return width;
}

View File

@@ -4,7 +4,6 @@ import { useActions, useSelector } from '../../services/state/store';
import { Divider, TimePicker, Button, Checkbox, Input } from '@douyinfe/semi-ui';
import { InputNumber } from '@douyinfe/semi-ui';
import Headline from '../../components/headline/Headline';
import { xhrPost } from '../../services/xhr';
import { SegmentPart } from '../../components/segment/SegmentPart';
import { Banner, Toast } from '@douyinfe/semi-ui';
@@ -125,7 +124,6 @@ const GeneralSettings = function GeneralSettings() {
<div>
{!loading && (
<React.Fragment>
<Headline text="General Settings" />
<div>
<SegmentPart
name="Interval"

View File

@@ -4,21 +4,29 @@ import JobTable from '../../components/table/JobTable';
import { useSelector, useActions } from '../../services/state/store';
import { xhrDelete, xhrPut } from '../../services/xhr';
import { useNavigate } from 'react-router-dom';
import ProcessingTimes from './ProcessingTimes';
import { Button, Toast } from '@douyinfe/semi-ui';
import { IconPlusCircle } from '@douyinfe/semi-icons';
import './Jobs.less';
export default function Jobs() {
const jobs = useSelector((state) => state.jobs.jobs);
const processingTimes = useSelector((state) => state.jobs.processingTimes);
const navigate = useNavigate();
const actions = useActions();
const onJobRemoval = async (jobId) => {
try {
await xhrDelete('/api/jobs', { jobId });
Toast.success('Job successfully remove');
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);
@@ -38,7 +46,6 @@ export default function Jobs() {
return (
<div>
<div>
{processingTimes != null && <ProcessingTimes processingTimes={processingTimes} />}
<Button
type="primary"
icon={<IconPlusCircle />}
@@ -52,6 +59,7 @@ export default function Jobs() {
<JobTable
jobs={jobs || []}
onJobRemoval={onJobRemoval}
onListingRemoval={onListingRemoval}
onJobStatusChanged={onJobStatusChanged}
onJobInsight={(jobId) => navigate(`/jobs/insights/${jobId}`)}
onJobEdit={(jobId) => navigate(`/jobs/edit/${jobId}`)}

View File

@@ -1,7 +1,8 @@
.jobs {
&__newButton {
margin-top: 1rem !important;
float: right;
float: left;
margin-bottom: 1rem !important;
margin-left: 1rem;
}
}

View File

@@ -1,47 +1,56 @@
import React from 'react';
import { format } from '../../services/time/timeService';
import { Button, Descriptions, Toast } from '@douyinfe/semi-ui';
import { Button, Card, Col, Row, Toast } from '@douyinfe/semi-ui';
import { IconPlayCircle } from '@douyinfe/semi-icons';
import { xhrPost } from '../../services/xhr.js';
import './ProsessingTimes.less';
function InfoCard({ title, value }) {
return (
<Card style={{ maxWidth: '13rem', margin: '1rem', background: 'rgb(53, 54, 60)' }} title={title}>
{value}
</Card>
);
}
export default function ProcessingTimes({ processingTimes = {} }) {
if (Object.keys(processingTimes).length === 0) {
return null;
}
return (
<>
<Descriptions
row
size="small"
style={{
backgroundColor: '#35363c',
borderRadius: '4px',
padding: '10px',
}}
>
<Descriptions.Item itemKey="Processing Interval">{processingTimes.interval} min</Descriptions.Item>
{processingTimes.lastRun && (
<>
<Descriptions.Item itemKey="Last run">{format(processingTimes.lastRun)}</Descriptions.Item>
<Descriptions.Item itemKey="Next run">
{format(processingTimes.lastRun + processingTimes.interval * 60000)}
</Descriptions.Item>
<Descriptions.Item itemKey="Find Listings now">
<Button
size="small"
icon={<IconPlayCircle />}
aria-label="Start now"
onClick={async () => {
await xhrPost('/api/jobs/startAll', null);
Toast.success('Successfully triggered Fredy search.');
}}
>
Search now
</Button>
</Descriptions.Item>
</>
)}
</Descriptions>
</>
<Row>
<Col span={6}>
<InfoCard title="Processing Interval" value={`${processingTimes.interval} min`} />
</Col>
{processingTimes.lastRun && (
<>
<Col span={6}>
<InfoCard title="Last run" value={format(processingTimes.lastRun)} />
</Col>
<Col span={6}>
<InfoCard title="Next run" value={format(processingTimes.lastRun + processingTimes.interval * 60000)} />
</Col>
</>
)}
<Col span={6}>
<InfoCard
title="Find Listings Now"
value={
<Button
size="small"
icon={<IconPlayCircle />}
aria-label="Start now"
onClick={async () => {
await xhrPost('/api/jobs/startAll', null);
Toast.success('Successfully triggered Fredy search.');
}}
>
Search now
</Button>
}
/>
</Col>
</Row>
);
}

View File

@@ -0,0 +1,5 @@
.processingTimes {
display: flex;
gap: 1rem;
justify-content: space-between;
}

View File

@@ -52,7 +52,7 @@ const Users = function Users() {
icon={<IconPlus />}
onClick={() => navigate('/users/new')}
>
Create new User
New User
</Button>
<UserTable

View File

@@ -1,7 +1,8 @@
.users {
&__newButton {
margin-top: 1rem !important;
float: right;
float: left;
margin-bottom: 1rem !important;
margin-left: 1rem;
}
}

114
yarn.lock
View File

@@ -1428,10 +1428,10 @@
"@resvg/resvg-js-win32-ia32-msvc" "2.4.1"
"@resvg/resvg-js-win32-x64-msvc" "2.4.1"
"@rolldown/pluginutils@1.0.0-beta.35":
version "1.0.0-beta.35"
resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.35.tgz#1a477e7742b154b67519d40e4fc17485de338e7a"
integrity sha512-slYrCpoxJUqzFDDNlvrOYRazQUNRvWPjXA17dAOISY3rDMxX6k8K4cj2H+hEYMHF81HO3uNd5rHVigAWRM5dSg==
"@rolldown/pluginutils@1.0.0-beta.38":
version "1.0.0-beta.38"
resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.38.tgz#95253608c4629eb2a5f3d656009ac9ba031eb292"
integrity sha512-N/ICGKleNhA5nc9XXQG/kkKHJ7S55u0x0XUJbbkmdCnFuoRkM1Il12q9q0eX19+M7KKUEPw/daUPIRnxhcxAIw==
"@rollup/rollup-android-arm-eabi@4.49.0":
version "4.49.0"
@@ -1895,15 +1895,15 @@
"@turf/invariant" "^6.5.0"
eventemitter3 "^4.0.7"
"@vitejs/plugin-react@5.0.3":
version "5.0.3"
resolved "https://registry.yarnpkg.com/@vitejs/plugin-react/-/plugin-react-5.0.3.tgz#182ea45406d89e55b4e35c92a4a8c2c8388726c8"
integrity sha512-PFVHhosKkofGH0Yzrw1BipSedTH68BFF8ZWy1kfUpCtJcouXXY0+racG8sExw7hw0HoX36813ga5o3LTWZ4FUg==
"@vitejs/plugin-react@5.0.4":
version "5.0.4"
resolved "https://registry.yarnpkg.com/@vitejs/plugin-react/-/plugin-react-5.0.4.tgz#d642058e89c5b712655c8cbd13482f5813519602"
integrity sha512-La0KD0vGkVkSk6K+piWDKRUyg8Rl5iAIKRMH0vMJI0Eg47bq1eOxmoObAaQG37WMW9MSyk7Cs8EIWwJC1PtzKA==
dependencies:
"@babel/core" "^7.28.4"
"@babel/plugin-transform-react-jsx-self" "^7.27.1"
"@babel/plugin-transform-react-jsx-source" "^7.27.1"
"@rolldown/pluginutils" "1.0.0-beta.35"
"@rolldown/pluginutils" "1.0.0-beta.38"
"@types/babel__core" "^7.20.5"
react-refresh "^0.17.0"
@@ -2362,10 +2362,10 @@ ccount@^2.0.0:
resolved "https://registry.yarnpkg.com/ccount/-/ccount-2.0.1.tgz#17a3bf82302e0870d6da43a01311a8bc02a3ecf5"
integrity sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==
chai@6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/chai/-/chai-6.0.1.tgz#88c2b4682fb56050647e222d2cf9d6772f2607b3"
integrity sha512-/JOoU2//6p5vCXh00FpNgtlw0LjvhGttaWc+y7wpW9yjBm3ys0dI8tSKZxIOgNruz5J0RleccatSIC3uxEZP0g==
chai@6.2.0:
version "6.2.0"
resolved "https://registry.yarnpkg.com/chai/-/chai-6.2.0.tgz#181bca6a219cddb99c3eeefb82483800ffa550ce"
integrity sha512-aUTnJc/JipRzJrNADXVvpVqi6CO0dn3nx4EVPxijri+fj3LUUDyZQOgVeW54Ob3Y1Xh9Iz8f+CgaCl8v0mn9bA==
chalk@^4.0.0, chalk@^4.1.0:
version "4.1.2"
@@ -2863,10 +2863,10 @@ devlop@^1.0.0, devlop@^1.1.0:
dependencies:
dequal "^2.0.0"
devtools-protocol@0.0.1495869:
version "0.0.1495869"
resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.1495869.tgz#f68daef77a48d5dcbcdd55dbfa3265a51989c91b"
integrity sha512-i+bkd9UYFis40RcnkW7XrOprCujXRAHg62IVh/Ah3G8MmNXpCGt1m0dTFhSdx/AVs8XEMbdOGRwdkR1Bcta8AA==
devtools-protocol@0.0.1508733:
version "0.0.1508733"
resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.1508733.tgz#047deb3531470efda2c7bf43c10b3ae9e4b3d51b"
integrity sha512-QJ1R5gtck6nDcdM+nlsaJXcelPEI7ZxSMw1ujHpO1c4+9l+Nue5qlebi9xO1Z2MGr92bFOQTW7/rrheh5hHxDg==
diff@^7.0.0:
version "7.0.0"
@@ -4274,6 +4274,11 @@ is-number@^7.0.0:
resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b"
integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==
is-path-inside@^3.0.3:
version "3.0.3"
resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283"
integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==
is-plain-obj@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287"
@@ -4559,10 +4564,10 @@ lines-and-columns@^1.1.6:
resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632"
integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==
lint-staged@16.2.1:
version "16.2.1"
resolved "https://registry.yarnpkg.com/lint-staged/-/lint-staged-16.2.1.tgz#bb82da8ce10059296b220f321980f0ee1ce40c28"
integrity sha512-KMeYmH9wKvHsXdUp+z6w7HN3fHKHXwT1pSTQTYxB9kI6ekK1rlL3kLZEoXZCppRPXFK9PFW/wfQctV7XUqMrPQ==
lint-staged@16.2.3:
version "16.2.3"
resolved "https://registry.yarnpkg.com/lint-staged/-/lint-staged-16.2.3.tgz#790866221d75602510507b5be40b2c7963715960"
integrity sha512-1OnJEESB9zZqsp61XHH2fvpS1es3hRCxMplF/AJUDa8Ho8VrscYDIuxGrj3m8KPXbcWZ8fT9XTMUhEQmOVKpKw==
dependencies:
commander "^14.0.1"
listr2 "^9.0.4"
@@ -5370,10 +5375,10 @@ mkdirp-classic@^0.5.2, mkdirp-classic@^0.5.3:
resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113"
integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==
mocha@11.7.2:
version "11.7.2"
resolved "https://registry.yarnpkg.com/mocha/-/mocha-11.7.2.tgz#3c0079fe5cc2f8ea86d99124debcc42bb1ab22b5"
integrity sha512-lkqVJPmqqG/w5jmmFtiRvtA2jkDyNVUcefFJKb2uyX4dekk8Okgqop3cgbFiaIvj8uCRJVTP5x9dfxGyXm2jvQ==
mocha@11.7.4:
version "11.7.4"
resolved "https://registry.yarnpkg.com/mocha/-/mocha-11.7.4.tgz#f161b17aeccb0762484b33bdb3f7ab9410ba5c82"
integrity sha512-1jYAaY8x0kAZ0XszLWu14pzsf4KV740Gld4HXkhNTXwcHx4AUEDkPzgEHg9CM5dVcW+zv036tjpsEbLraPJj4w==
dependencies:
browser-stdout "^1.3.1"
chokidar "^4.0.1"
@@ -5383,6 +5388,7 @@ mocha@11.7.2:
find-up "^5.0.0"
glob "^10.4.5"
he "^1.2.0"
is-path-inside "^3.0.3"
js-yaml "^4.1.0"
log-symbols "^4.1.0"
minimatch "^9.0.5"
@@ -5962,17 +5968,17 @@ 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.22.3:
version "24.22.3"
resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-24.22.3.tgz#63285a37da6e2c44069c0b31f2171f8ab81bbe23"
integrity sha512-M/Jhg4PWRANSbL/C9im//Yb55wsWBS5wdp+h59iwM+EPicVQQCNs56iC5aEAO7avfDPRfxs4MM16wHjOYHNJEw==
puppeteer-core@24.23.0:
version "24.23.0"
resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-24.23.0.tgz#1f84abafa480358652ae8df340af984438173a14"
integrity sha512-yl25C59gb14sOdIiSnJ08XiPP+O2RjuyZmEG+RjYmCXO7au0jcLf7fRiyii96dXGUBW7Zwei/mVKfxMx/POeFw==
dependencies:
"@puppeteer/browsers" "2.10.10"
chromium-bidi "9.1.0"
debug "^4.4.3"
devtools-protocol "0.0.1495869"
devtools-protocol "0.0.1508733"
typed-query-selector "^2.12.0"
webdriver-bidi-protocol "0.2.11"
webdriver-bidi-protocol "0.3.6"
ws "^8.18.3"
puppeteer-extra-plugin-stealth@^2.11.2:
@@ -6022,16 +6028,16 @@ puppeteer-extra@^3.3.6:
debug "^4.1.1"
deepmerge "^4.2.2"
puppeteer@^24.22.3:
version "24.22.3"
resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-24.22.3.tgz#07dcfabdb4e924b014cb7b96bcc92f43086e637e"
integrity sha512-mnhXzIqSYSJ1SMv1RYH07YMzWP81xCmmQj91Q8iQMZqnf97eVzeHgsGL6kpywiGCi+nQafta/+NkwM4URMy/XQ==
puppeteer@^24.23.0:
version "24.23.0"
resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-24.23.0.tgz#fa3c1bffc1b40c3d7a59b9463d444ff4be69f5c7"
integrity sha512-BVR1Lg8sJGKXY79JARdIssFWK2F6e1j+RyuJP66w4CUmpaXjENicmA3nNpUXA8lcTdDjAndtP+oNdni3T/qQqA==
dependencies:
"@puppeteer/browsers" "2.10.10"
chromium-bidi "9.1.0"
cosmiconfig "^9.0.0"
devtools-protocol "0.0.1495869"
puppeteer-core "24.22.3"
devtools-protocol "0.0.1508733"
puppeteer-core "24.23.0"
typed-query-selector "^2.12.0"
qs@^6.14.0:
@@ -6121,17 +6127,17 @@ react-resizable@^3.0.5:
prop-types "15.x"
react-draggable "^4.0.3"
react-router-dom@7.9.2:
version "7.9.2"
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.9.2.tgz#2bb35d226ca23329f4e39c8f86d1db26ee4fdf26"
integrity sha512-pagqpVJnjZOfb+vIM23eTp7Sp/AAJjOgaowhP1f1TWOdk5/W8Uk8d/M/0wfleqx7SgjitjNPPsKeCZE1hTSp3w==
react-router-dom@7.9.3:
version "7.9.3"
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.9.3.tgz#67ab1655f67b9b6108fe20ed3d4881b53dccf87a"
integrity sha512-1QSbA0TGGFKTAc/aWjpfW/zoEukYfU4dc1dLkT/vvf54JoGMkW+fNA+3oyo2gWVW1GM7BxjJVHz5GnPJv40rvg==
dependencies:
react-router "7.9.2"
react-router "7.9.3"
react-router@7.9.2:
version "7.9.2"
resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.9.2.tgz#f424a14f87e4d7b5b268ce3647876e9504e4fca6"
integrity sha512-i2TPp4dgaqrOqiRGLZmqh2WXmbdFknUyiCRmSKs0hf6fWXkTKg5h56b+9F22NbGRAMxjYfqQnpi63egzD2SuZA==
react-router@7.9.3:
version "7.9.3"
resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.9.3.tgz#f2d5ff6181851de3df3acb4e7364fce0dee5fba2"
integrity sha512-4o2iWCFIwhI/eYAIL43+cjORXYn/aRQPgtFRRZb3VzoyQ5Uej0Bmqj7437L97N9NJW4wnicSwLOLS+yCXfAPgg==
dependencies:
cookie "^1.0.1"
set-cookie-parser "^2.6.0"
@@ -7408,10 +7414,10 @@ vfile@^6.0.0:
"@types/unist" "^3.0.0"
vfile-message "^4.0.0"
vite@7.1.7:
version "7.1.7"
resolved "https://registry.yarnpkg.com/vite/-/vite-7.1.7.tgz#ed3f9f06e21d6574fe1ad425f6b0912d027ffc13"
integrity sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA==
vite@7.1.9:
version "7.1.9"
resolved "https://registry.yarnpkg.com/vite/-/vite-7.1.9.tgz#ba844410e5d0c0f2a4eaf17a52af60ebea322cbf"
integrity sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg==
dependencies:
esbuild "^0.25.0"
fdir "^6.5.0"
@@ -7427,10 +7433,10 @@ web-streams-polyfill@^3.0.3:
resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz#2073b91a2fdb1fbfbd401e7de0ac9f8214cecb4b"
integrity sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==
webdriver-bidi-protocol@0.2.11:
version "0.2.11"
resolved "https://registry.yarnpkg.com/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.2.11.tgz#dba18d9b0a33aed33fab272dbd6e42411ac753cc"
integrity sha512-Y9E1/oi4XMxcR8AT0ZC4OvYntl34SPgwjmELH+owjBr0korAX4jKgZULBWILGCVGdVCQ0dodTToIETozhG8zvA==
webdriver-bidi-protocol@0.3.6:
version "0.3.6"
resolved "https://registry.yarnpkg.com/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.3.6.tgz#55ad4ff9697532e3e04fb0446bb6dd4c158b3ad5"
integrity sha512-mlGndEOA9yK9YAbvtxaPTqdi/kaCWYYfwrZvGzcmkr/3lWM+tQj53BxtpVd6qbC6+E5OnHXgCcAhre6AkXzxjA==
whatwg-encoding@^3.1.1:
version "3.1.1"