Compare commits

...

12 Commits

Author SHA1 Message Date
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
orangecoding
95cd4028d7 next release version 2025-09-28 08:13:03 +02:00
orangecoding
eb01c2107c fixing default header 2025-09-28 08:12:51 +02:00
orangecoding
42cd4fa0ae next release version 2025-09-27 18:15:58 +02:00
orangecoding
6d96fd2bf8 Merge branch 'master' of github.com:orangecoding/fredy 2025-09-27 18:15:42 +02:00
orangecoding
ff1d2317a1 improve default puppeteer header 2025-09-27 18:15:28 +02:00
orangecoding
a47fa41278 fixing smaller problems in apprise and mattermost 2025-09-27 18:07:48 +02:00
orangecoding
9654e56846 improving some labels 2025-09-27 18:01:42 +02:00
Christian Kellner
43094640a8 Update README.md 2025-09-27 14:27:25 +02:00
41 changed files with 443 additions and 257 deletions

1
.gitignore vendored
View File

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

View File

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

View File

@@ -21,7 +21,7 @@ Finding an apartment or house in Germany can be stressful and
time-consuming.\ time-consuming.\
**Fredy** makes it easier: it automatically scrapes **ImmoScout24, **Fredy** makes it easier: it automatically scrapes **ImmoScout24,
Immowelt, Immonet, eBay Kleinanzeigen, and WG-Gesucht** and notifies you Immowelt, Immonet, eBay Kleinanzeigen, and WG-Gesucht** and notifies you
instantly via **Slack, Telegram, Email, ntfy, and more** when new instantly via **Slack, Telegram, Email, ntfy, discord and more** when new
listings appear. listings appear.
With a modern architecture, Fredy provides a **clean Web UI**, removes With a modern architecture, Fredy provides a **clean Web UI**, removes
@@ -35,7 +35,7 @@ same listing twice.
- 🏠 Scrapes **ImmoScout24, Immowelt, Immonet, eBay Kleinanzeigen, - 🏠 Scrapes **ImmoScout24, Immowelt, Immonet, eBay Kleinanzeigen,
WG-Gesucht** WG-Gesucht**
- ⚡ Instant notifications: Slack, Telegram, Email (SendGrid, - ⚡ Instant notifications: Slack, Telegram, Email (SendGrid,
Mailjet), ntfy Mailjet), ntfy, discord
- 🔎 Uses the **ImmoScout Mobile API** (reverse engineered) - 🔎 Uses the **ImmoScout Mobile API** (reverse engineered)
- 🌍 Runs anywhere: Docker, Node.js, self-hosted - 🌍 Runs anywhere: Docker, Node.js, self-hosted
- 🖥️ Intuitive **Web UI** to manage searches - 🖥️ Intuitive **Web UI** to manage searches
@@ -107,9 +107,9 @@ yarn run start:frontend # in another terminal
## 📸 Screenshots ## 📸 Screenshots
| Job Configuration | Job Analytics | Job Overview | | Fredy Main Overview | Job Configuration | Found Listings |
|-------------------|--------------|--------------| |--------------------------------------------------|-----------------------------------------------------------------------|-----------------------------------------------------------------------------|
| ![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) | | ![Screenshot showing Fredy](doc/screenshot1.png) | ![Screenshot showing job configuration in Fredy](doc/screenshot3.png) | ![Screenshot showing found listings in Fredy](doc/screenshot2.png) |
------------------------------------------------------------------------ ------------------------------------------------------------------------
@@ -129,7 +129,7 @@ picks up the newest listings first.
### Adapter 📡 ### Adapter 📡
An **adapter** is the channel through which Fredy notifies you (Slack, An **adapter** is the channel through which Fredy notifies you (Slack,
Telegram, Email, ntfy, ...).\ Telegram, Email, ntfy, discord ...).\
Each adapter has its own configuration (e.g. API keys, webhook URLs).\ Each adapter has its own configuration (e.g. API keys, webhook URLs).\
You can use multiple adapters at once --- Fredy will send new listings You can use multiple adapters at once --- Fredy will send new listings
through all of them. through all of them.

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: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
image: fredy/fredy image: ghcr.io/orangecoding/fredy
# map existing config and database # map existing config and database
volumes: volumes:
- ./conf:/conf - ./conf:/conf

View File

@@ -8,7 +8,14 @@ const versionRouter = service.newRouter();
versionRouter.get('/', async (req, res) => { versionRouter.get('/', async (req, res) => {
const versionPayload = await getCurrentVersionFromGithub(); 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(); res.send();
}); });

View File

@@ -8,7 +8,7 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
const jobName = job == null ? jobKey : job.name; const jobName = job == null ? jobKey : job.name;
const promises = newListings.map((newListing) => { const promises = newListings.map((newListing) => {
const title = `${jobName} at ${serviceName}: ${newListing.title}`; const title = `${jobName} at ${serviceName}: ${newListing.title}`;
const message = `Address: ${newListing.address}\nSize: ${newListing.size}\nPrice: ${newListing.price}\nink: ${newListing.link}`; const message = `Address: ${newListing.address}\nSize: ${newListing.size}\nPrice: ${newListing.price}\nLink: ${newListing.link}`;
return fetch(server, { return fetch(server, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },

View File

@@ -13,10 +13,10 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
return fetch(webhook, { return fetch(webhook, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: { body: JSON.stringify({
channel: channel, channel: channel,
text: message, text: message,
}, }),
}); });
}; };
export const config = { export const config = {

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

@@ -1,6 +1,6 @@
{ {
"name": "fredy", "name": "fredy",
"version": "12.3.0", "version": "14.0.0",
"description": "[F]ind [R]eal [E]states [d]amn eas[y].", "description": "[F]ind [R]eal [E]states [d]amn eas[y].",
"scripts": { "scripts": {
"prepare": "husky", "prepare": "husky",
@@ -62,7 +62,7 @@
"@visactor/react-vchart": "^2.0.5", "@visactor/react-vchart": "^2.0.5",
"@visactor/vchart": "^2.0.5", "@visactor/vchart": "^2.0.5",
"@visactor/vchart-semi-theme": "^1.12.2", "@visactor/vchart-semi-theme": "^1.12.2",
"@vitejs/plugin-react": "5.0.3", "@vitejs/plugin-react": "5.0.4",
"better-sqlite3": "^12.4.1", "better-sqlite3": "^12.4.1",
"body-parser": "2.2.0", "body-parser": "2.2.0",
"cheerio": "^1.1.2", "cheerio": "^1.1.2",
@@ -82,8 +82,8 @@
"query-string": "9.3.1", "query-string": "9.3.1",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-router": "7.9.2", "react-router": "7.9.3",
"react-router-dom": "7.9.2", "react-router-dom": "7.9.3",
"restana": "5.1.0", "restana": "5.1.0",
"semver": "^7.7.2", "semver": "^7.7.2",
"serve-static": "2.2.0", "serve-static": "2.2.0",
@@ -97,7 +97,7 @@
"@babel/eslint-parser": "7.28.4", "@babel/eslint-parser": "7.28.4",
"@babel/preset-env": "7.28.3", "@babel/preset-env": "7.28.3",
"@babel/preset-react": "7.27.1", "@babel/preset-react": "7.27.1",
"chai": "6.0.1", "chai": "6.2.0",
"eslint": "9.36.0", "eslint": "9.36.0",
"eslint-config-prettier": "10.1.8", "eslint-config-prettier": "10.1.8",
"eslint-plugin-react": "7.37.5", "eslint-plugin-react": "7.37.5",
@@ -105,7 +105,7 @@
"history": "5.3.0", "history": "5.3.0",
"husky": "9.1.7", "husky": "9.1.7",
"less": "4.4.1", "less": "4.4.1",
"lint-staged": "16.2.1", "lint-staged": "16.2.3",
"mocha": "11.7.2", "mocha": "11.7.2",
"nodemon": "^3.1.10", "nodemon": "^3.1.10",
"prettier": "3.6.2" "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", "url": "https://www.kleinanzeigen.de/s-immobilien/duesseldorf/anzeige:angebote/wohnung/k0c195l2068r5",
"enabled": true "enabled": true
}, },
"mcMakler": {
"url": "https://www.mcmakler.de/immobilien/results?placeId=62649&search=Leipzig%252C+Sachsen&propertyTypes=APARTMENT&page=0",
"enabled": true
},
"neubauKompass": { "neubauKompass": {
"url": "https://www.neubaukompass.de/neubau-immobilien/duesseldorf-region/eigentumswohnung/", "url": "https://www.neubaukompass.de/neubau-immobilien/duesseldorf-region/eigentumswohnung/",
"enabled": true "enabled": true

View File

@@ -8,18 +8,19 @@ import UserMutator from './views/user/mutation/UserMutator';
import JobInsight from './views/jobs/insights/JobInsight.jsx'; import JobInsight from './views/jobs/insights/JobInsight.jsx';
import { useActions, useSelector } from './services/state/store'; import { useActions, useSelector } from './services/state/store';
import { Routes, Route, Navigate } from 'react-router-dom'; 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 Login from './views/login/Login';
import Users from './views/user/Users'; import Users from './views/user/Users';
import Jobs from './views/jobs/Jobs'; import Jobs from './views/jobs/Jobs';
import './App.less'; import './App.less';
import TrackingModal from './components/tracking/TrackingModal.jsx'; 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 VersionBanner from './components/version/VersionBanner.jsx';
import Listings from './views/listings/Listings.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() { export default function FredyApp() {
const actions = useActions(); const actions = useActions();
@@ -27,6 +28,7 @@ export default function FredyApp() {
const currentUser = useSelector((state) => state.user.currentUser); const currentUser = useSelector((state) => state.user.currentUser);
const versionUpdate = useSelector((state) => state.versionUpdate.versionUpdate); const versionUpdate = useSelector((state) => state.versionUpdate.versionUpdate);
const settings = useSelector((state) => state.generalSettings.settings); const settings = useSelector((state) => state.generalSettings.settings);
const processingTimes = useSelector((state) => state.jobs.processingTimes);
useEffect(() => { useEffect(() => {
async function init() { async function init() {
@@ -50,6 +52,7 @@ export default function FredyApp() {
}; };
const isAdmin = () => currentUser != null && currentUser.isAdmin; const isAdmin = () => currentUser != null && currentUser.isAdmin;
const { Footer, Sider, Content } = Layout;
return loading ? null : needsLogin() ? ( return loading ? null : needsLogin() ? (
<Routes> <Routes>
@@ -57,71 +60,80 @@ export default function FredyApp() {
<Route path="*" element={<Navigate to="/login" replace />} /> <Route path="*" element={<Navigate to="/login" replace />} />
</Routes> </Routes>
) : ( ) : (
<div className="app"> <Layout className="app">
<div className="app__container"> <Layout className="app">
<Logout /> <Sider>
<Logo width={190} white /> <Navigation isAdmin={isAdmin()} />
<Menu isAdmin={isAdmin()} /> </Sider>
{versionUpdate?.newVersion && <VersionBanner />} <Content>
{settings.demoMode && ( {versionUpdate?.newVersion && <VersionBanner />}
<> {settings.demoMode && (
<Banner <>
fullMode={true} <Banner
type="info" fullMode={true}
bordered type="info"
closeIcon={null} bordered
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." 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 /> />
</> <br />
)} </>
{settings.analyticsEnabled === null && !settings.demoMode && <TrackingModal />} )}
<Routes> {settings.analyticsEnabled === null && !settings.demoMode && <TrackingModal />}
<Route path="/403" element={<InsufficientPermission />} /> {processingTimes != null && <ProcessingTimes processingTimes={processingTimes} />}
<Route path="/jobs/new" element={<JobMutation />} /> <Divider />
<Route path="/jobs/edit/:jobId" element={<JobMutation />} /> <div className="app__content">
<Route path="/jobs/insights/:jobId" element={<JobInsight />} /> <Routes>
<Route path="/jobs" element={<Jobs />} /> <Route path="/403" element={<InsufficientPermission />} />
<Route path="/listings" element={<Listings />} /> <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 */} {/* Permission-aware routes */}
<Route <Route
path="/users/new" path="/users/new"
element={ element={
<PermissionAwareRoute currentUser={currentUser}> <PermissionAwareRoute currentUser={currentUser}>
<UserMutator /> <UserMutator />
</PermissionAwareRoute> </PermissionAwareRoute>
} }
/> />
<Route <Route
path="/users/edit/:userId" path="/users/edit/:userId"
element={ element={
<PermissionAwareRoute currentUser={currentUser}> <PermissionAwareRoute currentUser={currentUser}>
<UserMutator /> <UserMutator />
</PermissionAwareRoute> </PermissionAwareRoute>
} }
/> />
<Route <Route
path="/users" path="/users"
element={ element={
<PermissionAwareRoute currentUser={currentUser}> <PermissionAwareRoute currentUser={currentUser}>
<Users /> <Users />
</PermissionAwareRoute> </PermissionAwareRoute>
} }
/> />
<Route <Route
path="/generalSettings" path="/generalSettings"
element={ element={
<PermissionAwareRoute currentUser={currentUser}> <PermissionAwareRoute currentUser={currentUser}>
<GeneralSettings /> <GeneralSettings />
</PermissionAwareRoute> </PermissionAwareRoute>
} }
/> />
<Route path="/" element={<Navigate to="/jobs" replace />} /> <Route path="/" element={<Navigate to="/jobs" replace />} />
</Routes> </Routes>
</div> </div>
</div> </Content>
</Layout>
<Footer>
<FredyFooter />
</Footer>
</Layout>
); );
} }

View File

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

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,17 @@
.fredyFooter {
background:rgb(53, 54, 60);
color: white;
display: flex;
justify-content: space-between;
align-items: center;
&__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 { Button } from '@douyinfe/semi-ui';
import { xhrPost } from '../../services/xhr'; import { xhrPost } from '../../services/xhr';
import { IconUser } from '@douyinfe/semi-icons'; import { IconUser } from '@douyinfe/semi-icons';
const Logout = function Logout() {
const Logout = function Logout({ text }) {
return ( return (
<Button <div>
icon={<IconUser />} <Button
type="danger" icon={<IconUser />}
theme="solid" type="danger"
onClick={async () => { theme="solid"
await xhrPost('/api/login/logout'); onClick={async () => {
location.reload(); await xhrPost('/api/login/logout');
}} location.reload();
> }}
Logout >
</Button> {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 ( return (
<Card <Card
className="segmentParts"
title={ title={
<Meta title={name} description={helpText} avatar={Icon == null ? null : <Icon size="extra-extra-small" />} /> <Meta title={name} description={helpText} avatar={Icon == null ? null : <Icon size="extra-extra-small" />} />
} }

View File

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

View File

@@ -10,7 +10,7 @@ const empty = (
<Empty <Empty
image={<IllustrationNoResult />} image={<IllustrationNoResult />}
darkModeImage={<IllustrationNoResultDark />} darkModeImage={<IllustrationNoResultDark />}
description={'No jobs available.'} description="No jobs available. Why don't you create one? ;)"
/> />
); );
@@ -32,7 +32,7 @@ export default function JobTable({ jobs = {}, onJobRemoval, onJobStatusChanged,
dataIndex: 'name', dataIndex: 'name',
}, },
{ {
title: 'Findings', title: 'Listings',
dataIndex: 'numberOfFoundListings', dataIndex: 'numberOfFoundListings',
render: (value) => { render: (value) => {
return value || 0; return value || 0;

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useMemo } from 'react'; 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 } from '@douyinfe/semi-ui';
import { useActions, useSelector } from '../../services/state/store.js'; import { useActions, useSelector } from '../../services/state/store.js';
import { IconClose, IconSearch, IconTick } from '@douyinfe/semi-icons'; import { IconClose, IconSearch, IconTick } from '@douyinfe/semi-icons';
import * as timeService from '../../services/time/timeService.js'; import * as timeService from '../../services/time/timeService.js';
@@ -8,6 +8,7 @@ import no_image from '../../assets/no_image.jpg';
import './ListingsTable.less'; import './ListingsTable.less';
import { format } from '../../services/time/timeService.js'; import { format } from '../../services/time/timeService.js';
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
const columns = [ const columns = [
{ {
@@ -65,7 +66,7 @@ const columns = [
}, },
{ {
title: 'Price', title: 'Price',
width: 100, width: 110,
dataIndex: 'price', dataIndex: 'price',
sorter: true, sorter: true,
render: (text) => text + ' €', render: (text) => text + ' €',
@@ -90,11 +91,19 @@ const columns = [
}, },
]; ];
const empty = (
<Empty
image={<IllustrationNoResult />}
darkModeImage={<IllustrationNoResultDark />}
description="No listings available."
/>
);
export default function ListingsTable() { export default function ListingsTable() {
const tableData = useSelector((state) => state.listingsTable); const tableData = useSelector((state) => state.listingsTable);
const actions = useActions(); const actions = useActions();
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const pageSize = 15; const pageSize = 10;
const [sortData, setSortData] = useState({}); const [sortData, setSortData] = useState({});
const [filter, setFilter] = useState(null); const [filter, setFilter] = useState(null);
@@ -158,6 +167,7 @@ export default function ListingsTable() {
/> />
<Table <Table
rowKey="id" rowKey="id"
empty={empty}
hideExpandedColumn={false} hideExpandedColumn={false}
sticky={{ top: 5 }} sticky={{ top: 5 }}
columns={columns} columns={columns}

View File

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

View File

@@ -1,3 +1,7 @@
.versionBanner { .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 { Divider, TimePicker, Button, Checkbox, Input } from '@douyinfe/semi-ui';
import { InputNumber } from '@douyinfe/semi-ui'; import { InputNumber } from '@douyinfe/semi-ui';
import Headline from '../../components/headline/Headline';
import { xhrPost } from '../../services/xhr'; import { xhrPost } from '../../services/xhr';
import { SegmentPart } from '../../components/segment/SegmentPart'; import { SegmentPart } from '../../components/segment/SegmentPart';
import { Banner, Toast } from '@douyinfe/semi-ui'; import { Banner, Toast } from '@douyinfe/semi-ui';
@@ -125,7 +124,6 @@ const GeneralSettings = function GeneralSettings() {
<div> <div>
{!loading && ( {!loading && (
<React.Fragment> <React.Fragment>
<Headline text="General Settings" />
<div> <div>
<SegmentPart <SegmentPart
name="Interval" name="Interval"
@@ -186,7 +184,7 @@ const GeneralSettings = function GeneralSettings() {
<Divider margin="1rem" /> <Divider margin="1rem" />
<SegmentPart <SegmentPart
name="Working hours" name="Working hours"
helpText="During this hours, Fredy will search for new apartments. If nothing is configured, Fredy will search around the clock." helpText="During these hours, Fredy will search for new apartments. If nothing is configured, Fredy will search around the clock."
Icon={IconCalendar} Icon={IconCalendar}
> >
<div className="generalSettings__timePickerContainer"> <div className="generalSettings__timePickerContainer">

View File

@@ -4,14 +4,12 @@ import JobTable from '../../components/table/JobTable';
import { useSelector, useActions } from '../../services/state/store'; import { useSelector, useActions } from '../../services/state/store';
import { xhrDelete, xhrPut } from '../../services/xhr'; import { xhrDelete, xhrPut } from '../../services/xhr';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import ProcessingTimes from './ProcessingTimes';
import { Button, Toast } from '@douyinfe/semi-ui'; import { Button, Toast } from '@douyinfe/semi-ui';
import { IconPlusCircle } from '@douyinfe/semi-icons'; import { IconPlusCircle } from '@douyinfe/semi-icons';
import './Jobs.less'; import './Jobs.less';
export default function Jobs() { export default function Jobs() {
const jobs = useSelector((state) => state.jobs.jobs); const jobs = useSelector((state) => state.jobs.jobs);
const processingTimes = useSelector((state) => state.jobs.processingTimes);
const navigate = useNavigate(); const navigate = useNavigate();
const actions = useActions(); const actions = useActions();
@@ -38,7 +36,6 @@ export default function Jobs() {
return ( return (
<div> <div>
<div> <div>
{processingTimes != null && <ProcessingTimes processingTimes={processingTimes} />}
<Button <Button
type="primary" type="primary"
icon={<IconPlusCircle />} icon={<IconPlusCircle />}

View File

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

View File

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

View File

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

View File

@@ -89,7 +89,7 @@ export default function JobMutator() {
/> />
)} )}
<Headline text={jobToBeEdit ? 'Edit a Job' : 'Create a new Job'} /> <Headline text={jobToBeEdit ? 'Edit Job' : 'Create new Job'} />
<form> <form>
<SegmentPart name="Name"> <SegmentPart name="Name">
<Input <Input

View File

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

View File

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

View File

@@ -1428,10 +1428,10 @@
"@resvg/resvg-js-win32-ia32-msvc" "2.4.1" "@resvg/resvg-js-win32-ia32-msvc" "2.4.1"
"@resvg/resvg-js-win32-x64-msvc" "2.4.1" "@resvg/resvg-js-win32-x64-msvc" "2.4.1"
"@rolldown/pluginutils@1.0.0-beta.35": "@rolldown/pluginutils@1.0.0-beta.38":
version "1.0.0-beta.35" version "1.0.0-beta.38"
resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.35.tgz#1a477e7742b154b67519d40e4fc17485de338e7a" resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.38.tgz#95253608c4629eb2a5f3d656009ac9ba031eb292"
integrity sha512-slYrCpoxJUqzFDDNlvrOYRazQUNRvWPjXA17dAOISY3rDMxX6k8K4cj2H+hEYMHF81HO3uNd5rHVigAWRM5dSg== integrity sha512-N/ICGKleNhA5nc9XXQG/kkKHJ7S55u0x0XUJbbkmdCnFuoRkM1Il12q9q0eX19+M7KKUEPw/daUPIRnxhcxAIw==
"@rollup/rollup-android-arm-eabi@4.49.0": "@rollup/rollup-android-arm-eabi@4.49.0":
version "4.49.0" version "4.49.0"
@@ -1895,15 +1895,15 @@
"@turf/invariant" "^6.5.0" "@turf/invariant" "^6.5.0"
eventemitter3 "^4.0.7" eventemitter3 "^4.0.7"
"@vitejs/plugin-react@5.0.3": "@vitejs/plugin-react@5.0.4":
version "5.0.3" version "5.0.4"
resolved "https://registry.yarnpkg.com/@vitejs/plugin-react/-/plugin-react-5.0.3.tgz#182ea45406d89e55b4e35c92a4a8c2c8388726c8" resolved "https://registry.yarnpkg.com/@vitejs/plugin-react/-/plugin-react-5.0.4.tgz#d642058e89c5b712655c8cbd13482f5813519602"
integrity sha512-PFVHhosKkofGH0Yzrw1BipSedTH68BFF8ZWy1kfUpCtJcouXXY0+racG8sExw7hw0HoX36813ga5o3LTWZ4FUg== integrity sha512-La0KD0vGkVkSk6K+piWDKRUyg8Rl5iAIKRMH0vMJI0Eg47bq1eOxmoObAaQG37WMW9MSyk7Cs8EIWwJC1PtzKA==
dependencies: dependencies:
"@babel/core" "^7.28.4" "@babel/core" "^7.28.4"
"@babel/plugin-transform-react-jsx-self" "^7.27.1" "@babel/plugin-transform-react-jsx-self" "^7.27.1"
"@babel/plugin-transform-react-jsx-source" "^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" "@types/babel__core" "^7.20.5"
react-refresh "^0.17.0" 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" resolved "https://registry.yarnpkg.com/ccount/-/ccount-2.0.1.tgz#17a3bf82302e0870d6da43a01311a8bc02a3ecf5"
integrity sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg== integrity sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==
chai@6.0.1: chai@6.2.0:
version "6.0.1" version "6.2.0"
resolved "https://registry.yarnpkg.com/chai/-/chai-6.0.1.tgz#88c2b4682fb56050647e222d2cf9d6772f2607b3" resolved "https://registry.yarnpkg.com/chai/-/chai-6.2.0.tgz#181bca6a219cddb99c3eeefb82483800ffa550ce"
integrity sha512-/JOoU2//6p5vCXh00FpNgtlw0LjvhGttaWc+y7wpW9yjBm3ys0dI8tSKZxIOgNruz5J0RleccatSIC3uxEZP0g== integrity sha512-aUTnJc/JipRzJrNADXVvpVqi6CO0dn3nx4EVPxijri+fj3LUUDyZQOgVeW54Ob3Y1Xh9Iz8f+CgaCl8v0mn9bA==
chalk@^4.0.0, chalk@^4.1.0: chalk@^4.0.0, chalk@^4.1.0:
version "4.1.2" version "4.1.2"
@@ -4559,10 +4559,10 @@ lines-and-columns@^1.1.6:
resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632"
integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==
lint-staged@16.2.1: lint-staged@16.2.3:
version "16.2.1" version "16.2.3"
resolved "https://registry.yarnpkg.com/lint-staged/-/lint-staged-16.2.1.tgz#bb82da8ce10059296b220f321980f0ee1ce40c28" resolved "https://registry.yarnpkg.com/lint-staged/-/lint-staged-16.2.3.tgz#790866221d75602510507b5be40b2c7963715960"
integrity sha512-KMeYmH9wKvHsXdUp+z6w7HN3fHKHXwT1pSTQTYxB9kI6ekK1rlL3kLZEoXZCppRPXFK9PFW/wfQctV7XUqMrPQ== integrity sha512-1OnJEESB9zZqsp61XHH2fvpS1es3hRCxMplF/AJUDa8Ho8VrscYDIuxGrj3m8KPXbcWZ8fT9XTMUhEQmOVKpKw==
dependencies: dependencies:
commander "^14.0.1" commander "^14.0.1"
listr2 "^9.0.4" listr2 "^9.0.4"
@@ -6121,17 +6121,17 @@ react-resizable@^3.0.5:
prop-types "15.x" prop-types "15.x"
react-draggable "^4.0.3" react-draggable "^4.0.3"
react-router-dom@7.9.2: react-router-dom@7.9.3:
version "7.9.2" version "7.9.3"
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.9.2.tgz#2bb35d226ca23329f4e39c8f86d1db26ee4fdf26" resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.9.3.tgz#67ab1655f67b9b6108fe20ed3d4881b53dccf87a"
integrity sha512-pagqpVJnjZOfb+vIM23eTp7Sp/AAJjOgaowhP1f1TWOdk5/W8Uk8d/M/0wfleqx7SgjitjNPPsKeCZE1hTSp3w== integrity sha512-1QSbA0TGGFKTAc/aWjpfW/zoEukYfU4dc1dLkT/vvf54JoGMkW+fNA+3oyo2gWVW1GM7BxjJVHz5GnPJv40rvg==
dependencies: dependencies:
react-router "7.9.2" react-router "7.9.3"
react-router@7.9.2: react-router@7.9.3:
version "7.9.2" version "7.9.3"
resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.9.2.tgz#f424a14f87e4d7b5b268ce3647876e9504e4fca6" resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.9.3.tgz#f2d5ff6181851de3df3acb4e7364fce0dee5fba2"
integrity sha512-i2TPp4dgaqrOqiRGLZmqh2WXmbdFknUyiCRmSKs0hf6fWXkTKg5h56b+9F22NbGRAMxjYfqQnpi63egzD2SuZA== integrity sha512-4o2iWCFIwhI/eYAIL43+cjORXYn/aRQPgtFRRZb3VzoyQ5Uej0Bmqj7437L97N9NJW4wnicSwLOLS+yCXfAPgg==
dependencies: dependencies:
cookie "^1.0.1" cookie "^1.0.1"
set-cookie-parser "^2.6.0" set-cookie-parser "^2.6.0"