Compare commits

..

13 Commits

Author SHA1 Message Date
Christian Kellner
472169693f Improvements 01 28 (#264)
* improving footer

* improve ui

* upgrading dependencies

* adding glow to all boxes on dashboard

* introducing single listing view

* next release version

* improve screenshots and login page
2026-01-28 14:27:03 +01:00
orangecoding
3117044139 fixing immoscout scraper 2026-01-26 19:52:37 +01:00
orangecoding
7879d0e94a next release version 2026-01-26 12:35:57 +01:00
orangecoding
afd1048c9e hardening the check if a listing is active 2026-01-26 12:34:49 +01:00
orangecoding
acbaab05ed next release version 2026-01-26 12:07:43 +01:00
orangecoding
72fffc526b deleting a listing now sets it to deleted in the db, preventing it from reappearing when scraping happens 2026-01-26 12:07:21 +01:00
orangecoding
9e5989ece3 zoom into map where most markers are 2026-01-26 11:54:47 +01:00
orangecoding
afc200c9e1 improved tooltip in map, improved user-settings handling 2026-01-26 11:50:16 +01:00
orangecoding
59226491f2 improved tooltip in map, improved user-settings handling 2026-01-26 11:20:02 +01:00
orangecoding
28f7760120 adapt link to listing in grid view to behave like a real link 2026-01-26 10:43:38 +01:00
orangecoding
2465514b7a fixing immoscout translator, allowing balcony and garden for purchases 2026-01-26 10:20:21 +01:00
Christian Kellner
9dde377fe6 possibility to display distance (#262) 2026-01-25 13:52:56 +01:00
Katrin Leinweber
28a3a7f372 Use EUR-symbol to match Map.jsx (see d43c5b3) (#261)
Co-authored-by: Katrin Leinweber <katrinleinweber@noreply.github.com>
2026-01-25 12:32:11 +01:00
42 changed files with 1890 additions and 641 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 MiB

After

Width:  |  Height:  |  Size: 4.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 402 KiB

After

Width:  |  Height:  |  Size: 531 KiB

View File

@@ -7,7 +7,7 @@
content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1"
/>
<meta name="google" content="notranslate" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<title>Fredy || Real Estate Finder</title>

View File

@@ -35,7 +35,6 @@ service.use('/api/jobs', authInterceptor());
service.use('/api/version', authInterceptor());
service.use('/api/listings', authInterceptor());
service.use('/api/dashboard', authInterceptor());
service.use('/api/features', authInterceptor());
service.use('/api/user/settings', authInterceptor());
// /admin can only be accessed when user is having admin permissions

View File

@@ -74,6 +74,18 @@ listingsRouter.get('/map', async (req, res) => {
res.send();
});
listingsRouter.get('/:listingId', async (req, res) => {
const { listingId } = req.params;
const listing = listingStorage.getListingById(listingId, req.session.currentUser, isAdminFn(req));
if (!listing) {
res.statusCode = 404;
res.body = { message: 'Listing not found' };
return res.send();
}
res.body = listing;
res.send();
});
// Toggle watch state for the current user on a listing
listingsRouter.post('/watch', async (req, res) => {
try {

View File

@@ -12,6 +12,7 @@ import { calculateDistanceForUser } from '../../services/geocoding/distanceServi
import { fromJson } from '../../utils.js';
import { trackFeature } from '../../services/tracking/Tracker.js';
import { FEATURES } from '../../features.js';
import logger from '../../services/logger.js';
const service = restana();
const userSettingsRouter = service.newRouter();
@@ -34,11 +35,12 @@ userSettingsRouter.get('/autocomplete', async (req, res) => {
res.body = results;
res.send();
} catch (error) {
res.status(500).send({ error: error.message });
res.statusCode = 500;
res.send({ error: error.message });
}
});
userSettingsRouter.post('/', async (req, res) => {
userSettingsRouter.post('/home-address', async (req, res) => {
const userId = req.session.currentUser;
const { home_address } = req.body;
@@ -51,15 +53,17 @@ userSettingsRouter.post('/', async (req, res) => {
calculateDistanceForUser(userId);
res.send({ success: true, coords });
} else {
res.status(400).send({ error: 'Could not geocode address' });
res.statusCode = 400;
res.send({ error: 'Could not geocode address' });
}
} else {
// If address is empty, maybe clear it?
upsertSettings({ home_address: null }, userId);
res.send({ success: true });
}
} catch (error) {
res.status(500).send({ error: error.message });
logger.error('Error updating home address settings', error);
res.statusCode = 500;
res.send({ error: error.message });
}
});

View File

@@ -8,7 +8,7 @@
*
* The mobile API provides the following endpoints:
* - GET /search/total?{search parameters}: Returns the total number of listings for the given query
* Example: `curl -H "User-Agent: ImmoScout_27.3_26.0_._" https://api.mobile.immobilienscout24.de/search/total?searchType=region&realestatetype=apartmentrent&pricetype=calculatedtotalrent&geocodes=%2Fde%2Fberlin%2Fberlin `
* Example: `curl -H "User-Agent: ImmoScout_27.12_26.2_._" https://api.mobile.immobilienscout24.de/search/total?searchType=region&realestatetype=apartmentrent&pricetype=calculatedtotalrent&geocodes=%2Fde%2Fberlin%2Fberlin `
*
* - POST /search/list?{search parameters}: Actually retrieves the listings. Body is json encoded and contains
* data specifying additional results (advertisements) to return. The format is as follows:
@@ -20,12 +20,12 @@
* ```
* It is not necessary to provide data for the specified keys.
*
* Example: `curl -X POST 'https://api.mobile.immobilienscout24.de/search/list?pricetype=calculatedtotalrent&realestatetype=apartmentrent&searchType=region&geocodes=%2Fde%2Fberlin%2Fberlin&pagenumber=1' -H "Connection: keep-alive" -H "User-Agent: ImmoScout_27.3_26.0_._" -H "Accept: application/json" -H "Content-Type: application/json" -d '{"supportedResultListType": [], "userData": {}}'`
* Example: `curl -X POST 'https://api.mobile.immobilienscout24.de/search/list?pricetype=calculatedtotalrent&realestatetype=apartmentrent&searchType=region&geocodes=%2Fde%2Fberlin%2Fberlin&pagenumber=1' -H "Connection: keep-alive" -H "User-Agent: ImmoScout_27.12_26.2_._" -H "Accept: application/json" -H "Content-Type: application/json" -d '{"supportedResultListType": [], "userData": {}}'`
* - GET /expose/{id} - Returns the details of a listing. The response contains additional details not included in the
* listing response.
*
* Example: `curl -H "User-Agent: ImmoScout_27.3_26.0_._" "https://api.mobile.immobilienscout24.de/expose/158382494"`
* Example: `curl -H "User-Agent: ImmoScout_27.12_26.2_._" "https://api.mobile.immobilienscout24.de/expose/158382494"`
*
*
* It is necessary to set the correct User Agent (see `getListings`) in the request header.
@@ -52,7 +52,7 @@ async function getListings(url) {
const response = await fetch(url, {
method: 'POST',
headers: {
'User-Agent': 'ImmoScout_27.3_26.0_._',
'User-Agent': 'ImmoScout_27.12_26.2_._',
'Content-Type': 'application/json',
},
body: JSON.stringify({
@@ -88,7 +88,7 @@ async function getListings(url) {
async function isListingActive(link) {
const result = await fetch(convertImmoscoutListingToMobileListing(link), {
headers: {
'User-Agent': 'ImmoScout_27.3_26.0_._',
'User-Agent': 'ImmoScout_27.12_26.2_._',
},
});

View File

@@ -36,7 +36,7 @@ const config = {
},
normalize: normalize,
filter: applyBlacklist,
activeTester: checkIfListingIsActive,
activeTester: (url) => checkIfListingIsActive(url, 'Angebot nicht gefunden'),
};
export const init = (sourceConfig, blacklist) => {
config.enabled = sourceConfig.enabled;

View File

@@ -103,13 +103,17 @@ const REAL_ESTATE_TYPE = {
'haus-mieten': 'houserent',
'wohnung-mieten': 'apartmentrent',
'wohnung-kaufen': 'apartmentbuy',
'wohnung-kaufen-mit-balkon': 'apartmentbuy',
'eigentumswohnung-mit-garten': 'apartmentbuy',
'haus-kaufen': 'housebuy',
};
const WEB_PATH_TO_APARTMENT_EQUIPMENT_MAP = {
// Category "Balkon/Terrasse"
'wohnung-mit-balkon-mieten': { equipment: ['balcony'] },
'wohnung-kaufen-mit-balkon': { equipment: ['balcony'] },
'wohnung-mit-garten-mieten': { equipment: ['garden'] },
'eigentumswohnung-mit-garten': { equipment: ['garden'] },
// Category "Wohnungstyp"
'souterrainwohnung-mieten': { apartmenttypes: ['halfbasement'] },
'erdgeschosswohnung-mieten': { apartmenttypes: ['groundfloor'] },
@@ -144,7 +148,7 @@ export function convertWebToMobile(webUrl) {
const realTypeKey = segments.at(-1);
let realType = REAL_ESTATE_TYPE[realTypeKey];
let additionalParamsFromWebPath;
let additionalParamsFromWebPath = WEB_PATH_TO_APARTMENT_EQUIPMENT_MAP[realTypeKey] || null;
if (!realType) {
// Test for seo optimized apartment path (only used on the ImmoScout web app)
@@ -165,7 +169,7 @@ export function convertWebToMobile(webUrl) {
Object.entries(rawParams).filter(([key]) => key !== 'enteredFrom' && PARAM_NAME_MAP[key]),
);
const geocodes = `/${segments.slice(2, 5).join('/')}`;
const geocodes = `/${segments.slice(2, segments.length - 1).join('/')}`;
const isRadius = segments.includes('radius');
const mobileParams = {
searchType: isRadius ? 'radius' : 'region',

View File

@@ -8,38 +8,71 @@ import { randomBetween, sleep } from '../../utils.js';
const maxAttempts = 3;
const userAgents = [
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Safari/605.1.15',
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1',
];
/**
* Check if a listing is still active with up to 3 attempts and exponential backoff.
* Backoff waits are capped and the last wait is at most 2000 ms.
* Check if a listing is still active with up to 5 attempts and exponential backoff.
* Backoff waits are randomized and capped.
*
* Rules:
* - HTTP 200 => return 1
* - HTTP 200 => return 1 (if checkForText is provided and found, returns 0)
* - HTTP 401/403 => return -1 (most certainly detected as a bot)
* - HTTP 404 => return 0
* - Other statuses or network errors => retry until attempts are exhausted
*
* @returns {Promise<Integer>} 1 if active, o if not active and -1 if detected as bot
* @returns {Promise<Integer>} 1 if active, 0 if not active and -1 if detected as bot
*/
export default async function checkIfListingIsActive(link) {
export default async function checkIfListingIsActive(link, checkForText = null) {
await sleep(randomBetween(50, 100));
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const userAgent = userAgents[Math.floor(Math.random() * userAgents.length)];
const res = await fetch(link, {
redirect: 'manual',
headers: {
'User-Agent':
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36',
'Accept-Language': 'de-DE,de;q=0.9,en;q=0.8',
'User-Agent': userAgent,
Accept:
'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
'Accept-Language': 'de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7',
'Accept-Encoding': 'gzip, deflate, br',
'Cache-Control': 'max-age=0',
'Sec-Ch-Ua': '"Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"',
'Sec-Ch-Ua-Mobile': '?0',
'Sec-Ch-Ua-Platform': '"macOS"',
'Sec-Fetch-Dest': 'document',
'Sec-Fetch-Mode': 'navigate',
'Sec-Fetch-Site': 'none',
'Sec-Fetch-User': '?1',
'Upgrade-Insecure-Requests': '1',
Referer: 'https://www.google.com/',
},
});
if (res.status === 200) {
if (checkForText) {
const htmText = await res.text();
if (htmText.includes(checkForText)) {
return 0;
}
}
return 1;
}
if (res.status === 401) return -1;
if (res.status === 403) return -1;
if (res.status === 404) return 0;
if (res.status === 401 || res.status === 403) {
if (attempt < maxAttempts) {
await sleep(backoffDelay(attempt));
continue;
}
return -1;
}
if (res.status === 404 || res.status === 410) return 0;
// For any other status, only retry if attempts remain
if (attempt < maxAttempts) {
@@ -62,13 +95,13 @@ export default async function checkIfListingIsActive(link) {
}
/**
* Exponential backoff delay with cap.
* attempt: 1 -> 500ms, 2 -> 1000ms, 3 -> 2000ms (cap)
* Exponential backoff delay with cap and jitter.
* @param {number} attempt 1-based attempt index
* @returns {number} delay in ms
*/
function backoffDelay(attempt) {
const base = 500;
const cap = 2000;
return Math.min(base * 2 ** (attempt - 1), cap);
const delay = Math.min(base * 2 ** (attempt - 1), cap);
return delay + randomBetween(0, 1000);
}

View File

@@ -48,7 +48,8 @@ export const getListingsKpisForJobIds = (jobIds = []) => {
SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END) AS activeCount,
AVG(price) AS avgPrice
FROM listings
WHERE job_id IN (${placeholders})`,
WHERE job_id IN (${placeholders})
AND manually_deleted = 0`,
jobIds,
)[0] || {};
@@ -80,6 +81,7 @@ export const getProviderDistributionForJobIds = (jobIds = []) => {
`SELECT provider, COUNT(*) AS cnt
FROM listings
WHERE job_id IN (${placeholders})
AND manually_deleted = 0
GROUP BY provider
ORDER BY cnt DESC`,
jobIds,
@@ -118,8 +120,8 @@ export const getActiveOrUnknownListings = () => {
return SqliteConnection.query(
`SELECT *
FROM listings
WHERE is_active is null
OR is_active = 1
WHERE (is_active is null OR is_active = 1)
AND manually_deleted = 0
ORDER BY provider`,
);
};
@@ -306,6 +308,9 @@ export const queryListings = ({
whereParts.push('(wl.id IS NULL)');
}
// Build whereSql (filtering by manually_deleted = 0)
whereParts.push('(l.manually_deleted = 0)');
const whereSql = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : '';
const whereSqlWithAlias = whereSql
.replace(/\btitle\b/g, 'l.title')
@@ -370,8 +375,8 @@ export const queryListings = ({
export const deleteListingsByJobId = (jobId) => {
if (!jobId) return;
return SqliteConnection.execute(
`DELETE
FROM listings
`UPDATE listings
SET manually_deleted = 1
WHERE job_id = @jobId`,
{ jobId },
);
@@ -387,9 +392,9 @@ 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})`,
`UPDATE listings
SET manually_deleted = 1
WHERE id IN (${placeholders})`,
ids,
);
};
@@ -404,6 +409,7 @@ export const getListingsToGeocode = () => {
`SELECT id, address
FROM listings
WHERE is_active = 1
AND manually_deleted = 0
AND address IS NOT NULL
AND (latitude IS NULL OR longitude IS NULL)`,
);
@@ -443,6 +449,7 @@ export const getListingsForMap = ({ jobId, userId = null, isAdmin = false } = {}
'l.latitude != -1',
'l.longitude != -1',
'l.is_active = 1',
'l.manually_deleted = 0',
];
const params = { userId: userId || '__NO_USER__' };
@@ -479,7 +486,7 @@ export const getListingsForMap = ({ jobId, userId = null, isAdmin = false } = {}
* @returns {{title: string|null, address: string|null, price: number|null}[]}
*/
export const getAllEntriesFromListings = () => {
return SqliteConnection.query(`SELECT title, address, price FROM listings`);
return SqliteConnection.query(`SELECT title, address, price FROM listings WHERE manually_deleted = 0`);
};
/**
@@ -493,6 +500,7 @@ export const getGeocoordinatesByAddress = (address) => {
`SELECT latitude, longitude
FROM listings
WHERE address = @address
AND manually_deleted = 0
AND latitude IS NOT NULL
AND longitude IS NOT NULL
AND latitude != -1
@@ -515,6 +523,7 @@ export const getListingsToCalculateDistance = (jobId) => {
FROM listings
WHERE job_id = @jobId
AND is_active = 1
AND manually_deleted = 0
AND latitude IS NOT NULL
AND longitude IS NOT NULL
AND distance_to_destination IS NULL`,
@@ -535,6 +544,7 @@ export const getListingsForUserToCalculateDistance = (userId) => {
JOIN jobs j ON l.job_id = j.id
WHERE j.user_id = @userId
AND l.is_active = 1
AND l.manually_deleted = 0
AND l.latitude IS NOT NULL
AND l.longitude IS NOT NULL`,
{ userId },
@@ -556,3 +566,29 @@ export const updateListingDistance = (id, distance) => {
{ id, distance },
);
};
/**
* Return a single listing by id.
*
* @param {string} id
* @param {string} userId
* @param {boolean} isAdmin
* @returns {Object|null}
*/
export const getListingById = (id, userId = null, isAdmin = false) => {
const params = { id, userId: userId || '__NO_USER__' };
let whereScoping = '';
if (!isAdmin) {
whereScoping = `AND (j.user_id = @userId OR EXISTS (SELECT 1 FROM json_each(j.shared_with_user) AS sw WHERE sw.value = @userId))`;
}
return (
SqliteConnection.query(
`SELECT l.*, j.name AS job_name, CASE WHEN wl.id IS NOT NULL THEN 1 ELSE 0 END AS isWatched
FROM listings l
LEFT JOIN jobs j ON j.id = l.job_id
LEFT JOIN watch_list wl ON wl.listing_id = l.id AND wl.user_id = @userId
WHERE l.id = @id AND l.manually_deleted = 0 ${whereScoping}`,
params,
)[0] || null
);
};

View File

@@ -0,0 +1,16 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
export function up(db) {
// 1. Add manually_deleted column
db.exec(`ALTER TABLE listings ADD COLUMN manually_deleted INTEGER NOT NULL DEFAULT 0;`);
// 2. Remove change_set column
try {
db.exec(`ALTER TABLE listings DROP COLUMN change_set;`);
} catch {
// if column does not exists for whatever reason
}
}

View File

@@ -90,15 +90,25 @@ export function upsertSettings(settingsMapOrEntry, userId = null) {
: Object.entries(settingsMapOrEntry || {});
for (const [name, rawValue] of entries) {
const id = nanoid();
const create_date = Date.now();
const json = toJson(rawValue);
SqliteConnection.execute(
`INSERT INTO settings (id, create_date, name, value, user_id)
if (rawValue === null) {
SqliteConnection.execute(
`DELETE FROM settings WHERE name = @name AND (user_id = @userId OR (user_id IS NULL AND @userId IS NULL))`,
{
name,
userId,
},
);
} else {
const id = nanoid();
const create_date = Date.now();
const json = toJson(rawValue);
SqliteConnection.execute(
`INSERT INTO settings (id, create_date, name, value, user_id)
VALUES (@id, @create_date, @name, @value, @userId)
ON CONFLICT(name, IFNULL(user_id, 'GLOBAL_SETTING')) DO UPDATE SET value = excluded.value`,
{ id, create_date, name, value: json, userId },
);
{ id, create_date, name, value: json, userId },
);
}
}
// keep cache in sync (only for global settings)
if (userId == null) {

View File

@@ -95,7 +95,7 @@ function isOneOf(word, arr) {
* @returns {boolean}
*/
function nullOrEmpty(val) {
return val == null || val.length === 0 || val === 'null' || val === 'undefined';
return val == null || val.length === 0;
}
/**

View File

@@ -1,6 +1,6 @@
{
"name": "fredy",
"version": "19.0.0",
"version": "19.3.0",
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
"scripts": {
"prepare": "husky",
@@ -68,27 +68,27 @@
"better-sqlite3": "^12.6.2",
"body-parser": "2.2.2",
"chart.js": "^4.5.1",
"cheerio": "^1.1.2",
"cheerio": "^1.2.0",
"cookie-session": "2.1.1",
"handlebars": "4.7.8",
"lodash": "4.17.23",
"maplibre-gl": "^5.16.0",
"maplibre-gl": "^5.17.0",
"nanoid": "5.1.6",
"node-cron": "^4.2.1",
"node-fetch": "3.3.2",
"node-mailjet": "6.0.11",
"p-throttle": "^8.1.0",
"package-up": "^5.0.0",
"puppeteer": "^24.36.0",
"puppeteer": "^24.36.1",
"puppeteer-extra": "^3.3.6",
"puppeteer-extra-plugin-stealth": "^2.11.2",
"query-string": "9.3.1",
"react": "19.2.3",
"react": "19.2.4",
"react-chartjs-2": "^5.3.1",
"react-dom": "19.2.3",
"react-dom": "19.2.4",
"react-range-slider-input": "^3.3.2",
"react-router": "7.12.0",
"react-router-dom": "7.12.0",
"react-router": "7.13.0",
"react-router-dom": "7.13.0",
"restana": "5.1.0",
"semver": "^7.7.3",
"serve-static": "2.2.1",

View File

@@ -41,7 +41,7 @@ Challenges:
_Returns the total number of listings for the given query._
```
curl -H "User-Agent: ImmoScout_27.3_26.0_._" \
curl -H "User-Agent: ImmoScout_27.12_26.2_._" \
-H "Accept: application/json" \
"https://api.mobile.immobilienscout24.de/search/total?searchType=region&realestatetype=apartmentrent&pricetype=calculatedtotalrent&geocodes=%2Fde%2Fberlin%2Fberlin"
```
@@ -63,7 +63,7 @@ _The body is json encoded and contains data specifying additional results (adver
```
curl -X POST 'https://api.mobile.immobilienscout24.de/search/list?pricetype=calculatedtotalrent&realestatetype=apartmentrent&searchType=region&geocodes=%2Fde%2Fberlin%2Fberlin&pagenumber=1' \
-H "Connection: keep-alive" \
-H "User-Agent: ImmoScout_27.3_26.0_._" \
-H "User-Agent: ImmoScout_27.12_26.2_._" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{"supportedResultListType":[],"userData":{}}'
@@ -78,7 +78,7 @@ curl -X POST 'https://api.mobile.immobilienscout24.de/search/list?pricetype=calc
The response contains additional details not included in the listing response.
```
curl -H "User-Agent: ImmoScout_27.3_26.0_._" \
curl -H "User-Agent: ImmoScout_27.12_26.2_._" \
-H "Accept: application/json" \
"https://api.mobile.immobilienscout24.de/expose/158382494"
```

View File

@@ -58,7 +58,7 @@ describe('#immoscout-mobile URL conversion', () => {
const response = await fetch(url, {
method: 'POST',
headers: {
'User-Agent': 'ImmoScout_27.3_26.0_._',
'User-Agent': 'ImmoScout_27.12_26.2_._',
'Content-Type': 'application/json',
},
body: JSON.stringify({
@@ -75,7 +75,9 @@ describe('#immoscout-mobile URL conversion', () => {
expect(responseBody.totalResults).to.be.greaterThan(0);
expect(responseBody.totalResults).to.be.greaterThan(0);
expect(responseBody.resultListItems.length).to.greaterThan(0);
expect(responseBody.resultListItems[0].item.realEstateType).to.equal(type);
expect(responseBody.resultListItems.filter((r) => r.type === 'EXPOSE_RESULT')[0].item.realEstateType).to.equal(
type,
);
}
});
});

View File

@@ -19,7 +19,7 @@ import Jobs from './views/jobs/Jobs';
import './App.less';
import TrackingModal from './components/tracking/TrackingModal.jsx';
import { Banner, Divider } from '@douyinfe/semi-ui-19';
import { Banner } from '@douyinfe/semi-ui-19';
import VersionBanner from './components/version/VersionBanner.jsx';
import Listings from './views/listings/Listings.jsx';
import MapView from './views/listings/Map.jsx';
@@ -28,6 +28,7 @@ import { Layout } from '@douyinfe/semi-ui-19';
import FredyFooter from './components/footer/FredyFooter.jsx';
import WatchlistManagement from './views/listings/management/WatchlistManagement.jsx';
import Dashboard from './views/dashboard/Dashboard.jsx';
import ListingDetail from './views/listings/ListingDetail.jsx';
export default function FredyApp() {
const actions = useActions();
@@ -40,12 +41,12 @@ export default function FredyApp() {
async function init() {
await actions.user.getCurrentUser();
if (!needsLogin()) {
await actions.features.getFeatures();
await actions.provider.getProvider();
await actions.jobsData.getJobs();
await actions.jobsData.getSharableUserList();
await actions.notificationAdapter.getAdapter();
await actions.generalSettings.getGeneralSettings();
await actions.userSettings.getUserSettings();
await actions.versionUpdate.getVersionUpdate();
}
setLoading(false);
@@ -59,7 +60,7 @@ export default function FredyApp() {
};
const isAdmin = () => currentUser != null && currentUser.isAdmin;
const { Footer, Sider, Content } = Layout;
const { Sider, Content } = Layout;
return loading ? null : needsLogin() ? (
<Routes>
@@ -68,11 +69,11 @@ export default function FredyApp() {
</Routes>
) : (
<Layout className="app">
<Layout className="app">
<Sider>
<Navigation isAdmin={isAdmin()} />
</Sider>
<Content>
<Sider>
<Navigation isAdmin={isAdmin()} />
</Sider>
<Layout className="app__main">
<Content className="app__content">
{versionUpdate?.newVersion && <VersionBanner />}
{settings.demoMode && (
<>
@@ -87,68 +88,64 @@ export default function FredyApp() {
</>
)}
{settings.analyticsEnabled === null && !settings.demoMode && <TrackingModal />}
<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="/dashboard" element={<Dashboard />} />
<Route path="/jobs" element={<Jobs />} />
<Route path="/listings" element={<Listings />} />
<Route path="/map" element={<MapView />} />
<Route path="/watchlistManagement" element={<WatchlistManagement />} />
<Routes>
<Route path="/403" element={<InsufficientPermission />} />
<Route path="/jobs/new" element={<JobMutation />} />
<Route path="/jobs/edit/:jobId" element={<JobMutation />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/jobs" element={<Jobs />} />
<Route path="/listings" element={<Listings />} />
<Route path="/listings/listing/:listingId" element={<ListingDetail />} />
<Route path="/map" element={<MapView />} />
<Route path="/watchlistManagement" element={<WatchlistManagement />} />
{/* 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="/userSettings"
element={
<PermissionAwareRoute currentUser={currentUser} adminOnly={false}>
<UserSettings />
</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="/userSettings"
element={
<PermissionAwareRoute currentUser={currentUser} adminOnly={false}>
<UserSettings />
</PermissionAwareRoute>
}
/>
<Route
path="/generalSettings"
element={
<PermissionAwareRoute currentUser={currentUser}>
<GeneralSettings />
</PermissionAwareRoute>
}
/>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
</Routes>
</div>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
</Routes>
</Content>
</Layout>
<Footer>
<FredyFooter />
</Footer>
</Layout>
</Layout>
);
}

View File

@@ -1,45 +1,29 @@
.app {
height: 100%;
width: 100%;
height: 100vh;
width: 100vw;
&__main {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
&__content {
margin: 1rem;
flex: 1;
overflow-y: auto;
overflow-x: hidden;
position: relative;
padding: 24px;
background-color: var(--semi-color-bg-0);
box-sizing: border-box;
@media (max-width: 768px) {
padding: 12px;
}
}
}
.ui.inverted.segment {
background: #31303078 !important;
}
.ui.black.label,
.ui.black.labels .label {
background-color: #31303078 !important;
}
a:link {
color: #54a9ff;
background-color: transparent;
text-decoration: none;
}
a:visited {
color: #54a9ff;
background-color: transparent;
text-decoration: none;
}
a:hover {
color: #54a9ff;
background-color: transparent;
text-decoration: underline;
}
a:active {
color: #54a9ff;
background-color: transparent;
text-decoration: underline;
}
.semi-icon:not(.semi-tabs-bar .semi-tabs-tab .semi-icon) {
vertical-align: middle;
}

View File

@@ -1,92 +1,67 @@
@import "DashboardCardColors.less";
.color-variant(@bg, @border, @text) {
background-color: @bg;
border: 1px solid @border;
color: @text;
}
.dashboard-card {
box-sizing: border-box;
padding: .8rem;
border-radius: .5rem;
border-width: 1px;
font-weight: 600;
box-shadow: 0 6px 20px rgba(0,0,0,0.08);
/* Make all KPI boxes the same size regardless of content/font */
width: 100%;
max-width: none;
height: 10rem;
display: flex;
flex-direction: column;
height: 140px;
margin-bottom: 16px;
transition: transform 0.2s, box-shadow 0.2s;
background-color: rgba(36, 36, 36, 0.9);
backdrop-filter: blur(8px);
border: 1px solid var(--semi-color-border);
&.blue {
.color-variant(@color-blue-bg, @color-blue-border, @color-blue-text);
}
&:hover {
transform: translateY(-4px);
background-color: rgba(36, 36, 36, 1);
&.orange {
.color-variant(@color-orange-bg, @color-orange-border, @color-orange-text);
}
&.green {
.color-variant(@color-green-bg, @color-green-border, @color-green-text);
}
&.purple {
.color-variant(@color-purple-bg, @color-purple-border, @color-purple-text);
}
&.gray {
.color-variant(@color-gray-bg, @color-gray-border, @color-gray-text);
}
&__header {
display: flex;
align-items: center;
gap: .6rem;
/* Keep header from growing content height */
min-height: 2rem;
overflow: hidden;
&.blue {
box-shadow: 0 8px 24px -5px var(--semi-color-primary);
}
&.orange {
box-shadow: 0 8px 24px -5px var(--semi-color-warning);
}
&.green {
box-shadow: 0 8px 24px -5px var(--semi-color-success);
}
&.purple {
box-shadow: 0 8px 24px -5px var(--semi-color-info);
}
&.gray {
box-shadow: 0 8px 24px -5px rgba(255, 255, 255, 0.4);
}
}
&__icon {
border-radius: .6rem;
display: grid;
place-items: center;
}
&__title {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 20px;
display: flex;
align-items: center;
justify-content: center;
}
&__content {
margin-top: .4rem;
font-size: .7rem;
flex: 1 1 auto;
display: flex;
flex-direction: column;
justify-content: center;
overflow: hidden;
width: 100%;
}
&__value {
margin: 0;
font-size: 1.5rem;
line-height: 1.1;
color: #fff;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 700;
margin-bottom: 4px;
color: var(--semi-color-text-0);
}
&__desc {
opacity: .8;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&.blue {
box-shadow: 0 4px 20px -5px var(--semi-color-primary);
}
&.orange {
box-shadow: 0 4px 20px -5px var(--semi-color-warning);
}
&.green {
box-shadow: 0 4px 20px -5px var(--semi-color-success);
}
&.purple {
box-shadow: 0 4px 20px -5px var(--semi-color-info);
}
&.gray {
box-shadow: 0 4px 20px -5px rgba(255, 255, 255, 0.2);
}
}

View File

@@ -3,12 +3,8 @@
* 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 React from 'react';
import { Card, Typography, Space } from '@douyinfe/semi-ui-19';
import './DashboardCard.less';
export default function KpiCard({
@@ -20,21 +16,28 @@ export default function KpiCard({
color = 'gray',
children,
}) {
const { Text } = Typography;
return (
<div className={`dashboard-card ${color}`}>
<div className="dashboard-card__header">
<div className="dashboard-card__icon">{icon}</div>
<div className="dashboard-card__title">
<span>{title}</span>
<Card className={`dashboard-card ${color}`} bodyStyle={{ padding: '16px' }}>
<Space vertical align="start" spacing="tight" style={{ width: '100%' }}>
<Space>
<div className="dashboard-card__icon">{icon}</div>
<Text strong className="dashboard-card__title">
{title}
</Text>
</Space>
<div className="dashboard-card__content">
<div className="dashboard-card__value" style={{ fontSize: valueFontSize }}>
{value}
{children}
</div>
{description && (
<Text size="small" type="tertiary" className="dashboard-card__desc">
{description}
</Text>
)}
</div>
</div>
<div className="dashboard-card__content">
<p className="dashboard-card__value" style={{ fontSize: valueFontSize }}>
{value}
{children}
</p>
{description && <span className="dashboard-card__desc">{description}</span>}
</div>
</div>
</Space>
</Card>
);
}

View File

@@ -6,19 +6,23 @@
import React from 'react';
import './FredyFooter.less';
import { useSelector } from '../../services/state/store.js';
import { Typography } from '@douyinfe/semi-ui-19';
import { Typography, Layout, Space, Divider } from '@douyinfe/semi-ui-19';
export default function FredyFooter() {
const { Text } = Typography;
const { Footer } = Layout;
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>
<Footer className="fredyFooter">
<Space split={<Divider layout="vertical" />}>
<Text type="tertiary" size="small">
Fredy V{version?.localFredyVersion || 'N/A'}
</Text>
<Text size="small" link={{ href: 'https://github.com/orangecoding', target: '_blank' }}>
Made with
</Text>
</Space>
</Footer>
);
}

View File

@@ -1,20 +1,12 @@
.fredyFooter {
background:rgb(53, 54, 60);
color: white;
background-color: var(--semi-color-bg-1);
display: flex;
justify-content: space-between;
justify-content: flex-end;
align-items: center;
height: 1.7rem;
border-radius: .3rem;
border-top: 1px solid #45464b;
&__version {
padding-left: .5rem;
font-size: small;
}
&__copyRight {
padding-right: 1rem;
}
padding: 0 1rem;
height: 32px;
border-top: 1px solid var(--semi-color-border);
z-index: 1000;
position: relative;
flex-shrink: 0;
}

View File

@@ -185,31 +185,21 @@ const JobGrid = () => {
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')}
>
<Space vertical align="start" style={{ width: '100%', marginBottom: '16px' }} spacing="medium">
<Button type="primary" icon={<IconPlusCircle />} onClick={() => navigate('/jobs/new')}>
New Job
</Button>
<div className="jobGrid__searchbar">
<div className="jobGrid__searchbar" style={{ width: '100%' }}>
<Input prefix={<IconSearch />} showClear placeholder="Search" onChange={handleFilterChange} />
<Popover content="Filter / Sort Results" style={{ color: 'white', padding: '.5rem' }}>
<div>
<Button
icon={<IconFilter />}
onClick={() => {
setShowFilterBar(!showFilterBar);
}}
/>
</div>
</Popover>
<Button
icon={<IconFilter />}
style={{ marginLeft: '8px' }}
onClick={() => {
setShowFilterBar(!showFilterBar);
}}
/>
</div>
</div>
</Space>
{showFilterBar && (
<div className="jobGrid__toolbar">
@@ -277,7 +267,6 @@ const JobGrid = () => {
<Card
className="jobGrid__card"
bodyStyle={{ padding: '16px' }}
headerLine={true}
title={
<div className="jobGrid__header">
<Title heading={5} ellipsis={{ showTooltip: true }} className="jobGrid__title">
@@ -351,6 +340,8 @@ const JobGrid = () => {
<div>
<Button
type="primary"
style={{ background: '#21aa21b5' }}
size="small"
theme="solid"
icon={<IconPlayCircle />}
disabled={job.isOnlyShared || job.running}
@@ -362,7 +353,7 @@ const JobGrid = () => {
<div>
<Button
type="secondary"
theme="solid"
size="small"
icon={<IconEdit />}
disabled={job.isOnlyShared}
onClick={() => navigate(`/jobs/edit/${job.id}`)}
@@ -373,7 +364,7 @@ const JobGrid = () => {
<div>
<Button
type="tertiary"
theme="solid"
size="small"
icon={<IconCopy />}
disabled={job.isOnlyShared}
onClick={() => navigate('/jobs/new', { state: { cloneFrom: job.id } })}
@@ -384,7 +375,7 @@ const JobGrid = () => {
<div>
<Button
type="danger"
theme="solid"
size="small"
icon={<IconDescend2 />}
disabled={job.isOnlyShared}
onClick={() => onListingRemoval(job.id)}
@@ -395,7 +386,7 @@ const JobGrid = () => {
<div>
<Button
type="danger"
theme="solid"
size="small"
icon={<IconDelete />}
disabled={job.isOnlyShared}
onClick={() => onJobRemoval(job.id)}

View File

@@ -1,11 +1,16 @@
.jobGrid {
&__card {
height: 100%;
transition: transform 0.2s;
transition: transform 0.2s, box-shadow 0.2s;
background-color: rgba(36, 36, 36, 0.9);
backdrop-filter: blur(8px);
border: 1px solid var(--semi-color-border);
box-shadow: 0 0 15px -3px rgb(78 78 78 / 50%);
&:hover {
transform: translateY(-4px);
box-shadow: var(--semi-shadow-elevated);
box-shadow: 0 0 15px -3px rgb(78 78 78 / 70%);
background-color: rgba(36, 36, 36, 1);
}
}
@@ -19,12 +24,14 @@
&__toolbar {
&__card {
border-radius: 5px;
border-radius: var(--semi-border-radius-medium);
display: flex;
flex-direction: column;
gap: .3rem;
background: #232429;
background: rgba(36, 36, 36, 0.9);
backdrop-filter: blur(8px);
padding: 0.5rem;
border: 1px solid var(--semi-color-border);
}
}

View File

@@ -32,7 +32,9 @@ import {
IconSearch,
IconFilter,
IconActivity,
IconEyeOpened,
} from '@douyinfe/semi-icons';
import { useNavigate } from 'react-router-dom';
import no_image from '../../../assets/no_image.jpg';
import * as timeService from '../../../services/time/timeService.js';
import { xhrDelete, xhrPost } from '../../../services/xhr.js';
@@ -49,6 +51,7 @@ const ListingsGrid = () => {
const providers = useSelector((state) => state.provider);
const jobs = useSelector((state) => state.jobsData.jobs);
const actions = useActions();
const navigate = useNavigate();
const [page, setPage] = useState(1);
const pageSize = 40;
@@ -103,6 +106,10 @@ const ListingsGrid = () => {
setPage(_page);
};
const cap = (val) => {
return String(val).charAt(0).toUpperCase() + String(val).slice(1);
};
return (
<div className="listingsGrid">
<div className="listingsGrid__searchbar">
@@ -219,6 +226,8 @@ const ListingsGrid = () => {
<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' : ''}`}
style={{ cursor: 'pointer' }}
onClick={() => navigate(`/listings/listing/${item.id}`)}
cover={
<div style={{ position: 'relative' }}>
<div className="listingsGrid__imageContainer">
@@ -251,11 +260,9 @@ const ListingsGrid = () => {
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>
<Text strong ellipsis={{ showTooltip: true }} className="listingsGrid__title">
{cap(item.title)}
</Text>
<Space vertical align="start" spacing={2} style={{ width: '100%', marginTop: 8 }}>
<Text type="secondary" icon={<IconCart />} size="small">
{item.price}
@@ -287,21 +294,26 @@ const ListingsGrid = () => {
</Space>
<Divider margin=".6rem" />
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<div className="listingsGrid__linkButton" onClick={(e) => e.stopPropagation()}>
<a href={item.link} target="_blank" rel="noopener noreferrer">
<IconLink />
</a>
</div>
<Button
title="Link to listing"
type="primary"
type="secondary"
size="small"
onClick={async () => {
window.open(item.link);
}}
icon={<IconLink />}
title="View Details"
onClick={() => navigate(`/listings/listing/${item.id}`)}
icon={<IconEyeOpened />}
/>
<Button
title="Remove"
type="danger"
size="small"
onClick={async () => {
onClick={async (e) => {
e.stopPropagation();
try {
await xhrDelete('/api/listings/', { ids: [item.id] });
Toast.success('Listing(s) successfully removed');

View File

@@ -33,11 +33,15 @@
&__card {
height: 100%;
transition: transform 0.2s;
transition: transform 0.2s, box-shadow 0.2s;
background-color: rgba(36, 36, 36, 0.9);
backdrop-filter: blur(8px);
border: 1px solid var(--semi-color-border);
&:hover {
transform: translateY(-4px);
box-shadow: var(--semi-shadow-elevated);
background-color: rgba(36, 36, 36, 1);
}
&--inactive {
@@ -90,17 +94,45 @@
}
&__toolbar {
&__card {
border-radius: 5px;
border-radius: var(--semi-border-radius-medium);
display: flex;
flex-direction: column;
gap: .3rem;
background: #232429;
background: rgba(36, 36, 36, 0.9);
backdrop-filter: blur(8px);
padding: 0.5rem;
border: 1px solid var(--semi-color-border);
}
}
&__setupButton {
margin-bottom: 1rem;
}
&__linkButton {
background: var(--semi-color-primary);
font-size: 14px;
line-height: 20px;
font-weight: 600;
height: 24px;
width: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 3px;
a {
color: white;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
&:hover {
background: var(--semi-color-primary-hover);
}
}
}

View File

@@ -6,5 +6,6 @@
gap: 0.5rem;
width: 100%;
display: flex;
padding-bottom: 12px;
}
}

View File

@@ -70,20 +70,18 @@ export default function Navigation({ isAdmin }) {
return (
<Nav
style={{ height: '100%' }}
style={{ height: '100%', maxWidth: collapsed ? '60px' : '240px' }}
items={items}
isCollapsed={collapsed}
selectedKeys={[parsePathName(location.pathname)]}
onSelect={(key) => {
navigate(key.itemKey);
}}
header={<img src={collapsed ? heart : logoWhite} width={collapsed ? '80' : '160'} alt="Fredy Logo" />}
header={<img src={collapsed ? heart : logoWhite} width={collapsed ? '30' : '120'} alt="Fredy Logo" />}
footer={
<Nav.Footer className="navigate__footer">
<Logout text={!collapsed} />
<Button icon={<IconSidebar />} onClick={() => setCollapsed(!collapsed)}>
{!collapsed && 'Collapse'}
</Button>
<Button icon={<IconSidebar />} onClick={() => setCollapsed(!collapsed)} />
</Nav.Footer>
}
/>

View File

@@ -7,8 +7,10 @@ import React from 'react';
import { Empty, Table, Button } from '@douyinfe/semi-ui-19';
import { IconDelete, IconEdit } from '@douyinfe/semi-icons';
import { Typography } from '@douyinfe/semi-ui';
export default function ProviderTable({ providerData = [], onRemove, onEdit } = {}) {
const { Text } = Typography;
return (
<Table
pagination={false}
@@ -22,11 +24,7 @@ export default function ProviderTable({ providerData = [], onRemove, onEdit } =
title: 'URL',
dataIndex: 'url',
render: (_, data) => {
return (
<a href={data.url} target="_blank" rel="noopener noreferrer">
Visit site
</a>
);
return <Text link={{ href: data.url, target: '_blank' }}>Open Provider</Text>;
},
},
{

View File

@@ -63,16 +63,6 @@ export const useFredyState = create(
}
},
},
features: {
async getFeatures() {
try {
const response = await xhrGet('/api/features');
set((state) => ({ ...state.features, ...response.json }));
} catch (Exception) {
console.error('Error while trying to get resource for api/features. Error:', Exception);
}
},
},
provider: {
async getProvider() {
try {
@@ -205,6 +195,18 @@ export const useFredyState = create(
console.error('Error while trying to get resource for api/listings. Error:', Exception);
}
},
async getListing(listingId) {
try {
const response = await xhrGet(`/api/listings/${listingId}`);
set((state) => ({
listingsData: { ...state.listingsData, currentListing: response.json },
}));
return response.json;
} catch (Exception) {
console.error(`Error while trying to get resource for api/listings/${listingId}. Error:`, Exception);
throw Exception;
}
},
async getListingsForMap({ jobId, minPrice, maxPrice } = {}) {
try {
const qryString = queryString.stringify(
@@ -228,6 +230,16 @@ export const useFredyState = create(
}
},
},
userSettings: {
async getUserSettings() {
try {
const response = await xhrGet('/api/user/settings');
set((state) => ({ userSettings: { ...state.userSettings, settings: response.json } }));
} catch (Exception) {
console.error('Error while trying to get resource for api/user/settings. Error:', Exception);
}
},
},
};
// Initial state
@@ -239,10 +251,11 @@ export const useFredyState = create(
page: 1,
result: [],
mapListings: [],
currentListing: null,
maxPrice: 0,
},
features: {},
generalSettings: { settings: {} },
userSettings: { settings: {} },
demoMode: { demoMode: false },
versionUpdate: {},
provider: [],
@@ -265,9 +278,9 @@ export const useFredyState = create(
versionUpdate: { ...effects.versionUpdate },
listingsData: { ...effects.listingsData },
provider: { ...effects.provider },
features: { ...effects.features },
jobsData: { ...effects.jobsData },
user: { ...effects.user },
userSettings: { ...effects.userSettings },
};
return {

View File

@@ -41,10 +41,10 @@ export default function Dashboard() {
<div className="dashboard">
<Headline text="Dashboard" size={3} />
<Row gutter={16} className="dashboard__row">
<Row gutter={[16, 16]} className="dashboard__row">
<Col span={12} xs={24} sm={24} md={24} lg={24} xl={12}>
<SegmentPart name="General" Icon={IconTerminal}>
<Row gutter={16} className="dashboard__row">
<Row gutter={[16, 16]} className="dashboard__row">
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
<KpiCard
title="Search Interval"
@@ -104,7 +104,7 @@ export default function Dashboard() {
</Col>
<Col span={12} xs={24} sm={24} md={24} lg={24} xl={12}>
<SegmentPart name="Overview" Icon={IconStar}>
<Row gutter={16} className="dashboard__row">
<Row gutter={[16, 16]} className="dashboard__row">
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
<KpiCard
title="Jobs"
@@ -136,7 +136,7 @@ export default function Dashboard() {
<KpiCard
title="Avg. Price"
color="purple"
value={`${!kpis.avgPriceOfListings ? '---' : kpis.avgPriceOfListings} EUR`}
value={`${!kpis.avgPriceOfListings ? '---' : kpis.avgPriceOfListings} `}
icon={<IconNoteMoney />}
description="Avg. Price of listings"
/>
@@ -147,7 +147,7 @@ export default function Dashboard() {
</Row>
<SegmentPart name="Provider Insights" Icon={IconStar} helpText="Percentage of found listings over all providers">
<PieChartCard title="Jobs per Provider" data={pieData} isLoading={false} />
<PieChartCard data={pieData} />
</SegmentPart>
</div>
);

View File

@@ -1,11 +1,10 @@
.dashboard {
&__row {
margin-bottom: 1rem;
/* Ensure grid items wrap to next line on narrow screens */
margin-bottom: 24px;
flex-wrap: wrap;
/* Vertical gap of 1rem between wrapped grid items (no px) */
.semi-col {
margin-bottom: 1rem;
margin-bottom: 0; // Handled by Row gutter
}
}
}

View File

@@ -0,0 +1,383 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React, { useEffect, useRef, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useSelector, useActions } from '../../services/state/store.js';
import {
Typography,
Button,
Space,
Card,
Row,
Col,
Image,
Tag,
Divider,
Descriptions,
Banner,
Spin,
Toast,
} from '@douyinfe/semi-ui-19';
import {
IconArrowLeft,
IconMapPin,
IconCart,
IconClock,
IconBriefcase,
IconActivity,
IconLink,
IconStar,
IconStarStroked,
IconRealSize,
} from '@douyinfe/semi-icons';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import no_image from '../../assets/no_image.jpg';
import * as timeService from '../../services/time/timeService.js';
import { distanceMeters, getBoundsFromCoords } from './mapUtils.js';
import { xhrPost } from '../../services/xhr.js';
import './ListingDetail.less';
const { Title, Text } = Typography;
const STYLES = {
STANDARD: 'https://tiles.openfreemap.org/styles/bright',
};
export default function ListingDetail() {
const { listingId } = useParams();
const navigate = useNavigate();
const actions = useActions();
const listing = useSelector((state) => state.listingsData.currentListing);
const homeAddress = useSelector((state) => state.userSettings.settings.home_address);
const mapContainer = useRef(null);
const map = useRef(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchListing() {
try {
setLoading(true);
await actions.listingsData.getListing(listingId);
} catch (e) {
console.error('Failed to load listing details:', e);
Toast.error('Failed to load listing details');
navigate('/listings');
} finally {
setLoading(false);
}
}
fetchListing();
}, [listingId]);
const hasGeo =
listing?.latitude != null && listing?.longitude != null && listing?.latitude !== -1 && listing?.longitude !== -1;
useEffect(() => {
if (loading || !listing || !mapContainer.current || !hasGeo) return;
if (map.current) {
map.current.remove();
}
map.current = new maplibregl.Map({
container: mapContainer.current,
style: STYLES.STANDARD,
center: [listing.longitude, listing.latitude],
zoom: 14,
cooperativeGestures: true,
});
map.current.addControl(new maplibregl.NavigationControl(), 'top-right');
new maplibregl.Marker({ color: '#3FB1CE' })
.setLngLat([listing.longitude, listing.latitude])
.setPopup(new maplibregl.Popup({ offset: 25 }).setHTML(`<h4>Listing Location</h4><p>${listing.address}</p>`))
.addTo(map.current);
if (homeAddress?.coords) {
new maplibregl.Marker({ color: 'red' })
.setLngLat([homeAddress.coords.lng, homeAddress.coords.lat])
.setPopup(new maplibregl.Popup({ offset: 25 }).setHTML(`<h4>Home Address</h4><p>${homeAddress.address}</p>`))
.addTo(map.current);
const bounds = getBoundsFromCoords([
[listing.longitude, listing.latitude],
[homeAddress.coords.lng, homeAddress.coords.lat],
]);
map.current.fitBounds(bounds, {
padding: 50,
maxZoom: 15,
});
const drawLine = () => {
if (!map.current || !map.current.isStyleLoaded()) return;
const distance = distanceMeters(
listing.latitude,
listing.longitude,
homeAddress.coords.lat,
homeAddress.coords.lng,
);
const midpoint = [
(listing.longitude + homeAddress.coords.lng) / 2,
(listing.latitude + homeAddress.coords.lat) / 2,
];
if (map.current.getSource('route')) {
map.current.getSource('route').setData({
type: 'FeatureCollection',
features: [
{
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: [
[listing.longitude, listing.latitude],
[homeAddress.coords.lng, homeAddress.coords.lat],
],
},
},
{
type: 'Feature',
geometry: {
type: 'Point',
coordinates: midpoint,
},
properties: {
distance: `${Math.round(distance)} m`,
},
},
],
});
} else {
map.current.addSource('route', {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: [
[listing.longitude, listing.latitude],
[homeAddress.coords.lng, homeAddress.coords.lat],
],
},
},
{
type: 'Feature',
geometry: {
type: 'Point',
coordinates: midpoint,
},
properties: {
distance: `${Math.round(distance)} m`,
},
},
],
},
});
map.current.addLayer({
id: 'route',
type: 'line',
source: 'route',
layout: {
'line-join': 'round',
'line-cap': 'round',
},
paint: {
'line-color': '#3FB1CE',
'line-width': 4,
'line-dasharray': [2, 1],
},
filter: ['==', '$type', 'LineString'],
});
map.current.addLayer({
id: 'route-distance',
type: 'symbol',
source: 'route',
layout: {
'text-field': ['get', 'distance'],
'text-size': 14,
'text-offset': [0, -1],
'text-allow-overlap': true,
},
paint: {
'text-color': '#ffffff',
'text-halo-color': '#3FB1CE',
'text-halo-width': 2,
},
filter: ['==', '$type', 'Point'],
});
}
};
if (map.current.isStyleLoaded()) {
drawLine();
} else {
map.current.on('load', drawLine);
}
}
return () => {
if (map.current) {
map.current.remove();
map.current = null;
}
};
}, [listing, loading, homeAddress]);
const handleWatch = async () => {
try {
await xhrPost('/api/listings/watch', { listingId: listing.id });
Toast.success(listing.isWatched === 1 ? 'Removed from Watchlist' : 'Added to Watchlist');
actions.listingsData.getListing(listingId);
} catch (e) {
console.error('Failed to operate Watchlist:', e);
Toast.error('Failed to operate Watchlist');
}
};
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
<Spin size="large" />
</div>
);
}
if (!listing) return null;
const data = [
{
key: 'Job',
value: listing.job_name,
Icon: <IconBriefcase />,
},
{
key: 'Provider',
value: listing.provider.charAt(0).toUpperCase() + listing.provider.slice(1),
Icon: <IconBriefcase />,
},
{ key: 'Price', value: `${listing.price}`, Icon: <IconCart /> },
{
key: 'Size',
value: listing.size ? `${listing.size}` : 'N/A',
Icon: <IconRealSize />,
},
{
key: 'Added',
value: timeService.format(listing.created_at),
Icon: <IconClock />,
},
];
return (
<div className="listing-detail">
<div className="listing-detail__back">
<Button icon={<IconArrowLeft />} onClick={() => navigate(-1)} theme="borderless">
Back
</Button>
</div>
<Card className="listing-detail__card">
<div className="listing-detail__header">
<Space vertical align="start" spacing="tight">
<Title heading={2} className="listing-detail__title">
{listing.title}
</Title>
<Space align="center">
<IconMapPin style={{ fontSize: '18px', color: 'var(--semi-color-primary)' }} />
<Text type="secondary">{listing.address || 'No address provided'}</Text>
</Space>
</Space>
<Space wrap className="listing-detail__header-actions">
<Button
icon={
listing.isWatched === 1 ? (
<IconStar style={{ color: 'var(--semi-color-warning)' }} />
) : (
<IconStarStroked />
)
}
onClick={handleWatch}
theme="light"
>
{listing.isWatched === 1 ? 'Watched' : 'Watch'}
</Button>
<Text link={{ href: listing.link }} icon={<IconLink />} underline>
Open listing
</Text>
</Space>
</div>
<Row>
<Col span={24} lg={12}>
<div className="listing-detail__image-container">
<Image src={listing.image_url || no_image} fallback={no_image} preview={true} />
</div>
</Col>
<Col span={24} lg={12}>
<div className="listing-detail__info-section">
<Title heading={4} style={{ marginBottom: '1rem' }}>
Details
</Title>
<Descriptions column={1}>
{data.map((item, index) => (
<Descriptions.Item key={index}>
<Space>
{item.Icon}
{item.value}
</Space>
</Descriptions.Item>
))}
</Descriptions>
<Divider margin="1.5rem" />
<Title heading={4} style={{ marginBottom: '1rem' }}>
Description
</Title>
<Text type="secondary" style={{ whiteSpace: 'pre-wrap' }}>
{listing.description || 'No description available.'}
</Text>
{listing.distance_to_destination && (
<>
<Divider margin="1.5rem" />
<Space align="center">
<IconActivity style={{ fontSize: '18px', color: 'var(--semi-color-primary)' }} />
<Text strong>Distance to home:</Text>
<Tag color="blue">{listing.distance_to_destination} m</Tag>
</Space>
</>
)}
</div>
</Col>
</Row>
</Card>
<div className="listing-detail__map-wrapper">
<Title heading={3}>Location</Title>
{!hasGeo ? (
<Banner
type="warning"
bordered
description="This listing has no valid geocoordinates, so we cannot show it on the map."
style={{ marginTop: '1rem' }}
/>
) : (
<div ref={mapContainer} className="listing-detail__map-container" />
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,110 @@
.listing-detail {
padding-bottom: 2rem;
&__back {
margin-bottom: 1.5rem;
}
&__card {
background-color: rgba(36, 36, 36, 0.9);
backdrop-filter: blur(8px);
border: 1px solid var(--semi-color-border);
margin-bottom: 2rem;
overflow: hidden;
.semi-card-body {
padding: 0;
}
}
&__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 1.5rem;
gap: 1rem;
border-bottom: 1px solid var(--semi-color-border);
@media (max-width: 768px) {
flex-direction: column;
align-items: stretch;
& > .semi-space {
width: 100%;
}
&-actions {
width: 100%;
justify-content: flex-start;
margin-top: 0.5rem;
.semi-button {
flex: 1;
}
}
}
}
&__title {
margin: 0 !important;
word-break: break-word;
@media (max-width: 768px) {
font-size: 1.5rem;
}
}
&__image-container {
width: 100%;
height: 400px;
background-color: var(--semi-color-fill-0);
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
@media (max-width: 768px) {
height: 250px;
}
img {
width: 100%;
height: 100%;
object-fit: contain;
}
}
&__info-section {
padding: 1.5rem;
}
&__map-container {
height: 400px;
width: 100%;
border-radius: var(--semi-border-radius-medium);
margin-top: 1rem;
border: 1px solid var(--semi-color-border);
@media (max-width: 768px) {
height: 300px;
}
}
&__map-wrapper {
margin-top: 2rem;
margin-bottom: 3rem;
}
.info-tag {
font-size: 0.9rem;
padding: 0.2rem 0.6rem;
}
}
.listing-detail-popup {
.map-popup-content {
padding: 5px;
h4 {
margin: 5px 0;
}
}
}

View File

@@ -4,15 +4,21 @@
*/
import React, { useEffect, useRef, useState } from 'react';
import { renderToString } from 'react-dom/server';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import { useSelector, useActions } from '../../services/state/store.js';
import { Select, Space, Typography, Button, Popover, Divider, Switch, Banner } from '@douyinfe/semi-ui-19';
import { IconFilter } from '@douyinfe/semi-icons';
import { distanceMeters, generateCircleCoords, getBoundsFromCenter, getBoundsFromCoords } from './mapUtils.js';
import { Select, Space, Typography, Button, Popover, Divider, Switch, Banner, Toast } from '@douyinfe/semi-ui-19';
import { IconFilter, IconLink } from '@douyinfe/semi-icons';
import { IconDelete, IconEyeOpened } from '@douyinfe/semi-icons';
import no_image from '../../assets/no_image.jpg';
import RangeSlider from 'react-range-slider-input';
import 'react-range-slider-input/dist/style.css';
import './Map.less';
import { xhrDelete } from '../../services/xhr.js';
import { Link, useNavigate } from 'react-router-dom';
const { Text } = Typography;
@@ -65,8 +71,11 @@ export default function MapView() {
const mapContainer = useRef(null);
const map = useRef(null);
const markers = useRef([]);
const homeMarker = useRef(null);
const actions = useActions();
const navigate = useNavigate();
const listings = useSelector((state) => state.listingsData.mapListings);
const homeAddress = useSelector((state) => state.userSettings.settings.home_address);
const [style, setStyle] = useState('STANDARD');
const [show3dBuildings, setShow3dBuildings] = useState(false);
@@ -74,6 +83,7 @@ export default function MapView() {
const [jobId, setJobId] = useState(null);
const [priceRange, setPriceRange] = useState([0, 0]);
const [showFilterBar, setShowFilterBar] = useState(false);
const [distanceFilter, setDistanceFilter] = useState(0);
useEffect(() => {
setPriceRange([0, getMaxPrice()]);
@@ -93,6 +103,27 @@ export default function MapView() {
return listings.filter((listing) => listing.price && listing.price >= min && listing.price <= max);
};
useEffect(() => {
window.deleteListing = async (id) => {
try {
await xhrDelete('/api/listings/', { ids: [id] });
Toast.success('Listing successfully removed');
fetchListings();
} catch (error) {
Toast.error(error.message || 'Error deleting listing');
}
};
window.viewDetails = (id) => {
navigate(`/listings/listing/${id}`);
};
return () => {
delete window.deleteListing;
delete window.viewDetails;
};
}, [navigate]);
useEffect(() => {
if (map.current) return;
@@ -150,6 +181,7 @@ export default function MapView() {
if (!map.current) return;
const add3dLayer = () => {
if (!map.current || !map.current.isStyleLoaded()) return;
if (show3dBuildings) {
if (!map.current.getSource('openfreemap')) {
map.current.addSource('openfreemap', {
@@ -201,11 +233,7 @@ export default function MapView() {
}
};
if (map.current.isStyleLoaded()) {
add3dLayer();
} else {
map.current.once('styledata', add3dLayer);
}
add3dLayer();
}, [show3dBuildings, style]);
const setMapStyle = (value) => {
@@ -225,12 +253,118 @@ export default function MapView() {
fetchListings();
}, [jobId]);
useEffect(() => {
if (!map.current) return;
if (homeAddress?.coords) {
// We only want to zoom/fly when distanceFilter OR homeAddress actually change,
// not on every render. useEffect dependency array handles this.
if (distanceFilter > 0) {
const bounds = getBoundsFromCenter([homeAddress.coords.lng, homeAddress.coords.lat], distanceFilter);
map.current.fitBounds(bounds, {
padding: 20,
maxZoom: 15,
duration: 1000,
});
} else {
map.current.flyTo({
center: [homeAddress.coords.lng, homeAddress.coords.lat],
zoom: 12,
duration: 1000,
});
}
} else {
const filtered = filterListings();
const coords = filtered
.filter((l) => l.latitude != null && l.longitude != null && l.latitude !== -1 && l.longitude !== -1)
.map((l) => [l.longitude, l.latitude]);
if (coords.length > 0) {
const bounds = getBoundsFromCoords(coords);
map.current.fitBounds(bounds, {
padding: 50,
maxZoom: 15,
duration: 1000,
});
}
}
}, [homeAddress?.address, distanceFilter, listings]);
useEffect(() => {
if (!map.current) return;
markers.current.forEach((marker) => marker.remove());
markers.current = [];
if (homeMarker.current) {
homeMarker.current.remove();
homeMarker.current = null;
}
if (homeAddress?.coords) {
homeMarker.current = new maplibregl.Marker({ color: 'red' })
.setLngLat([homeAddress.coords.lng, homeAddress.coords.lat])
.setPopup(
new maplibregl.Popup({ offset: 25 }).setHTML(
`<div class="map-popup-content"><h4>Home Address</h4><p>${homeAddress.address}</p></div>`,
),
)
.addTo(map.current);
}
const addCircleLayer = () => {
if (!map.current || !map.current.isStyleLoaded()) return;
if (map.current.getLayer('distance-circle')) map.current.removeLayer('distance-circle');
if (map.current.getLayer('distance-circle-outline')) map.current.removeLayer('distance-circle-outline');
if (map.current.getSource('distance-circle-source')) map.current.removeSource('distance-circle-source');
if (distanceFilter > 0 && homeAddress?.coords) {
const ret = generateCircleCoords([homeAddress.coords.lng, homeAddress.coords.lat], distanceFilter);
map.current.addSource('distance-circle-source', {
type: 'geojson',
data: {
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [ret],
},
},
});
map.current.addLayer({
id: 'distance-circle',
type: 'fill',
source: 'distance-circle-source',
paint: {
'fill-color': '#90EE90',
'fill-opacity': 0.3,
},
});
map.current.addLayer({
id: 'distance-circle-outline',
type: 'line',
source: 'distance-circle-source',
paint: {
'line-color': '#006400',
'line-width': 1,
},
});
}
};
const updateLayers = () => {
addCircleLayer();
};
if (map.current.isStyleLoaded()) {
updateLayers();
} else {
map.current.on('load', updateLayers);
}
filterListings().forEach((listing) => {
if (
listing.latitude != null &&
@@ -242,8 +376,8 @@ export default function MapView() {
? listing.provider.charAt(0).toUpperCase() + listing.provider.slice(1)
: 'N/A';
const popup = new maplibregl.Popup({ offset: 25 }).setHTML(
`<div class="map-popup-content">
const popupContent = `
<div class="map-popup-content">
<img src="${listing.image_url || no_image}" alt="${listing.title}" />
<h4>${listing.title}</h4>
<div class="info">
@@ -251,12 +385,47 @@ export default function MapView() {
<span><strong>Address:</strong> ${listing.address || 'N/A'}</span>
<span><strong>Job:</strong> ${listing.job_name || 'N/A'}</span>
<span><strong>Provider:</strong> ${capitalizedProvider}</span>
<a href="${listing.link}" target="_blank" rel="noopener noreferrer">View Listing</a>
<span><strong>Size:</strong> ${listing.size != null ? `${listing.size}` : 'N/A'}</span>
<div style="display: flex; gap: 8px; margin-top: 8px; justify-content: space-between;">
<div class="map-popup-content__linkButton">
<a href="${listing.link}" target="_blank" rel="noopener noreferrer">
${renderToString(<IconLink />)}
</a>
</div>
<button
class="map-popup-content__detailsButton"
title="View Details"
onclick="viewDetails('${listing.id}')"
>
${renderToString(<IconEyeOpened />)}
</button>
<button
class="map-popup-content__deleteButton"
title="Remove"
onclick="deleteListing('${listing.id}')"
>
${renderToString(<IconDelete />)}
</button>
</div>
</div>
</div>`,
);
</div>`;
const marker = new maplibregl.Marker()
const popup = new maplibregl.Popup({ offset: 25 }).setHTML(popupContent);
let color = '#3FB1CE'; // Default blue-ish
if (distanceFilter > 0 && homeAddress?.coords) {
const dist = distanceMeters(
homeAddress.coords.lat,
homeAddress.coords.lng,
listing.latitude,
listing.longitude,
);
if (dist <= distanceFilter * 1000) {
color = 'orange';
}
}
const marker = new maplibregl.Marker({ color })
.setLngLat([listing.longitude, listing.latitude])
.setPopup(popup)
.addTo(map.current);
@@ -264,7 +433,7 @@ export default function MapView() {
markers.current.push(marker);
}
});
}, [listings, priceRange]);
}, [listings, priceRange, homeAddress, distanceFilter]);
return (
<div className="map-view-container">
@@ -318,6 +487,29 @@ export default function MapView() {
</div>
</div>
<Divider layout="vertical" />
<div className="listingsGrid__toolbar__card">
<div>
<Text strong>Distance:</Text>
</div>
<div style={{ display: 'flex', gap: '.3rem', alignItems: 'center' }}>
<Select
placeholder="Distance"
style={{ width: 100 }}
onChange={(val) => {
setDistanceFilter(val);
}}
value={distanceFilter}
>
<Select.Option value={0}>---</Select.Option>
<Select.Option value={5}>5 km</Select.Option>
<Select.Option value={10}>10 km</Select.Option>
<Select.Option value={15}>15 km</Select.Option>
<Select.Option value={20}>20 km</Select.Option>
<Select.Option value={25}>25 km</Select.Option>
</Select>
</div>
</div>
<Divider layout="vertical" />
<div className="listingsGrid__toolbar__card">
<div>
<Text strong>Price Range ():</Text>
@@ -343,6 +535,21 @@ export default function MapView() {
</div>
)}
{!homeAddress && (
<Banner
fullMode={true}
type="warning"
bordered
closeIcon={null}
description={
<span>
You have not set your home address yet. Please do so in the <Link to="/userSettings">user settings</Link>{' '}
to use the distance filter.
</span>
}
/>
)}
<Banner
fullMode={true}
type="info"

View File

@@ -6,8 +6,9 @@
.map-view-container {
display: flex;
flex-direction: column;
height: calc(100vh - 120px); /* Adjust based on header/footer height */
padding: 1rem;
height: 100%;
padding: 0;
box-sizing: border-box;
}
.map-filter-bar {
@@ -43,10 +44,85 @@
}
.info {
font-size: 0.9rem;
font-size: 0.8rem;
display: flex;
flex-direction: column;
gap: 0.2rem;
gap: 0.1rem;
}
&__linkButton {
background: var(--semi-color-primary);
color: white;
font-size: 14px;
line-height: 20px;
font-weight: 600;
height: 32px;
width: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
a {
color: white;
display: flex;
align-items: center;
justify-content: center;
text-decoration: none;
width: 100%;
height: 100%;
}
&:hover {
background: var(--semi-color-primary-hover);
cursor: pointer;
}
}
&__detailsButton {
background: var(--semi-color-tertiary);
color: white;
border: none;
font-size: 14px;
height: 32px;
width: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
cursor: pointer;
padding: 0;
&:hover {
background: var(--semi-color-tertiary-hover);
}
svg {
fill: currentColor;
}
}
&__deleteButton {
background: var(--semi-color-danger);
color: white;
border: none;
font-size: 14px;
height: 32px;
width: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
cursor: pointer;
padding: 0;
&:hover {
background: var(--semi-color-danger-hover);
}
svg {
fill: currentColor;
}
}
}

View File

@@ -0,0 +1,130 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
/**
* Calculates the great-circle distance between two points on a sphere using the Haversine formula.
*
* I'm using the Haversine formula here because it accounts for the Earth's curvature.
* By calculating the central angle (c) between two points and multiplying it by the Earth's radius (R ≈ 6371km),
* we get a pretty accurate straight-line distance. It's basically some trigonometry involving
* sines and cosines of the latitudes and longitudes to find the chord length (a) first.
*
* @param {number} lat1 - Latitude of the first point
* @param {number} lon1 - Longitude of the first point
* @param {number} lat2 - Latitude of the second point
* @param {number} lon2 - Longitude of the second point
* @returns {number} Distance in meters, rounded to one decimal place
*/
export const distanceMeters = (lat1, lon1, lat2, lon2) => {
const R = 6371000;
const toRad = (deg) => (deg * Math.PI) / 180;
const phi1 = toRad(lat1);
const phi2 = toRad(lat2);
const dPhi = toRad(lat2 - lat1);
const dLambda = toRad(lon2 - lon1);
const a =
Math.sin(dPhi / 2) * Math.sin(dPhi / 2) +
Math.cos(phi1) * Math.cos(phi2) * Math.sin(dLambda / 2) * Math.sin(dLambda / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return Math.round(R * c * 10) / 10;
};
/**
* Generates an array of coordinates representing a circle on a map.
*
* To get this circle right, I'm approximating it with a polygon of 64 points.
* Since the Earth isn't flat, I have to adjust the longitude distance based on the latitude
* using the cosine of the latitude. The formula for the points is basically:
* x = center_lon + radius_lon * cos(theta)
* y = center_lat + radius_lat * sin(theta)
* where theta ranges from 0 to 2π. This handles the slight "squishing" of distances as you move away from the equator.
*
* @param {number[]} center - [longitude, latitude] of the center
* @param {number} radiusInKm - Radius of the circle in kilometers
* @param {number} [points=64] - Number of points to generate for the polygon
* @returns {number[][]} Array of [longitude, latitude] coordinates
*/
export const generateCircleCoords = (center, radiusInKm, points = 64) => {
const [longitude, latitude] = center;
const coords = [];
// 1 degree of latitude is roughly 110.574 km
// 1 degree of longitude is roughly 111.32 km * cos(latitude)
const distanceX = radiusInKm / (111.32 * Math.cos((latitude * Math.PI) / 180));
const distanceY = radiusInKm / 110.574;
for (let i = 0; i < points; i++) {
const theta = (i / points) * (2 * Math.PI);
const x = distanceX * Math.cos(theta);
const y = distanceY * Math.sin(theta);
coords.push([longitude + x, latitude + y]);
}
// Close the polygon
coords.push(coords[0]);
return coords;
};
/**
* Calculates the bounding box for a given center and radius.
*
* I'm calculating the bounds by offsetting the center coordinates by the radius.
* Again, using the 110.574 km per degree latitude and the cosine-adjusted longitude
* to make sure the bounds actually contain the circle, even at our latitudes.
* I've added a bit of padding (15% by default) to make sure everything fits nicely on the screen.
*
* @param {number[]} center - [longitude, latitude] of the center
* @param {number} radiusInKm - Radius in kilometers
* @param {number} [padding=0.15] - Percentage of padding to add
* @returns {number[][]} Bounding box coordinates [[minLon, minLat], [maxLon, maxLat]]
*/
export const getBoundsFromCenter = (center, radiusInKm, padding = 0.15) => {
const [lng, lat] = center;
const kmInDegLat = 1 / 110.574;
const kmInDegLng = 1 / (111.32 * Math.cos((lat * Math.PI) / 180));
const offsetLng = radiusInKm * kmInDegLng * (1 + padding);
const offsetLat = radiusInKm * kmInDegLat * (1 + padding);
return [
[lng - offsetLng, lat - offsetLat],
[lng + offsetLng, lat + offsetLat],
];
};
/**
* Calculates the bounding box for a set of coordinates.
*
* @param {number[][]} coords - Array of [longitude, latitude] coordinates
* @param {number} [padding=0.1] - Padding to add to the bounds
* @returns {number[][]} Bounding box coordinates [[minLon, minLat], [maxLon, maxLat]]
*/
export const getBoundsFromCoords = (coords, padding = 0.1) => {
if (!coords || coords.length === 0) return null;
let minLng = Infinity;
let minLat = Infinity;
let maxLng = -Infinity;
let maxLat = -Infinity;
coords.forEach(([lng, lat]) => {
if (lng < minLng) minLng = lng;
if (lng > maxLng) maxLng = lng;
if (lat < minLat) minLat = lat;
if (lat > maxLat) maxLat = lat;
});
const lngDiff = maxLng - minLng;
const latDiff = maxLat - minLat;
return [
[minLng - lngDiff * padding, minLat - latDiff * padding],
[maxLng + lngDiff * padding, maxLat + latDiff * padding],
];
};

View File

@@ -58,40 +58,46 @@ export default function Login() {
return (
<div className="login">
<div className="login__bgImage" style={{ background: `url("${cityBackground}")` }} />
<Logo />
<form>
<div className="login__loginWrapper">
{error && <Banner type="danger" closeIcon={null} description={error} />}
<Input
size="large"
prefix={<IconUser />}
placeholder="Username"
value={username}
showClear
autoFocus
onChange={(value) => setUserName(value)}
onKeyPress={async (e) => {
if (e.key === 'Enter') {
await tryLogin();
}
}}
/>
<div className="login__loginWrapper">
<div className="login__logoWrapper">
<Logo width={250} white />
</div>
<form onSubmit={(e) => e.preventDefault()}>
{error && <Banner type="danger" closeIcon={null} description={error} style={{ marginBottom: '1rem' }} />}
<div className="login__inputGroup">
<Input
size="large"
prefix={<IconUser />}
placeholder="Username"
value={username}
showClear
autoFocus
onChange={(value) => setUserName(value)}
onKeyPress={async (e) => {
if (e.key === 'Enter') {
await tryLogin();
}
}}
/>
</div>
<Input
size="large"
mode="password"
prefix={<IconLock />}
value={password}
placeholder="Password"
onChange={(value) => setPassword(value)}
onKeyPress={async (e) => {
if (e.key === 'Enter') {
await tryLogin();
}
}}
/>
<div className="login__inputGroup">
<Input
size="large"
mode="password"
prefix={<IconLock />}
value={password}
placeholder="Password"
onChange={(value) => setPassword(value)}
onKeyPress={async (e) => {
if (e.key === 'Enter') {
await tryLogin();
}
}}
/>
</div>
<Button type="primary" onClick={tryLogin} theme="solid" style={{ marginTop: '1rem' }}>
<Button block type="primary" onClick={tryLogin} theme="solid" style={{ marginTop: '1rem' }}>
Login
</Button>
@@ -102,10 +108,11 @@ export default function Login() {
bordered
closeIcon={null}
description="This is the demo version of Fredy. Use 'demo' as both the username and password to log in."
style={{ marginTop: '1.5rem' }}
/>
)}
</div>
</form>
</form>
</div>
</div>
);
}

View File

@@ -4,32 +4,80 @@
align-items: center;
width: 100%;
height: 100%;
overflow: hidden;
position: relative;
&__bgImage {
background-size: cover;
filter: blur(8px);
-webkit-filter: blur(8px);
background-position: center;
filter: blur(10px) brightness(0.4);
-webkit-filter: blur(10px) brightness(0.4);
position: absolute;
top: 0;
left: 0;
top: -20px;
left: -20px;
right: -20px;
bottom: -20px;
z-index: 0;
right: 0;
bottom: 0;
}
&__loginWrapper {
border: 1px solid #555050;
border-radius: 30px;
position: relative;
z-index: 1;
background-color: #151313ab;
background: rgba(20, 20, 25, 0.7);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 24px;
padding: 3rem;
width: 90%;
max-width: 420px;
display: flex;
flex-direction: column;
padding: 2rem;
gap: 1rem;
align-items: center;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.7);
}
&__logoWrapper {
margin-bottom: 2.5rem;
display: flex;
justify-content: center;
width: 100%;
.logo {
position: static !important;
max-width: 100%;
height: auto;
}
}
form {
z-index: 1;
width: 100%;
display: flex;
flex-direction: column;
gap: 1.2rem;
}
&__inputGroup {
width: 100%;
}
// Mobile responsiveness
@media (max-width: 480px) {
&__loginWrapper {
padding: 2rem 1.5rem;
width: 95%;
border-radius: 20px;
background: rgba(20, 20, 25, 0.85);
&::after {
opacity: 0.2;
filter: blur(10px);
}
}
&__logoWrapper {
margin-bottom: 1.5rem;
}
}
}

View File

@@ -6,6 +6,7 @@
import React, { useEffect, useState, useMemo } from 'react';
import { Divider, Button, AutoComplete, Toast, Typography, Banner } from '@douyinfe/semi-ui-19';
import { IconSave, IconHome } from '@douyinfe/semi-icons';
import { useSelector, useActions } from '../../services/state/store';
import { xhrGet, xhrPost } from '../../services/xhr';
import { SegmentPart } from '../../components/segment/SegmentPart';
import debounce from 'lodash/debounce';
@@ -13,37 +14,25 @@ import debounce from 'lodash/debounce';
const { Title } = Typography;
const UserSettings = () => {
const [address, setAddress] = useState('');
const [coords, setCoords] = useState(null);
const [loading, setLoading] = useState(true);
const actions = useActions();
const homeAddress = useSelector((state) => state.userSettings.settings.home_address);
const [address, setAddress] = useState(homeAddress?.address || '');
const [coords, setCoords] = useState(homeAddress?.coords || null);
const [saving, setSaving] = useState(false);
const [dataSource, setDataSource] = useState([]);
useEffect(() => {
fetchUserSettings();
}, []);
const fetchUserSettings = async () => {
try {
const response = await xhrGet('/api/user/settings');
if (response.status === 200) {
const homeAddress = response.json.home_address;
setAddress(homeAddress?.address || '');
setCoords(homeAddress?.coords || null);
}
} catch {
Toast.error('Failed to fetch user settings');
} finally {
setLoading(false);
}
};
setAddress(homeAddress?.address || '');
setCoords(homeAddress?.coords || null);
}, [homeAddress]);
const handleSave = async () => {
setSaving(true);
try {
const response = await xhrPost('/api/user/settings', { home_address: address });
const response = await xhrPost('/api/user/settings/home-address', { home_address: address });
if (response.status === 200) {
setCoords(response.json.coords);
await actions.userSettings.getUserSettings();
Toast.success('Settings saved successfully');
} else {
Toast.error(response.json.error || 'Failed to save settings');
@@ -79,10 +68,6 @@ const UserSettings = () => {
debouncedSearch(value);
};
if (loading) {
return null;
}
return (
<div className="user-settings">
<Title heading={2}>User Specific Settings</Title>
@@ -96,6 +81,7 @@ const UserSettings = () => {
<AutoComplete
data={dataSource}
value={address}
showClear
onChange={(v) => setAddress(v)}
onSearch={searchAddress}
placeholder="Enter your home address"

570
yarn.lock

File diff suppressed because it is too large Load Diff