Compare commits

..

39 Commits

Author SHA1 Message Date
orangecoding
c132e64437 adding poi for new multilingual support & add news for the new feature 2026-06-04 10:50:45 +02:00
orangecoding
1dcb852ea1 fredy goes multilingual 🇩🇪 🇺🇸 2026-06-04 10:35:42 +02:00
orangecoding
019b9ac87b next release version 2026-06-03 17:36:40 +02:00
Ramin
0d23d43e79 fix: multiple small style fixes/improvements (#316)
* fix(ui-nav): use selected ring and remove item margin override

* fix(listings): format prices as 1.234,50 € with tabular numerals

* fix(listings): align watchlist star button styles

* fix(version-banner): set margin-bottom to 16px

* fix(ui-nav): keep selected ring with focus reset

* fix(listings): right-align price values

* fix(listings): remove cart icon from price display

* fix(listings): format detail price with shared formatter

* revert(listings): restore grid tile layout from master

Drop watchlist/actions restyle on grid cards.
2026-06-03 17:34:37 +02:00
orangecoding
324afee483 next release version 2026-06-03 10:20:04 +02:00
orangecoding
e95ebb9624 more housekeeping 2026-06-03 10:19:50 +02:00
orangecoding
c29387c85d housekeeping 2026-06-03 09:59:32 +02:00
orangecoding
322ae199b0 allowing multiple chat id's for telegram 2026-06-03 09:46:56 +02:00
orangecoding
b3300169fa merged branch 2026-06-03 08:13:49 +02:00
orangecoding
9296bcdc86 fixing 'open in fredy' link 2026-06-03 08:12:30 +02:00
Christian Kellner
44edf47393 Improved Listing Management (#317)
* adding ability to tag listings eg if you have applied to it / adding ability to add notes to a listing

* storing the date when a status was set
2026-06-02 21:10:08 +02:00
orangecoding
bbebc2a1a2 storing the date when a status was set 2026-06-02 21:09:35 +02:00
orangecoding
d2978c14db merged master 2026-06-02 20:41:43 +02:00
orangecoding
5ceac25aa6 fixing #319 & #318 2026-06-02 20:11:43 +02:00
orangecoding
34b68e1f52 fixing notification adapter 2026-06-02 19:53:06 +02:00
orangecoding
696ae451d3 fixing tooltip issues 2026-06-02 12:57:12 +02:00
orangecoding
317ef79336 adding ability to tag listings eg if you have applied to it / adding ability to add notes to a listing 2026-06-02 12:48:01 +02:00
Christian Kellner
6428e7ad78 Ghost commit for rebuilding due to ci interruption 2026-06-02 11:02:05 +02:00
orangecoding
2bcec04d55 next release version 2026-06-02 10:56:05 +02:00
orangecoding
ee2112a24d fixing tests harder 2026-06-02 10:55:16 +02:00
orangecoding
5a54448288 fixing tests 2026-06-02 10:49:06 +02:00
Ramin
f1b8709ab7 feat: remember listing delete preference (#314)
* feat: remember listing delete preference

Persist soft/hard choice and skip-confirm in user settings.
2026-06-02 10:23:45 +02:00
orangecoding
b56e13aa16 upgrading dependencies 2026-06-02 09:26:46 +02:00
Christian Kellner
a834abc31c fixing filtering of lists (#311)
* fixing listing filtering by applying the correct id
2026-06-02 09:24:45 +02:00
Ramin
573868eccb feat(ui): zoom map to saved area when editing a job (#313)
Fit the job edit map to existing polygon areas on init.
2026-06-02 08:54:56 +02:00
orangecoding
1a210d7c1c poi for proxies 2026-05-25 11:54:49 +02:00
orangecoding
996b841cfb adding ability to add proxies for cloak 2026-05-24 20:49:27 +02:00
orangecoding
b2e294e38c next release version 2026-05-21 21:46:13 +02:00
orangecoding
8afeaa05d9 fixing cloakbrowser connection issue 2026-05-21 21:45:57 +02:00
orangecoding
ec47137b89 upgrading dependencies 2026-05-21 21:40:35 +02:00
orangecoding
33161de087 upgrading dependencies 2026-05-19 09:15:12 +02:00
Stephan
acab23207e fix: kleinanzige listing might have a different structure when it was with no image created (#308) 2026-05-16 14:16:31 +02:00
orangecoding
2896d531e4 upgrading pois 2026-05-13 08:14:57 +02:00
orangecoding
0cbfa25062 upgrading pois 2026-05-12 19:28:58 +02:00
orangecoding
bcd3042026 fixing error messages not being shown properly in user table 2026-05-12 13:24:13 +02:00
orangecoding
0ce93acaf6 more demo fixes 2026-05-12 13:12:26 +02:00
orangecoding
cabef973a2 forbid backuo/restore in demo mode 2026-05-12 12:42:25 +02:00
orangecoding
3d0fa87d19 upgrading dependencies 2026-05-12 09:23:52 +02:00
orangecoding
8b012ef2f1 upgrading dependencies / new pois 2026-05-11 09:18:32 +02:00
103 changed files with 5587 additions and 2428 deletions

View File

@@ -23,7 +23,7 @@
# Fredy 🏡 Your Self-Hosted Real Estate Finder for Germany
# Fredy 🏡 - Your Self-Hosted Real Estate Finder for Germany
Finding an apartment or house in Germany can be stressful and
time-consuming.\
@@ -167,6 +167,40 @@ For more information on how to set it up and use it, please refer to the [MCP Re
Immoscout has implemented advanced bot detection. In order to work around this, we are using a reversed engineered version of their mobile api. See [Immoscout Reverse Engineering Documentation](https://github.com/orangecoding/fredy/blob/master/reverse-engineered-immoscout.md)
## 🛡️ Bot Detection & Proxies
Most browser-based providers (immowelt, immonet, kleinanzeigen, ...) are scraped through a hardened headless browser ([CloakBrowser](https://www.npmjs.com/package/cloakbrowser)). It makes the **browser fingerprint** indistinguishable from a real Chrome, which is enough when you run Fredy on a normal home connection.
On a **server / VPS the requests usually originate from a datacenter IP**, and providers behind anti-bot systems (e.g. AWS CloudFront/WAF) block those based on **IP reputation alone**, no matter how perfect the fingerprint is. The typical symptom: it works locally but you get `We have been detected as a bot :-/` on the server.
### The fix: a residential proxy
A **residential proxy** routes Fredy's browser through the internet connection of a real household, so the provider sees a "normal user" IP instead of a datacenter. For German portals, use a **German (DE) residential** (or mobile/4G) proxy. Plain VPNs and **datacenter proxies do not help** here, they share the same bad reputation as your server.
**Configure it** under **Settings → Execution → Proxy URL**. Supported formats:
```
http://user:pass@host:port
socks5://user:pass@host:port
```
Leave the field empty to disable. The proxy applies to all headless-browser providers and takes effect on the next job run (no restart needed). Immoscout uses a separate mobile API and is not affected.
### Where to get a residential proxy
Residential proxies are a paid service (usually billed per GB, Fredy's traffic is small). Well-known providers offering German residential IPs include:
| Provider | Notes |
|---|---|
| [IPRoyal](https://iproyal.com) | Pay-as-you-go, no monthly minimum, good for low volume |
| [Webshare](https://www.webshare.io) | Cheap entry tier, has a small free plan to test with |
| [Decodo (formerly Smartproxy)](https://decodo.com) | Easy setup, country/city targeting |
| [SOAX](https://soax.com) | Residential + mobile, fine-grained geo-targeting |
| [Bright Data](https://brightdata.com) | Largest pool, most features, higher complexity/price |
| [Oxylabs](https://oxylabs.io) | Enterprise-grade, larger plans |
This is not an endorsement, pick whatever fits your budget. For low-volume use like Fredy, a pay-as-you-go plan (e.g. IPRoyal) or a cheap entry tier (e.g. Webshare) is usually plenty. Make sure to select **Germany** as the proxy location and keep the search interval reasonable (the higher the interval, the less you look like a bot).
## Analytics
Fredy is completely free (and will always remain free). However, it would be a huge help if youd allow me to collect some analytical data.
@@ -206,6 +240,37 @@ If you have to refresh the fixtures (every once in a while needed because the pr
yarn run download-fixtures
```
## Adding a new language
Fredy's UI is fully multilingual. Translation files live in `ui/src/locales/`. To add a new language, create a single JSON file there, no code changes required.
**Example: `ui/src/locales/fr.json`**
```json
{
"_meta": {
"flag": "🇫🇷",
"name": "Français",
"locale": "fr-FR",
"semiLocale": "fr"
},
"nav.dashboard": "Tableau de bord",
"common.save": "Enregistrer",
...
}
```
The `_meta` fields:
| Field | Description |
|---|---|
| `flag` | Unicode flag emoji shown in the language selector |
| `name` | Display name shown in the language selector |
| `locale` | BCP 47 locale string used for date and number formatting (e.g. `fr-FR`) |
| `semiLocale` | Semi UI locale key for component-level strings (date pickers, pagination, etc.) |
> **Important:** `semiLocale` must exactly match a locale filename from the Semi UI locale sources (without the `.js` extension). See the [available Semi UI locales on GitHub](https://github.com/DouyinFE/semi-design/tree/main/packages/semi-ui/locale/source) for the full list of supported keys.
After adding the file, rebuild the frontend (`yarn build:frontend` or restart the dev server) and the new language will appear automatically in **Settings → User Settings → Language**.
------------------------------------------------------------------------

View File

@@ -11,6 +11,8 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<title>Fredy || Real Estate Finder</title>
<link rel="icon" type="image/png" href="/ui/src/assets/heart.png" />
<link rel="apple-touch-icon" href="/ui/src/assets/heart.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />

View File

@@ -5,9 +5,10 @@
import { NoNewListingsWarning } from './errors.js';
import {
storeListings,
getKnownListingHashesForJobAndProvider,
deleteListingsById,
getKnownListingHashesForJobAndProvider,
storeListings,
updateListingDistance,
} from './services/storage/listingsStorage.js';
import { getJob } from './services/storage/jobStorage.js';
import * as notify from './notification/notify.js';
@@ -16,8 +17,7 @@ import urlModifier from './services/queryStringMutator.js';
import logger from './services/logger.js';
import { geocodeAddress } from './services/geocoding/geoCodingService.js';
import { distanceMeters } from './services/listings/distanceCalculator.js';
import { getUserSettings, getSettings } from './services/storage/settingsStorage.js';
import { updateListingDistance } from './services/storage/listingsStorage.js';
import { getSettings, getUserSettings } from './services/storage/settingsStorage.js';
import booleanPointInPolygon from '@turf/boolean-point-in-polygon';
import { formatListing } from './utils/formatListing.js';
@@ -97,10 +97,10 @@ class FredyPipelineExecutioner {
}
/**
* Optionally enrich new listings with data from their detail pages.
* Optionally, enrich new listings with data from their detail pages.
* Only called when the provider config defines a `fetchDetails` function.
* Runs all fetches in parallel. Each individual fetch must handle its own errors
* and always resolve (never reject) to avoid aborting other listings.
* Fetches are performed sequentially to avoid overloading the provider or
* the shared browser instance.
*
* @param {Listing[]} newListings New listings to enrich.
* @returns {Promise<Listing[]>} Resolves with enriched listings.
@@ -132,7 +132,7 @@ class FredyPipelineExecutioner {
for (const listing of newListings) {
if (listing.address) {
const coords = await geocodeAddress(listing.address);
if (coords) {
if (coords && coords.lat !== -1 && coords.lng !== -1) {
listing.latitude = coords.lat;
listing.longitude = coords.lng;
}
@@ -199,9 +199,9 @@ class FredyPipelineExecutioner {
const toDeleteListingByIds = [];
const keptListings = newListings.filter((listing) => {
const filterOut =
(minRooms && listing.rooms && listing.rooms < minRooms) ||
(minSize && listing.size && listing.size < minSize) ||
(maxPrice && listing.price && listing.price > maxPrice);
(minRooms && listing.rooms != null && listing.rooms < minRooms) ||
(minSize && listing.size != null && listing.size < minSize) ||
(maxPrice && listing.price != null && listing.price > maxPrice);
if (filterOut) {
toDeleteListingByIds.push(listing.id);
@@ -223,24 +223,15 @@ class FredyPipelineExecutioner {
* @param {string} url The provider URL to fetch from.
* @returns {Promise<ParsedListing[]>} Resolves with an array of listings (empty when none found).
*/
_getListings(url) {
async _getListings(url) {
const extractor = new Extractor({ ...this._providerConfig.puppeteerOptions, browser: this._browser });
return new Promise((resolve, reject) => {
extractor
.execute(url, this._providerConfig.waitForSelector)
.then(() => {
const listings = extractor.parseResponseText(
this._providerConfig.crawlContainer,
this._providerConfig.crawlFields,
url,
);
resolve(listings == null ? [] : listings);
})
.catch((err) => {
reject(err);
logger.error(err);
});
});
await extractor.execute(url, this._providerConfig.waitForSelector, this._providerId);
const listings = extractor.parseResponseText(
this._providerConfig.crawlContainer,
this._providerConfig.crawlFields,
url,
);
return listings == null ? [] : listings;
}
/**
@@ -264,15 +255,15 @@ class FredyPipelineExecutioner {
const requiredKeys = this._providerConfig.requiredFieldNames;
const requireValues = ['id', 'link', 'title'];
const filteredListings = listings
// this should never filter some listings out, because the normalize function should always extract all fields.
.filter((item) => requiredKeys.every((key) => key in item))
// TODO: move blacklist filter to this file, so it will handle for all providers in same way.
.filter(this._providerConfig.filter)
// filter out listings that are missing required fields
.filter((item) => requireValues.every((key) => item[key] != null));
return filteredListings;
return (
listings
// this should never filter some listings out, because the normalize function should always extract all fields.
.filter((item) => requiredKeys.every((key) => key in item))
// TODO: move blacklist filter to this file, so it will handle for all providers in same way.
.filter(this._providerConfig.filter)
// filter out listings that are missing required fields
.filter((item) => requireValues.every((key) => item[key] != null))
);
}
/**

View File

@@ -7,4 +7,12 @@ export const TRACKING_POIS = {
DISTANCE_ADDRESS_ENTERED: 'DISTANCE_ADDRESS_ENTERED',
WELCOME_FINISHED: 'WELCOME_FINISHED',
WELCOME_SKIPPED: 'WELCOME_SKIPPED',
JOBS_TABLE_VIEW: 'JOBS_TABLE_VIEW',
LISTING_TABLE_VIEW: 'LISTING_TABLE_VIEW',
BASE_URL_SETTING: 'BASE_URL_SETTING',
SET_PROXY_SETTING: 'SET_PROXY_SETTING',
DETECTED_AS_BOT: 'DETECTED_AS_BOT',
NOTES_CREATE: 'NOTES_CREATE',
USING_LISTING_STATUS: 'USING_LISTING_STATUS',
CHANGE_LANGUAGE: 'CHANGE_LANGUAGE',
};

View File

@@ -76,13 +76,13 @@ fastify.register(async (app) => {
app.register(dashboardPlugin, { prefix: '/api/dashboard' });
app.register(userSettingsPlugin, { prefix: '/api/user/settings' });
app.register(trackingPlugin, { prefix: '/api/tracking' });
app.register(generalSettingsPlugin, { prefix: '/api/admin/generalSettings' });
});
// Admin-only routes
fastify.register(async (app) => {
app.addHook('preHandler', authHook);
app.addHook('preHandler', adminHook);
app.register(generalSettingsPlugin, { prefix: '/api/admin/generalSettings' });
app.register(backupPlugin, { prefix: '/api/admin/backup' });
app.register(userPlugin, { prefix: '/api/admin/users' });
});

View File

@@ -9,6 +9,10 @@ import {
precheckRestore,
restoreFromZip,
} from '../../services/storage/backupRestoreService.js';
import { getSettings } from '../../services/storage/settingsStorage.js';
import { isAdmin } from '../security.js';
const DEMO_MODE_ERROR = 'Backup and restore are not available in demo mode.';
/**
* @param {import('fastify').FastifyInstance} fastify
@@ -21,7 +25,11 @@ export default async function backupPlugin(fastify) {
(req, body, done) => done(null, body),
);
fastify.get('/', async (_request, reply) => {
fastify.get('/', async (request, reply) => {
const settings = await getSettings();
if (settings.demoMode && !isAdmin(request)) {
return reply.code(403).send({ error: DEMO_MODE_ERROR });
}
const zipBuffer = await createBackupZip();
const fileName = await buildBackupFileName();
reply.header('Content-Type', 'application/zip');
@@ -30,6 +38,10 @@ export default async function backupPlugin(fastify) {
});
fastify.post('/restore', async (request, reply) => {
const settings = await getSettings();
if (settings.demoMode && !isAdmin(request)) {
return reply.code(403).send({ error: DEMO_MODE_ERROR });
}
const { dryRun = 'false', force = 'false' } = request.query || {};
const doDryRun = String(dryRun) === 'true';
const doForce = String(force) === 'true';

View File

@@ -9,6 +9,8 @@ import { ensureDemoUserExists } from '../../services/storage/userStorage.js';
import logger from '../../services/logger.js';
import { getSettings, upsertSettings } from '../../services/storage/settingsStorage.js';
import { isAdmin } from '../security.js';
import { trackPoi } from '../../services/tracking/Tracker.js';
import { TRACKING_POIS } from '../../TRACKING_POIS.js';
/**
* @param {import('fastify').FastifyInstance} fastify
@@ -25,16 +27,26 @@ export default async function generalSettingsPlugin(fastify) {
}
const localSettings = await getSettings();
if (localSettings.demoMode && !isAdmin(request)) {
return reply.code(403).send({ error: 'In demo mode, it is not allowed to change these settings.' });
if (!isAdmin(request)) {
const reason = localSettings.demoMode
? 'In demo mode, it is not allowed to change these settings.'
: 'Only admins can change these settings.';
return reply.code(403).send({ error: reason });
}
try {
if (typeof sqlitepath !== 'undefined') {
fs.writeFileSync(`${getDirName()}/../conf/config.json`, JSON.stringify({ sqlitepath }));
}
upsertSettings(appSettings);
ensureDemoUserExists();
if (appSettings.baseUrl != null) {
await trackPoi(TRACKING_POIS.BASE_URL_SETTING);
}
if (appSettings.proxyUrl != null) {
await trackPoi(TRACKING_POIS.SET_PROXY_SETTING);
}
} catch (err) {
logger.error(err);
return reply.code(500).send({ error: 'Error while trying to write settings.' });

View File

@@ -195,6 +195,9 @@ export default async function jobPlugin(fastify) {
const settings = await getSettings();
try {
const job = jobStorage.getJob(jobId);
if (!job) {
return reply.code(404).send({ error: 'Job not found' });
}
if (settings.demoMode && !isAdmin(request) && job.name === DEMO_JOB_NAME) {
return reply.code(403).send({ error: 'Sorry, but you cannot remove the Demo Job ;)' });
}
@@ -216,6 +219,9 @@ export default async function jobPlugin(fastify) {
const settings = await getSettings();
try {
const job = jobStorage.getJob(jobId);
if (!job) {
return reply.code(404).send({ error: 'Job not found' });
}
if (settings.demoMode && !isAdmin(request) && job.name === DEMO_JOB_NAME) {
return reply.code(403).send({ error: 'Sorry, but you cannot change the Status of our Demo Job ;)' });

View File

@@ -8,8 +8,10 @@ import * as watchListStorage from '../../services/storage/watchListStorage.js';
import { isAdmin as isAdminFn } from '../security.js';
import logger from '../../services/logger.js';
import { nullOrEmpty } from '../../utils.js';
import { getJobs } from '../../services/storage/jobStorage.js';
import { getJob } from '../../services/storage/jobStorage.js';
import { getSettings } from '../../services/storage/settingsStorage.js';
import { trackPoi } from '../../services/tracking/Tracker.js';
import { TRACKING_POIS } from '../../TRACKING_POIS.js';
/**
* @param {import('fastify').FastifyInstance} fastify
@@ -23,6 +25,7 @@ export default async function listingsPlugin(fastify) {
jobNameFilter,
providerFilter,
watchListFilter,
statusFilter,
sortfield = null,
sortdir = 'asc',
freeTextFilter,
@@ -35,12 +38,16 @@ export default async function listingsPlugin(fastify) {
};
const normalizedActivity = toBool(activityFilter);
const normalizedWatch = toBool(watchListFilter);
const allowedStatuses = ['applied', 'rejected', 'accepted', 'none'];
const normalizedStatus =
typeof statusFilter === 'string' && allowedStatuses.includes(statusFilter.toLowerCase())
? statusFilter.toLowerCase()
: undefined;
let jobFilter = null;
let jobIdFilter = null;
const jobs = getJobs();
if (!nullOrEmpty(jobNameFilter)) {
const job = jobs.find((j) => j.id === jobNameFilter);
const job = getJob(jobNameFilter);
jobFilter = job != null ? job.name : null;
jobIdFilter = job != null ? job.id : null;
}
@@ -54,6 +61,7 @@ export default async function listingsPlugin(fastify) {
jobIdFilter: jobIdFilter,
providerFilter,
watchListFilter: normalizedWatch,
statusFilter: normalizedStatus,
sortField: sortfield || null,
sortDir: sortdir === 'desc' ? 'desc' : 'asc',
userId: request.session.currentUser,
@@ -94,6 +102,55 @@ export default async function listingsPlugin(fastify) {
return reply.send();
});
fastify.post('/:listingId/notes', async (request, reply) => {
const { listingId } = request.params || {};
const { notes } = request.body || {};
const userId = request.session?.currentUser;
if (!listingId || !userId) {
return reply.code(400).send({ message: 'listingId or user not provided' });
}
try {
const changes = listingStorage.setListingNotes(listingId, typeof notes === 'string' ? notes : null);
if (changes === 0) {
return reply.code(404).send({ message: 'Listing not found' });
}
} catch (error) {
logger.error(error);
return reply.code(500).send({ message: 'Failed to update listing notes' });
}
await trackPoi(TRACKING_POIS.NOTES_CREATE);
return reply.send();
});
fastify.post('/:listingId/status', async (request, reply) => {
const { listingId } = request.params || {};
const { status } = request.body || {};
const userId = request.session?.currentUser;
if (!listingId || !userId) {
return reply.code(400).send({ message: 'listingId or user not provided' });
}
const allowed = ['applied', 'rejected', 'accepted'];
const normalized = status == null ? null : String(status).toLowerCase();
if (normalized != null && !allowed.includes(normalized)) {
return reply.code(400).send({ message: `Invalid status: ${status}` });
}
try {
const changes = listingStorage.setListingStatus(listingId, normalized);
await trackPoi(TRACKING_POIS.USING_LISTING_STATUS);
if (changes === 0) {
return reply.code(404).send({ message: 'Listing not found' });
}
if (normalized != null) {
watchListStorage.ensureWatch(listingId, userId);
}
} catch (error) {
logger.error(error);
return reply.code(500).send({ message: 'Failed to update listing status' });
}
return reply.send();
});
fastify.delete('/job', async (request, reply) => {
const { jobId, hardDelete = false } = request.body;
const settings = await getSettings();
@@ -101,6 +158,16 @@ export default async function listingsPlugin(fastify) {
if (settings.demoMode && !isAdminFn(request)) {
return reply.code(403).send({ error: 'Sorry, but you cannot remove listings in demo mode ;)' });
}
const job = getJob(jobId);
if (!job) {
return reply.code(404).send({ error: 'Job not found' });
}
const userId = request.session.currentUser;
if (!isAdminFn(request) && job.userId !== userId && !job.shared_with_user.includes(userId)) {
return reply
.code(403)
.send({ error: 'You are trying to remove listings for a job that is not associated to your user' });
}
listingStorage.deleteListingsByJobId(jobId, hardDelete);
} catch (error) {
logger.error(error);
@@ -111,7 +178,11 @@ export default async function listingsPlugin(fastify) {
fastify.delete('/', async (request, reply) => {
const { ids, hardDelete = false } = request.body;
const settings = await getSettings();
try {
if (settings.demoMode && !isAdminFn(request)) {
return reply.code(403).send({ error: 'Sorry, but you cannot remove listings in demo mode ;)' });
}
if (Array.isArray(ids) && ids.length > 0) {
listingStorage.deleteListingsById(ids, hardDelete);
}

View File

@@ -20,6 +20,9 @@ function getClientIp(request) {
function isRateLimited(ip) {
const now = Date.now();
for (const [key, rec] of loginAttempts) {
if (now - rec.firstAttempt > LOGIN_WINDOW_MS) loginAttempts.delete(key);
}
const record = loginAttempts.get(ip);
if (!record || now - record.firstAttempt > LOGIN_WINDOW_MS) {
loginAttempts.set(ip, { count: 1, firstAttempt: now });

View File

@@ -18,7 +18,7 @@ const notificationAdapter = await Promise.all(
*/
export default async function notificationAdapterPlugin(fastify) {
fastify.get('/', async () => {
return notificationAdapter.map((adapter) => adapter.config);
return notificationAdapter.map((adapter) => adapter.config).filter(Boolean);
});
fastify.post('/try', async (request, reply) => {

View File

@@ -3,13 +3,11 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import SqliteConnection from '../../services/storage/SqliteConnection.js';
import { getSettings, upsertSettings } from '../../services/storage/settingsStorage.js';
import { getSettings, getUserSettings, upsertSettings } from '../../services/storage/settingsStorage.js';
import { isAdmin } from '../security.js';
import { resetGeocoordinatesAndDistanceForUser } from '../../services/storage/listingsStorage.js';
import { geocodeAddress } from '../../services/geocoding/geoCodingService.js';
import { autocompleteAddress } from '../../services/geocoding/autocompleteService.js';
import { fromJson } from '../../utils.js';
import { trackPoi } from '../../services/tracking/Tracker.js';
import { TRACKING_POIS } from '../../TRACKING_POIS.js';
import logger from '../../services/logger.js';
@@ -21,12 +19,7 @@ import { runGeoCordTask } from '../../services/crons/geocoding-cron.js';
export default async function userSettingsPlugin(fastify) {
fastify.get('/', async (request) => {
const userId = request.session.currentUser;
const rows = SqliteConnection.query('SELECT name, value FROM settings WHERE user_id = @userId', { userId });
const settings = {};
for (const r of rows) {
settings[r.name] = fromJson(r.value, null);
}
return settings;
return getUserSettings(userId);
});
fastify.get('/autocomplete', async (request, reply) => {
@@ -118,6 +111,10 @@ export default async function userSettingsPlugin(fastify) {
return reply.code(400).send({ error: 'listings_view_mode must be "grid" or "table".' });
}
if (listings_view_mode === 'table') {
await trackPoi(TRACKING_POIS.LISTING_TABLE_VIEW);
}
try {
upsertSettings({ listings_view_mode }, userId);
return { success: true };
@@ -135,6 +132,10 @@ export default async function userSettingsPlugin(fastify) {
return reply.code(400).send({ error: 'jobs_view_mode must be "grid" or "table".' });
}
if (jobs_view_mode === 'table') {
await trackPoi(TRACKING_POIS.JOBS_TABLE_VIEW);
}
try {
upsertSettings({ jobs_view_mode }, userId);
return { success: true };
@@ -143,4 +144,46 @@ export default async function userSettingsPlugin(fastify) {
return reply.code(500).send({ error: error.message });
}
});
fastify.post('/listing-deletion-preference', async (request, reply) => {
const userId = request.session.currentUser;
const { listing_deletion_preference } = request.body;
const globalSettings = await getSettings();
if (globalSettings.demoMode && !isAdmin(request)) {
return reply.code(403).send({ error: 'In demo mode, it is not allowed to change settings.' });
}
if (listing_deletion_preference == null) {
return reply.code(400).send({ error: 'listing_deletion_preference is required.' });
}
const { skipPrompt, hardDelete } = listing_deletion_preference;
try {
upsertSettings({ listing_deletion_preference: { skipPrompt, hardDelete } }, userId);
return { success: true };
} catch (error) {
logger.error('Error updating listing deletion preference', error);
return reply.code(500).send({ error: error.message });
}
});
fastify.post('/language', async (request, reply) => {
const userId = request.session.currentUser;
const { language } = request.body;
if (typeof language !== 'string' || language.trim() === '') {
return reply.code(400).send({ error: 'language must be a non-empty string.' });
}
try {
upsertSettings({ language }, userId);
await trackPoi(TRACKING_POIS.CHANGE_LANGUAGE);
return { success: true };
} catch (error) {
logger.error('Error updating language setting', error);
return reply.code(500).send({ error: error.message });
}
});
}

View File

@@ -155,6 +155,12 @@ export function createMcpServer() {
),
sortField: z.string().optional().describe('Sort by: created_at, price, size, provider, title, is_active'),
sortDir: z.string().optional().describe('Sort direction: asc or desc'),
status: z
.enum(['applied', 'rejected', 'accepted', 'none'])
.optional()
.describe(
'Filter by user-set status. "applied", "rejected", or "accepted" return only listings with that status; "none" returns only listings without a status set.',
),
},
async (
{
@@ -170,6 +176,7 @@ export function createMcpServer() {
maxPrice,
sortField,
sortDir,
status,
},
extra,
) => {
@@ -192,6 +199,7 @@ export function createMcpServer() {
maxPrice: maxPrice ?? null,
sortField: sortField ?? null,
sortDir: sortDir ?? 'desc',
statusFilter: status,
userId: user.id,
isAdmin: user.isAdmin,
});

View File

@@ -124,10 +124,10 @@ export function normalizeListListings(queryResult, { page, pageSize }) {
md += '\n\n';
if (listings.length > 0) {
md += `| ID | Title | Address | Price | Size | Provider | Active | Created | Job |\n`;
md += `|----|-------|---------|-------|------|----------|--------|---------|-----|\n`;
md += `| ID | Title | Address | Price | Size | Provider | Active | Status | Created | Job |\n`;
md += `|----|-------|---------|-------|------|----------|--------|--------|---------|-----|\n`;
for (const l of listings) {
md += `| ${cell(l.id)} | ${cell(l.title)} | ${cell(l.address)} | ${cell(l.price)} | ${cell(l.size)} | ${cell(l.provider)} | ${l.is_active ? 'yes' : 'no'} | ${formatDate(l.created_at)} | ${cell(l.job_name)} |\n`;
md += `| ${cell(l.id)} | ${cell(l.title)} | ${cell(l.address)} | ${cell(l.price)} | ${cell(l.size)} | ${cell(l.provider)} | ${l.is_active ? 'yes' : 'no'} | ${cell(l.status?.status)} | ${formatDate(l.created_at)} | ${cell(l.job_name)} |\n`;
}
md += `\nUse **get_listing** with an ID for full details (description, link, image).\n`;
} else {
@@ -156,6 +156,10 @@ export function normalizeGetListing(listing) {
md += `- **Link:** ${listing.link || ''}\n`;
md += `- **Image:** ${listing.image_url || ''}\n`;
md += `- **Active:** ${listing.is_active ? 'yes' : 'no'}\n`;
md += `- **Status:** ${listing.status?.status || ''}\n`;
if (listing.status?.setAt) {
md += `- **Status set at:** ${formatDate(listing.status.setAt)}\n`;
}
md += `- **Created:** ${formatDate(listing.created_at)}\n`;
md += `- **Job:** ${listing.job_name || ''}\n`;
if (listing.latitude != null && listing.longitude != null) {

View File

@@ -13,7 +13,7 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey, bas
const jobName = job == null ? jobKey : job.name;
const promises = newListings.map((newListing) => {
const title = `${jobName} at ${serviceName}: ${newListing.title}`;
const fredyLine = baseUrl && newListing.id ? `\nOpen in Fredy: ${baseUrl}/listings/listing/${newListing.id}` : '';
const fredyLine = baseUrl && newListing.id ? `\nOpen in Fredy: ${baseUrl}/#/listings/listing/${newListing.id}` : '';
const message = `Address: ${newListing.address}\nSize: ${newListing.size}\nPrice: ${newListing.price}\nLink: ${newListing.link}${fredyLine}`;
return fetch(server, {
method: 'POST',

View File

@@ -7,7 +7,7 @@ import { markdown2Html } from '../../services/markdown.js';
export const send = ({ serviceName, newListings, jobKey, baseUrl }) => {
/* eslint-disable no-console */
const fredyLinks = baseUrl ? newListings.map((l) => `${baseUrl}/listings/listing/${l.id}`).join(', ') : null;
const fredyLinks = baseUrl ? newListings.map((l) => `${baseUrl}/#/listings/listing/${l.id}`).join(', ') : null;
return [
Promise.resolve(
console.info(

View File

@@ -7,6 +7,7 @@ import fetch from 'node-fetch';
import { getJob } from '../../services/storage/jobStorage.js';
import { markdown2Html } from '../../services/markdown.js';
import { normalizeImageUrl } from '../../utils.js';
import logger from '../../services/logger.js';
/**
* Generates an idempotent decimal color code. The input string-based color code is
@@ -67,11 +68,19 @@ const buildEmbed = (jobKey, listing, baseUrl) => {
},
];
if (baseUrl && listing.id) {
fields.push({
name: 'Open in Fredy',
value: `[Open in Fredy](${baseUrl}/#/listings/listing/${listing.id})`,
inline: false,
});
}
const embed = {
title: title,
color: generateColorFromString(jobKey),
url: listing.link,
fields: fields,
fields,
};
if (listing.image) {
@@ -80,14 +89,6 @@ const buildEmbed = (jobKey, listing, baseUrl) => {
};
}
if (baseUrl && listing.id) {
fields.push({
name: 'Open in Fredy',
value: `[Open in Fredy](${baseUrl}/listings/listing/${listing.id})`,
inline: false,
});
}
return embed;
};
@@ -119,7 +120,7 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey, bas
headers: { 'Content-Type': 'application/json' },
body,
}).catch((error) => {
console.error(`Error sending Discord webhook for chunk starting at ${i}:`, error);
logger.error(`Error sending Discord webhook for chunk starting at ${i}:`, error);
return Promise.reject(new Error(`Webhook failed: ${error.message}`));
});

View File

@@ -13,7 +13,7 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey, bas
let message = `### *${jobName}* (${serviceName}) found **${newListings.length}** new listings:\n\n`;
message += `| Title | Address | Size | Price |${baseUrl ? ' Open in Fredy |' : ''}\n|:----|:----|:----|:----|${baseUrl ? ':----|\n' : '\n'}`;
message += newListings.map((o) => {
const fredyCell = baseUrl && o.id ? ` [Open in Fredy](${baseUrl}/listings/listing/${o.id}) |` : '';
const fredyCell = baseUrl && o.id ? ` [Open in Fredy](${baseUrl}/#/listings/listing/${o.id}) |` : '';
return (
`| [${o.title}](${o.link}) | ` +
[o.address, o.size.replace(/2m/g, '$m^2$'), o.price].join(' | ') +

View File

@@ -14,7 +14,7 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey, bas
const jobName = job == null ? jobKey : job.name;
const promises = newListings.map((newListing) => {
const fredyLine = baseUrl && newListing.id ? `\nOpen in Fredy: ${baseUrl}/listings/listing/${newListing.id}` : '';
const fredyLine = baseUrl && newListing.id ? `\nOpen in Fredy: ${baseUrl}/#/listings/listing/${newListing.id}` : '';
const message = `
Address: ${newListing.address}
Size: ${newListing.size == null ? 'N/A' : newListing.size.replace(/2m/g, '$m^2$')}

View File

@@ -15,7 +15,8 @@ export const send = async ({ serviceName, newListings, notificationConfig, jobKe
const results = await Promise.all(
newListings.map(async (newListing) => {
const title = `${jobName} at ${serviceName}: ${newListing.title}`;
const fredyLine = baseUrl && newListing.id ? `\nOpen in Fredy: ${baseUrl}/listings/listing/${newListing.id}` : '';
const fredyLine =
baseUrl && newListing.id ? `\nOpen in Fredy: ${baseUrl}/#/listings/listing/${newListing.id}` : '';
const message = `Address: ${newListing.address}\nSize: ${newListing.size}\nPrice: ${newListing.price}\nLink: ${newListing.link}${fredyLine}`;
const form = new FormData();

View File

@@ -39,7 +39,7 @@ const buildBlocks = (serviceName, jobKey, p, baseUrl) => {
if (baseUrl && p.id) {
blocks.push({
type: 'section',
text: { type: 'mrkdwn', text: `<${baseUrl}/listings/listing/${p.id}|Open in Fredy>` },
text: { type: 'mrkdwn', text: `<${baseUrl}/#/listings/listing/${p.id}|Open in Fredy>` },
});
}

View File

@@ -39,7 +39,7 @@ const buildBlocks = (serviceName, jobKey, p, baseUrl) => {
if (baseUrl && p.id) {
blocks.push({
type: 'section',
text: { type: 'mrkdwn', text: `<${baseUrl}/listings/listing/${p.id}|Open in Fredy>` },
text: { type: 'mrkdwn', text: `<${baseUrl}/#/listings/listing/${p.id}|Open in Fredy>` },
});
}

View File

@@ -9,43 +9,48 @@ import fetch from 'node-fetch';
import pThrottle from 'p-throttle';
import { normalizeImageUrl } from '../../utils.js';
import logger from '../../services/logger.js';
import { shouldUseMultipart, buildPhotoFormData } from './telegramPhotoUploader.js';
const RATE_LIMIT_INTERVAL = 1000;
const THROTTLE_MAX_IDLE_MS = RATE_LIMIT_INTERVAL + 2000;
const chatThrottleMap = new Map();
/**
* Removes stale throttled call entries to keep memory bounded.
* An entry is stale when no API call has fired for longer than THROTTLE_MAX_IDLE_MS.
*/
function cleanupOldThrottles() {
const now = Date.now();
const maxAge = RATE_LIMIT_INTERVAL + 1000;
const toBeDeleted = [];
for (const [chatId, chatThrottle] of chatThrottleMap.entries()) {
if (now - chatThrottle.lastUsedAt > maxAge) toBeDeleted.push(chatId);
if (now - chatThrottle.lastUsedAt > THROTTLE_MAX_IDLE_MS) chatThrottleMap.delete(chatId);
}
for (const chatId of toBeDeleted) chatThrottleMap.delete(chatId);
}
/**
* Return a throttled wrapper for a chatId to limit Telegram API calls.
* Uses p-throttle with 1 request per RATE_LIMIT_INTERVAL per chat.
* `lastUsedAt` is refreshed on every actual API call so that the idle window
* starts from the last fired call, not from when send() was invoked.
*
* @template {Function} T
* @param {string|number} chatId
* @param {T} call - async function (endpoint: string, body: any) => Promise<Response>
* @returns {T}
* @param {Function} call - async function (endpoint: string, body: any) => Promise<Response>
* @returns {Function}
*/
function getThrottled(chatId, call) {
cleanupOldThrottles();
const now = Date.now();
const chatThrottle = chatThrottleMap.get(chatId);
if (chatThrottle) {
chatThrottle.lastUsedAt = now;
return chatThrottle.throttled;
const existing = chatThrottleMap.get(chatId);
if (existing) {
existing.lastUsedAt = Date.now();
return existing.throttled;
}
const throttled = pThrottle({ limit: 1, interval: RATE_LIMIT_INTERVAL })(call);
chatThrottleMap.set(chatId, { lastUsedAt: now, throttled });
return throttled;
const entry = { lastUsedAt: Date.now(), throttled: null };
chatThrottleMap.set(chatId, entry);
entry.throttled = pThrottle({ limit: 1, interval: RATE_LIMIT_INTERVAL })(async (endpoint, body) => {
const e = chatThrottleMap.get(chatId);
if (e) e.lastUsedAt = Date.now();
return call(endpoint, body);
});
return entry.throttled;
}
/**
@@ -69,39 +74,20 @@ function escapeHtml(s = '') {
}
/**
* Build a Telegram photo caption (max 1024 characters) using HTML parse mode.
* Build a Telegram HTML-formatted message body.
* Suitable for both sendMessage (uncapped) and sendPhoto captions (caller must slice to 1024).
*
* @param {string} jobName
* @param {string} serviceName
* @param {Object} o - Listing object
* @param {string} [o.title]
* @param {string} [o.address]
* @param {string|number} [o.price]
* @param {string|number} [o.size]
* @param {string} [o.link]
* @param {string} [baseUrl]
* @returns {string}
*/
function buildCaption(jobName, serviceName, o, baseUrl) {
function buildHtmlBody(jobName, serviceName, o, baseUrl) {
const title = shorten((o.title || '').replace(/\*/g, ''), 90);
const meta = [o.address, o.price, o.size].filter(Boolean).join(' | ');
const fredyLink =
baseUrl && o.id ? `\n<a href='${escapeHtml(`${baseUrl}/listings/listing/${o.id}`)}'>Open in Fredy</a>` : '';
return `<i>${escapeHtml(jobName)}</i> (${escapeHtml(serviceName)})\n<a href='${escapeHtml(
o.link || '',
)}'><b>${escapeHtml(title)}</b></a>\n${escapeHtml(meta)}${fredyLink}`.slice(0, 1024);
}
/**
* Build a Telegram message text using HTML parse mode.
* @param {string} jobName
* @param {string} serviceName
* @param {Object} o - Listing object
* @returns {string}
*/
function buildText(jobName, serviceName, o, baseUrl) {
const title = shorten((o.title || '').replace(/\*/g, ''), 90);
const meta = [o.address, o.price, o.size].filter(Boolean).join(' | ');
const fredyLink =
baseUrl && o.id ? `\n<a href='${escapeHtml(`${baseUrl}/listings/listing/${o.id}`)}'>Open in Fredy</a>` : '';
baseUrl && o.id ? `\n<a href='${escapeHtml(`${baseUrl}/#/listings/listing/${o.id}`)}'>Open in Fredy</a>` : '';
return (
`<i>${escapeHtml(jobName)}</i> (${escapeHtml(serviceName)})\n` +
`<a href='${escapeHtml(o.link || '')}'><b>${escapeHtml(title)}</b></a>\n` +
@@ -110,34 +96,128 @@ function buildText(jobName, serviceName, o, baseUrl) {
}
/**
* Build a plain text Telegram photo caption (max 4096 characters).
* Build a plain-text Telegram photo caption (max 4096 characters).
* Meta appears before the link so the most relevant info is visible within the cap.
*
* @param {string} jobName
* @param {string} serviceName
* @param {Object} o - Listing object
* @param baseUrl
* @param {string} [baseUrl]
* @returns {string}
*/
function buildCaptionPlain(jobName, serviceName, o, baseUrl) {
function buildPlainCaption(jobName, serviceName, o, baseUrl) {
const title = shorten((o.title || '').replace(/\*/g, ''), 90);
const meta = [o.address, o.price, o.size].filter(Boolean).join(' | ');
const fredyLine = baseUrl && o.id ? `\nOpen in Fredy: ${baseUrl}/listings/listing/${o.id}` : '';
const fredyLine = baseUrl && o.id ? `\nOpen in Fredy: ${baseUrl}/#/listings/listing/${o.id}` : '';
return `${jobName} (${serviceName})\n${title}\n${meta}\n\n${o.link || ''}${fredyLine}`.slice(0, 4096);
}
/**
* Build a plain text Telegram message.
* Build a plain-text Telegram message body.
* Link appears early so it is tappable without scrolling.
*
* @param {string} jobName
* @param {string} serviceName
* @param {Object} o - Listing object
* @param {string} [baseUrl]
* @returns {string}
*/
function buildTextPlain(jobName, serviceName, o, baseUrl) {
function buildPlainText(jobName, serviceName, o, baseUrl) {
const title = shorten((o.title || '').replace(/\*/g, ''), 90);
const meta = [o.address, o.price, o.size].filter(Boolean).join(' | ');
const fredyLine = baseUrl && o.id ? `\nOpen in Fredy: ${baseUrl}/listings/listing/${o.id}` : '';
const fredyLine = baseUrl && o.id ? `\nOpen in Fredy: ${baseUrl}/#/listings/listing/${o.id}` : '';
return `${jobName} (${serviceName})\n${title}\n${o.link || ''}\n${meta}${fredyLine}`;
}
/**
* Create the raw Telegram API caller for a given bot token.
* Handles JSON and multipart (FormData) bodies.
*
* @param {string} token - Telegram bot token.
* @param {string} jobName - Used in error messages.
* @returns {(endpoint: string, body: object|FormData) => Promise<Response>}
*/
function makeTelegramCaller(token, jobName) {
return async function (endpoint, body) {
const isFormData = body instanceof FormData;
const opts = isFormData
? { method: 'post', body }
: { method: 'post', body: JSON.stringify(body), headers: { 'Content-Type': 'application/json' } };
const res = await fetch(`https://api.telegram.org/bot${token}/${endpoint}`, opts);
if (!res.ok) {
const errorBody = await res.text();
throw new Error(`API error for '${jobName}'. '${endpoint}' returned ${errorBody}`);
}
return res;
};
}
/**
* Send a single listing to a single Telegram chat, with photo-then-text fallback.
*
* @param {Function} throttledCall - Throttled Telegram API caller for this chat.
* @param {Object} listing - Listing object.
* @param {string|number} chatId
* @param {Object} opts
* @param {string} opts.jobName
* @param {string} opts.serviceName
* @param {string} opts.baseUrl
* @param {boolean} opts.plainText
* @param {number|undefined} opts.message_thread_id
* @returns {Promise<void>}
*/
async function sendListingToChat(
throttledCall,
listing,
chatId,
{ jobName, serviceName, baseUrl, plainText, message_thread_id },
) {
const img = normalizeImageUrl(listing.image);
const textPayload = {
chat_id: chatId,
text: plainText
? buildPlainText(jobName, serviceName, listing, baseUrl)
: buildHtmlBody(jobName, serviceName, listing, baseUrl),
...(plainText ? {} : { parse_mode: 'HTML' }),
disable_web_page_preview: true,
...(message_thread_id ? { message_thread_id } : {}),
};
if (!img) {
return throttledCall('sendMessage', textPayload).catch((e) => {
logger.error(`Error sending message to Telegram: ${e.message}`);
});
}
const caption = plainText
? buildPlainCaption(jobName, serviceName, listing, baseUrl)
: buildHtmlBody(jobName, serviceName, listing, baseUrl).slice(0, 1024);
const parseMode = plainText ? undefined : 'HTML';
// .webp URLs (Immowelt/Cloudimage) fail Telegram's URL-based sendPhoto with
// "failed to get HTTP URL content". Upload the bytes via multipart instead.
const photoCall = shouldUseMultipart(img)
? buildPhotoFormData({ chatId, imageUrl: img, caption, parseMode, messageThreadId: message_thread_id }).then((fd) =>
throttledCall('sendPhoto', fd),
)
: throttledCall('sendPhoto', {
chat_id: chatId,
photo: img,
caption,
...(parseMode ? { parse_mode: parseMode } : {}),
...(message_thread_id ? { message_thread_id } : {}),
});
return photoCall.catch(async (e) => {
logger.warn(`Error sending photo to Telegram and use a fallback: ${e.message}`);
return throttledCall('sendMessage', textPayload).catch((e) => {
logger.error(`Error sending message to Telegram: ${e.message}`);
throw e;
});
});
}
/**
* Send new listings to Telegram.
* - Respects per-chat Telegram rate limits using a lightweight throttle cache.
@@ -160,6 +240,11 @@ export const send = ({ serviceName, newListings = [], notificationConfig, jobKey
throw new Error("Telegram 'token' and 'chatId' must be provided in notification config");
}
const chatIds = String(chatId)
.split(',')
.map((s) => s.trim())
.filter(Boolean);
// Optional Telegram topic/thread support (supergroups)
let message_thread_id;
if (messageThreadId !== undefined && messageThreadId !== null && `${messageThreadId}`.trim() !== '') {
@@ -176,56 +261,16 @@ export const send = ({ serviceName, newListings = [], notificationConfig, jobKey
const job = getJob(jobKey);
const jobName = job == null ? jobKey : job.name;
const throttledCall = getThrottled(chatId, async function (endpoint, body) {
const res = await fetch(`https://api.telegram.org/bot${token}/${endpoint}`, {
method: 'post',
body: JSON.stringify(body),
headers: { 'Content-Type': 'application/json' },
});
if (!res.ok) {
const errorBody = await res.text();
throw new Error(`API error for '${jobName}'. '${endpoint}' returned ${errorBody}`);
}
return res;
});
if (!Array.isArray(newListings) || newListings.length === 0) return Promise.resolve([]);
const promises = newListings.map(async (o) => {
const img = normalizeImageUrl(o.image);
const textPayload = {
chat_id: chatId,
text: plainText ? buildTextPlain(jobName, serviceName, o, baseUrl) : buildText(jobName, serviceName, o, baseUrl),
...(plainText ? {} : { parse_mode: 'HTML' }),
disable_web_page_preview: true,
...(message_thread_id ? { message_thread_id } : {}),
};
if (!img) {
return await throttledCall('sendMessage', textPayload).catch(async (e) => {
logger.error(`Error sending message to Telegram: ${e.message}`);
});
}
return await throttledCall('sendPhoto', {
chat_id: chatId,
photo: img,
caption: plainText
? buildCaptionPlain(jobName, serviceName, o, baseUrl)
: buildCaption(jobName, serviceName, o, baseUrl),
...(plainText ? {} : { parse_mode: 'HTML' }),
...(message_thread_id ? { message_thread_id } : {}),
}).catch(async (e) => {
logger.error(`Error sending photo to Telegram and use a fallback: ${e.message}`);
return await throttledCall('sendMessage', textPayload).catch((e) => {
logger.error(`Error sending message to Telegram: ${e.message}`);
throw e;
});
});
const allPromises = chatIds.flatMap((id) => {
const caller = makeTelegramCaller(token, jobName);
const throttledCall = getThrottled(id, caller);
const opts = { jobName, serviceName, baseUrl, plainText, message_thread_id };
return newListings.map((listing) => sendListingToChat(throttledCall, listing, id, opts));
});
return Promise.all(promises);
return Promise.all(allPromises);
};
/**
@@ -246,7 +291,8 @@ export const config = {
chatId: {
type: 'chatId',
label: 'Chat Id',
description: 'The chat id to send messages to you.',
description:
'The chat ID to send messages to. Separate multiple IDs with commas to notify several recipients (e.g. 123456789, 987654321).',
},
messageThreadId: {
type: 'text',

View File

@@ -21,6 +21,8 @@ Steps:
- Private chats: `chat.id` is a positive number
- Groups/supergroups: `chat.id` is a negative number
**Multiple recipients:** To notify several users individually, enter a comma-separated list of chat IDs in the Chat Id field, e.g. `123456789, 987654321`. Each recipient receives the same messages and gets its own independent rate-limit window. This avoids having to create a group and add the bot to it.
Keep your bot token secret. If `getUpdates` returns an empty list, send a new message and try again, or make sure your bots privacy settings allow it to see group messages when used in groups.
#### Getting the thread ID (this is optional to be used for forum topics)

View File

@@ -0,0 +1,106 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
/**
* Helpers for sending photos to Telegram via `multipart/form-data` instead of
* the HTTP-URL path. Used when the URL is one that Telegram's URL-fetcher will
* reject - notably `.webp` images from Cloudimage (mms.immowelt.de), which
* Telegram refuses with "Bad Request: failed to get HTTP URL content".
*
* The HTTP-URL path is faster and is still the default in telegram.js; this
* module is the fallback for URLs whose extension makes Telegram fail.
*/
/** Telegram's sendPhoto limit when uploading bytes via multipart/form-data. */
const TELEGRAM_MULTIPART_MAX_BYTES = 10 * 1024 * 1024;
/** Accept header used when re-fetching the image ourselves.
* Deliberately excludes `image/webp` so CDNs that content-negotiate
* (like Cloudimage on mms.immowelt.de) transcode WEBP to JPEG. */
const NON_WEBP_ACCEPT = 'image/jpeg,image/png,image/*;q=0.8';
/**
* Returns true if the URL's path ends in a `.webp` extension. Such URLs need
* multipart upload because Telegram identifies media types from the URL path
* and rejects `.webp` in sendPhoto via HTTP URL.
*
* Conservative: returns false for null/empty/non-string input, malformed URLs,
* and non-https schemes.
*
* @param {string|null|undefined} url
* @returns {boolean}
*/
export function shouldUseMultipart(url) {
if (typeof url !== 'string' || url.length === 0) return false;
let parsed;
try {
parsed = new URL(url);
} catch {
return false;
}
if (parsed.protocol !== 'https:') return false;
return /\.webp$/i.test(parsed.pathname);
}
/**
* Fetch an image from `imageUrl` and build a `FormData` body suitable for
* POSTing to `https://api.telegram.org/bot<token>/sendPhoto`.
*
* - Sends an `Accept` header that excludes `image/webp` so origin/CDN servers
* that content-negotiate return JPEG bytes.
* - Rejects images larger than Telegram's 10 MB multipart limit, both
* advertised via `Content-Length` and (defensively) after download.
* - The `photo` field is named with a `.jpg` extension because Telegram
* identifies file type from the filename.
*
* Throws if the image fetch fails, the size limit is exceeded, or the URL is
* unreachable. The caller is responsible for catching and falling back.
*
* @param {Object} args
* @param {string|number} args.chatId
* @param {string} args.imageUrl
* @param {string} args.caption
* @param {string} [args.parseMode] - Telegram parse_mode, e.g. 'HTML'.
* @param {number} [args.messageThreadId] - Telegram supergroup topic id.
* @returns {Promise<FormData>}
*/
export async function buildPhotoFormData({ chatId, imageUrl, caption, parseMode, messageThreadId }) {
const res = await fetch(imageUrl, {
method: 'GET',
headers: { Accept: NON_WEBP_ACCEPT },
});
if (!res.ok) {
throw new Error(`Failed to fetch image for multipart upload (${res.status}): ${imageUrl}`);
}
const advertised = Number(res.headers.get('content-length'));
if (Number.isFinite(advertised) && advertised > TELEGRAM_MULTIPART_MAX_BYTES) {
throw new Error(
`Image exceeds Telegram multipart size limit (advertised ${advertised} bytes, max ${TELEGRAM_MULTIPART_MAX_BYTES}): ${imageUrl}`,
);
}
const buf = await res.arrayBuffer();
if (buf.byteLength > TELEGRAM_MULTIPART_MAX_BYTES) {
throw new Error(
`Image exceeds Telegram multipart size limit (downloaded ${buf.byteLength} bytes, max ${TELEGRAM_MULTIPART_MAX_BYTES}): ${imageUrl}`,
);
}
// Telegram identifies the media type from the filename extension. We always
// upload as .jpg because the Accept header forces JPEG bytes from CDNs that
// honor it; for the rare CDN that ignores Accept and still returns WEBP, the
// .jpg filename is a small lie but Telegram's image pipeline accepts it.
const blob = new Blob([buf], { type: 'image/jpeg' });
const fd = new FormData();
fd.append('chat_id', String(chatId));
fd.append('caption', caption);
if (parseMode) fd.append('parse_mode', parseMode);
if (messageThreadId != null) fd.append('message_thread_id', String(messageThreadId));
fd.append('photo', blob, 'photo.jpg');
return fd;
}

View File

@@ -4,6 +4,7 @@
*/
import fs from 'fs';
import logger from '../services/logger.js';
const path = './adapter';
/** Read every integration existing in ./adapter **/
@@ -23,7 +24,13 @@ const findAdapter = (notificationAdapter) => {
export const send = (serviceName, newListings, notificationConfig, jobKey, baseUrl) => {
//this is not being used in tests, therefore adapter are always set
return notificationConfig
.filter((notificationAdapter) => findAdapter(notificationAdapter) != null)
.map((notificationAdapter) => findAdapter(notificationAdapter))
.map((notificationAdapter) => {
const found = findAdapter(notificationAdapter);
if (!found) {
logger.warn(`Notification adapter '${notificationAdapter.id}' not found for job '${jobKey || ''}'`);
}
return found;
})
.filter(Boolean)
.map((a) => a.send({ serviceName, newListings, notificationConfig, jobKey, baseUrl }));
};

View File

@@ -26,7 +26,7 @@ function parseId(shortenedLink) {
async function fetchDetails(listing, browser) {
try {
const html = await puppeteerExtractor(listing.link, null, { browser });
const html = await puppeteerExtractor(listing.link, null, { browser, name: 'immobilienDe_details' });
if (!html) return listing;
const $ = cheerio.load(html);

View File

@@ -16,7 +16,7 @@ let appliedBlackList = [];
async function fetchDetails(listing, browser) {
try {
const html = await puppeteerExtractor(listing.link, null, { browser });
const html = await puppeteerExtractor(listing.link, null, { browser, name: 'immowelt_details' });
if (!html) return listing;
const $ = cheerio.load(html);

View File

@@ -128,7 +128,7 @@ async function enrichListingFromDetails(listing, browser) {
if (!absoluteLink) return listing;
try {
const html = await puppeteerExtractor(absoluteLink, null, { browser });
const html = await puppeteerExtractor(absoluteLink, null, { browser, name: 'kleinanzeigen_details' });
if (!html) return { ...listing, link: absoluteLink };
const { detailAddress, detailDescription } = extractDetailFromHtml(html);
@@ -196,8 +196,8 @@ const config = {
id: '.aditem@data-adid',
price: '.aditem-main--middle--price-shipping--price | removeNewline | trim',
tags: '.aditem-main--middle--tags | removeNewline | trim',
title: '.aditem-main .text-module-begin a | removeNewline | trim',
link: '.aditem-main .text-module-begin a@href | removeNewline | trim',
title: '.aditem-main .text-module-begin | removeNewline | trim',
link: '.aditem@data-href',
description: '.aditem-main .aditem-main--middle--description | removeNewline | trim',
address: '.aditem-main--top--left | trim | removeNewline',
image: 'img@src',

View File

@@ -16,7 +16,7 @@ let appliedBlackList = [];
async function fetchDetails(listing, browser) {
try {
const html = await puppeteerExtractor(listing.link, 'body', { browser });
const html = await puppeteerExtractor(listing.link, 'body', { browser, name: 'sparkasse_details' });
const $ = cheerio.load(html);
const nextDataRaw = $('#__NEXT_DATA__').text;

View File

@@ -16,7 +16,7 @@ let appliedBlackList = [];
async function fetchDetails(listing, browser) {
try {
const html = await puppeteerExtractor(listing.link, null, { browser });
const html = await puppeteerExtractor(listing.link, null, { browser, name: 'wgGesucht_details' });
if (!html) return listing;
const $ = cheerio.load(html);

View File

@@ -29,11 +29,12 @@ export default class Extractor {
* your response will never contain what you are really looking for
* @param url
* @param waitForSelector
* @param jobKey
*/
execute = async (url, waitForSelector = null) => {
execute = async (url, waitForSelector = null, jobKey = null) => {
this.responseText = null;
try {
this.responseText = await puppeteerExtractor(url, waitForSelector, this.options);
this.responseText = await puppeteerExtractor(url, waitForSelector, { ...this.options, name: jobKey });
if (this.responseText != null) {
loadParser(this.responseText);
}

View File

@@ -4,9 +4,11 @@
*/
import { launch } from 'cloakbrowser/puppeteer';
import { debug, botDetected } from './utils.js';
import { botDetected, debug } from './utils.js';
import { getPreLaunchConfig } from './botPrevention.js';
import logger from '../logger.js';
import { trackPoi } from '../tracking/Tracker.js';
import { TRACKING_POIS } from '../../TRACKING_POIS.js';
/**
* Launch a CloakBrowser/Puppeteer browser instance with stealth and humanizer enabled.
@@ -48,7 +50,7 @@ export async function launchBrowser(url, options) {
preCfg.windowSizeArg,
];
const browser = await launch({
return await launch({
headless: options?.puppeteerHeadless ?? true,
humanize: true,
args,
@@ -57,8 +59,6 @@ export async function launchBrowser(url, options) {
...(options?.proxyUrl ? { proxy: options.proxyUrl } : {}),
...(preCfg.timezone ? { timezone: preCfg.timezone } : {}),
});
return browser;
}
/**
@@ -145,6 +145,13 @@ export default async function execute(url, waitForSelector, options) {
if (botDetected(pageSource, statusCode)) {
logger.warn('We have been detected as a bot :-/ Tried url: => ', url);
if (options != null && options.name != null) {
await trackPoi(TRACKING_POIS.DETECTED_AS_BOT + '_' + options.name);
} else {
await trackPoi(TRACKING_POIS.DETECTED_AS_BOT);
}
result = null;
} else {
result = pageSource || (await page.content());

View File

@@ -141,6 +141,43 @@ const WEB_PATH_TO_APARTMENT_EQUIPMENT_MAP = {
'barrierefreie-wohnung-mieten': { equipment: ['handicappedaccessible'] },
};
// SEO-optimized rental paths used by the ImmoScout web UI when the user
// configures a maximum warmrent. Example: "wohnung-bis-800-euro-warm" means
// "apartment for rent up to 800 EUR warmrent". The web UI generates these
// paths instead of explicit `price` / `pricetype` query parameters.
// Note: only the warmrent variant uses an SEO slug; max coldrent searches
// use the regular "wohnung-mieten" path with explicit `price` and
// `pricetype=rentpermonth` query params, which the existing translator
// already handles.
const SEO_RENT_TYPE_TO_REAL_ESTATE_TYPE = {
wohnung: 'apartmentrent',
haus: 'houserent',
};
const SEO_MAX_WARMRENT_PATH_PATTERN = /^(?<type>wohnung|haus)-bis-(?<price>\d+)-euro-warm$/;
/**
* Parses SEO-optimized ImmoScout web paths that encode a maximum warmrent, such
* as "wohnung-bis-800-euro-warm". Returns the corresponding mobile API real
* estate type and the implicit price/pricetype parameters, or null if the path
* does not match the known SEO max-warmrent pattern.
*
* @param {string} realTypeKey The last segment of the URL path.
* @returns {{ realType: string, additionalParams: Record<string, string> } | null}
*/
function parseSeoMaxWarmrentPath(realTypeKey) {
const match = realTypeKey.match(SEO_MAX_WARMRENT_PATH_PATTERN);
if (!match) return null;
const { type, price } = match.groups;
return {
realType: SEO_RENT_TYPE_TO_REAL_ESTATE_TYPE[type],
additionalParams: {
price: `-${price}`,
pricetype: 'calculatedtotalrent',
},
};
}
export function convertWebToMobile(webUrl) {
let url;
try {
@@ -164,7 +201,14 @@ export function convertWebToMobile(webUrl) {
additionalParamsFromWebPath = WEB_PATH_TO_APARTMENT_EQUIPMENT_MAP[realTypeKey];
realType = REAL_ESTATE_TYPE['wohnung-mieten'];
} else {
throw new Error(`Real estate type not found: ${realTypeKey}`);
// Test for SEO max-warmrent path, e.g. "wohnung-bis-800-euro-warm"
const seoMaxWarmrent = parseSeoMaxWarmrentPath(realTypeKey);
if (seoMaxWarmrent) {
realType = seoMaxWarmrent.realType;
additionalParamsFromWebPath = seoMaxWarmrent.additionalParams;
} else {
throw new Error(`Real estate type not found: ${realTypeKey}`);
}
}
}

View File

@@ -14,6 +14,7 @@ import * as similarityCache from '../similarity-check/similarityCache.js';
import { isRunning, markFinished, markRunning } from './run-state.js';
import { sendToUsers } from '../sse/sse-broker.js';
import * as puppeteerExtractor from '../extractor/puppeteerExtractor.js';
import { getSettings } from '../storage/settingsStorage.js';
/**
* Initializes the job execution service.
@@ -104,14 +105,11 @@ export function initJobExecutionService({ providers, settings, intervalMs }) {
return;
}
settings.lastRun = now;
const jobs = jobStorage
.getJobs()
.filter((job) => job.enabled)
.filter((job) => {
if (!context) return true; // startup/cron → all
if (context.isAdmin) return true; // admin → all
return context.userId ? job.userId === context.userId : false; // user → own
});
const jobs = jobStorage.getJobs().filter((job) => {
if (!context) return true; // startup/cron → all
if (context.isAdmin) return true; // admin → all
return context.userId ? job.userId === context.userId : false; // user → own
});
for (const job of jobs) {
await executeJob(job);
@@ -160,6 +158,14 @@ export function initJobExecutionService({ providers, settings, intervalMs }) {
}
let browser;
try {
// Read the proxy live (not from the startup snapshot) so changing it in the
// UI takes effect on the next run without a backend restart. An empty value
// disables the proxy. Routing the headless browser through a (German
// residential) proxy avoids datacenter-IP based bot detection on the
// Puppeteer-based providers (immowelt, immonet, kleinanzeigen, ...).
const liveSettings = await getSettings();
const proxyUrl = typeof liveSettings?.proxyUrl === 'string' ? liveSettings.proxyUrl.trim() : '';
const jobProviders = job.provider.filter(
(p) => providers.find((loaded) => loaded.metaInformation.id === p.id) != null,
);
@@ -168,14 +174,14 @@ export function initJobExecutionService({ providers, settings, intervalMs }) {
const matchedProvider = providers.find((loaded) => loaded.metaInformation.id === prov.id);
matchedProvider.init({ ...prov, userId: job.userId }, job.blacklist);
if (browser && !browser.isConnected()) {
if (browser && !browser.connected) {
logger.debug('Browser is disconnected, nullifying to launch a new one.');
await puppeteerExtractor.closeBrowser(browser);
browser = null;
}
if (!browser && matchedProvider.config.getListings == null) {
browser = await puppeteerExtractor.launchBrowser(matchedProvider.config.url, {});
browser = await puppeteerExtractor.launchBrowser(matchedProvider.config.url, proxyUrl ? { proxyUrl } : {});
}
await new FredyPipelineExecutioner(matchedProvider.config, job, prov.id, similarityCache, browser).execute();

View File

@@ -16,7 +16,7 @@ import logger from '../../services/logger.js';
* Concurrency: network-bound checks are executed with a configurable concurrency limit.
*
* @param {object} [opts]
* @param {number} [opts.concurrency=8] Max number of parallel activeTester calls.
* @param {number} [opts.concurrency=4] Max number of parallel activeTester calls.
* @returns {Promise<void>}
*/
export default async function runActiveChecker(opts = {}) {

View File

@@ -3,10 +3,27 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { nullOrEmpty } from '../../utils.js';
import { nullOrEmpty, fromJson } from '../../utils.js';
import SqliteConnection from './SqliteConnection.js';
import { nanoid } from 'nanoid';
/**
* Parse the JSON `status` column of a listing row in place.
*
* The DB stores status as a JSON payload `{ status, setAt }` (or NULL).
* Consumers expect an object/null, so we normalize before returning.
*
* @param {Object|null|undefined} row - A raw row from the listings table.
* @returns {Object|null|undefined} The same row with `status` parsed.
*/
const parseListingStatus = (row) => {
if (row == null) return row;
if (typeof row.status === 'string') {
row.status = fromJson(row.status, null);
}
return row;
};
/**
* Return a list of known listing hashes for a given job and provider.
* Useful to de-duplicate before inserting new listings.
@@ -43,18 +60,14 @@ export const getListingsKpisForJobIds = (jobIds = []) => {
const placeholders = jobIds.map(() => '?').join(',');
const rows = SqliteConnection.query(
`SELECT
SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END) OVER() AS active_count,
price
FROM listings
WHERE job_id IN (${placeholders})
AND manually_deleted = 0
GROUP BY
id`,
`SELECT is_active, price
FROM listings
WHERE job_id IN (${placeholders})
AND manually_deleted = 0`,
jobIds,
);
const activeCount = rows[0]?.active_count ?? 0;
const activeCount = rows.filter((r) => r.is_active === 1).length;
const prices = rows
.map((r) => r.price)
@@ -214,6 +227,8 @@ export const storeListings = (jobId, providerId, listings) => {
longitude: item.longitude || null,
};
stmt.run(params);
// Propagate the DB primary key back so downstream pipeline steps use the correct id
item.id = params.id;
}
});
@@ -242,6 +257,7 @@ export const storeListings = (jobId, providerId, listings) => {
* @param {object} [params.jobNameFilter]
* @param {object} [params.providerFilter]
* @param {object} [params.watchListFilter]
* @param {('applied'|'rejected'|'accepted'|'none')} [params.statusFilter] - Filter by listing status. 'none' matches NULL.
* @param {string|null} [params.sortField=null] - One of: 'created_at','price','size','provider','title'.
* @param {('asc'|'desc')} [params.sortDir='asc']
* @param {number} [params.createdAfter] - Only include listings created at or after this unix timestamp (ms).
@@ -258,6 +274,7 @@ export const queryListings = ({
jobIdFilter,
providerFilter,
watchListFilter,
statusFilter,
freeTextFilter,
sortField = null,
sortDir = 'asc',
@@ -287,13 +304,15 @@ export const queryListings = ({
}
if (freeTextFilter && String(freeTextFilter).trim().length > 0) {
params.filter = `%${String(freeTextFilter).trim()}%`;
whereParts.push(`(title LIKE @filter OR address LIKE @filter OR provider LIKE @filter OR link LIKE @filter)`);
whereParts.push(
`(l.title LIKE @filter OR l.address LIKE @filter OR l.provider LIKE @filter OR l.link LIKE @filter)`,
);
}
// activityFilter: when true -> only active listings (is_active = 1), false -> only inactive
if (activityFilter === true) {
whereParts.push('(is_active = 1)');
whereParts.push('(l.is_active = 1)');
} else if (activityFilter === false) {
whereParts.push('(is_active = 0)');
whereParts.push('(l.is_active = 0)');
}
// Prefer filtering by job id when provided (unambiguous and robust)
if (jobIdFilter && String(jobIdFilter).trim().length > 0) {
@@ -307,7 +326,7 @@ export const queryListings = ({
// providerFilter: when provided as string (assumed provider name), filter listings where provider equals that name (exact match)
if (providerFilter && String(providerFilter).trim().length > 0) {
params.providerName = String(providerFilter).trim();
whereParts.push('(provider = @providerName)');
whereParts.push('(l.provider = @providerName)');
}
// watchListFilter: when true -> only watched listings, false -> only unwatched
if (watchListFilter === true) {
@@ -315,14 +334,26 @@ export const queryListings = ({
} else if (watchListFilter === false) {
whereParts.push('(wl.id IS NULL)');
}
// statusFilter: 'applied'|'rejected'|'accepted' -> equality on JSON status field; 'none' -> NULL.
// The status column is a JSON payload `{ status, setAt }`, so we extract the inner
// status string for comparison instead of matching the raw text.
if (statusFilter === 'none') {
whereParts.push('(l.status IS NULL)');
} else if (
typeof statusFilter === 'string' &&
['applied', 'rejected', 'accepted'].includes(statusFilter.toLowerCase())
) {
params.statusValue = statusFilter.toLowerCase();
whereParts.push(`(json_extract(l.status, '$.status') = @statusValue)`);
}
// Time range filters (unix timestamps in milliseconds)
if (Number.isFinite(createdAfter) && createdAfter > 0) {
params.createdAfter = createdAfter;
whereParts.push('(created_at >= @createdAfter)');
whereParts.push('(l.created_at >= @createdAfter)');
}
if (Number.isFinite(createdBefore) && createdBefore > 0) {
params.createdBefore = createdBefore;
whereParts.push('(created_at <= @createdBefore)');
whereParts.push('(l.created_at <= @createdBefore)');
}
// Price range filters
if (Number.isFinite(minPrice) && minPrice >= 0) {
@@ -337,32 +368,22 @@ export const queryListings = ({
// 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')
.replace(/\bdescription\b/g, 'l.description')
.replace(/\baddress\b/g, 'l.address')
.replace(/\bprovider\b/g, 'l.provider')
.replace(/\blink\b/g, 'l.link')
.replace(/\bis_active\b/g, 'l.is_active')
.replace(/\bj\.user_id\b/g, 'j.user_id')
.replace(/\bj\.name\b/g, 'j.name')
.replace(/\bwl\.id\b/g, 'wl.id');
const whereSqlWithAlias = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : '';
// whitelist sortable fields to avoid SQL injection
const sortable = new Set(['created_at', 'price', 'size', 'provider', 'title', 'job_name', 'is_active', 'isWatched']);
const safeSortField = sortField && sortable.has(sortField) ? sortField : null;
// whitelist sortable fields to avoid SQL injection; map to fully-qualified expressions
const sortableMap = {
created_at: 'l.created_at',
price: 'l.price',
size: 'l.size',
provider: 'l.provider',
title: 'l.title',
job_name: 'j.name',
is_active: 'l.is_active',
isWatched: 'CASE WHEN wl.id IS NOT NULL THEN 1 ELSE 0 END',
};
const safeSortExpr = sortField && sortableMap[sortField] ? sortableMap[sortField] : null;
const safeSortDir = String(sortDir).toLowerCase() === 'desc' ? 'DESC' : 'ASC';
const orderSql = safeSortField ? `ORDER BY ${safeSortField} ${safeSortDir}` : 'ORDER BY created_at DESC';
const orderSqlWithAlias = orderSql
.replace(/\bcreated_at\b/g, 'l.created_at')
.replace(/\bprice\b/g, 'l.price')
.replace(/\bsize\b/g, 'l.size')
.replace(/\bprovider\b/g, 'l.provider')
.replace(/\btitle\b/g, 'l.title')
.replace(/\bjob_name\b/g, 'j.name')
// Sort by computed watch flag when requested
.replace(/\bisWatched\b/g, 'CASE WHEN wl.id IS NOT NULL THEN 1 ELSE 0 END');
const orderSqlWithAlias = safeSortExpr ? `ORDER BY ${safeSortExpr} ${safeSortDir}` : 'ORDER BY l.created_at DESC';
// count total with same WHERE
const countRow = SqliteConnection.query(
@@ -389,7 +410,7 @@ export const queryListings = ({
params,
);
return { totalNumber, page: safePage, result: rows };
return { totalNumber, page: safePage, result: rows.map(parseListingStatus) };
};
/**
@@ -417,9 +438,10 @@ export const deleteListingsByJobId = (jobId, hardDelete = false) => {
};
/**
* Delete listings by a list of listing IDs.
* Delete listings by a list of listing IDs (the nanoid primary key stored in the `id` column).
* Used by API routes that receive row IDs from the client.
*
* @param {string[]} ids - Array of listing IDs to delete.
* @param {string[]} ids - Array of DB row IDs to delete.
* @param {boolean} [hardDelete=false] - Whether to hard delete from DB or just mark as deleted.
* @returns {any} The result from SqliteConnection.execute.
*/
@@ -482,7 +504,7 @@ export const updateListingGeocoordinates = (id, latitude, longitude) => {
* @param {string} [params.jobId]
* @param {string} [params.userId]
* @param {boolean} [params.isAdmin=false]
* @returns {{listings: Object[], maxPrice: number}} Object containing listings and maxPrice.
* @returns {{listings: Object[]}} Object containing listings.
*/
export const getListingsForMap = ({ jobId, userId = null, isAdmin = false } = {}) => {
const baseWhereParts = [
@@ -623,7 +645,7 @@ export const getListingById = (id, userId = null, isAdmin = false) => {
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 (
return parseListingStatus(
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
@@ -631,10 +653,57 @@ export const getListingById = (id, userId = null, isAdmin = false) => {
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
)[0] || null,
);
};
/**
* Set or clear the notes attached to a single listing.
*
* Empty strings are normalized to NULL so the DB doesn't keep meaningless
* whitespace and queries can filter "has notes" with a simple IS NOT NULL.
*
* @param {string} id - The listing ID.
* @param {string|null} notes - The note text to store, or null/empty to clear.
* @returns {number} Number of rows affected (0 if listing not found).
*/
export const setListingNotes = (id, notes) => {
if (!id) return 0;
const trimmed = typeof notes === 'string' ? notes.trim() : null;
const value = trimmed && trimmed.length > 0 ? trimmed : null;
const res = SqliteConnection.execute(`UPDATE listings SET notes = @notes WHERE id = @id`, {
id,
notes: value,
});
return res?.changes ?? 0;
};
/**
* Set or clear the status of a single listing.
*
* The status column stores a JSON payload `{ status, setAt }` so consumers
* can show both the user's decision and when it was made. Passing `null`
* clears the column.
*
* @param {string} id - The listing ID.
* @param {('applied'|'rejected'|'accepted'|null)} status - New status, or null to clear.
* @returns {number} Number of rows affected (0 if listing not found).
*/
export const setListingStatus = (id, status) => {
if (!id) return 0;
const allowed = ['applied', 'rejected', 'accepted'];
const normalized = status == null ? null : String(status).toLowerCase();
if (normalized != null && !allowed.includes(normalized)) {
throw new Error(`Invalid listing status: ${status}`);
}
const payload = normalized == null ? null : JSON.stringify({ status: normalized, setAt: Date.now() });
const res = SqliteConnection.execute(`UPDATE listings SET status = @status WHERE id = @id`, {
id,
status: payload,
});
return res?.changes ?? 0;
};
/**
* Resets geocoordinates and distance for all listings related to a user.
*

View File

@@ -0,0 +1,11 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
export function up(db) {
db.exec(`
ALTER TABLE listings ADD COLUMN status JSON;
CREATE INDEX IF NOT EXISTS idx_listings_status ON listings (json_extract(status, '$.status'));
`);
}

View File

@@ -0,0 +1,10 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
export function up(db) {
db.exec(`
ALTER TABLE listings ADD COLUMN notes TEXT;
`);
}

View File

@@ -123,8 +123,11 @@ export function upsertSettings(settingsMapOrEntry, userId = null) {
);
}
}
// keep cache in sync (only for global settings)
// Invalidate cache synchronously so the next getSettings() call rebuilds it.
// refreshSettingsCache() is async (reads config.json), so we cannot await it
// here without making upsertSettings async everywhere. Nulling is safe because
// getSettings() will call refreshSettingsCache() on the next invocation.
if (userId == null) {
refreshSettingsCache();
cachedSettingsConfig = null;
}
}

View File

@@ -47,6 +47,25 @@ export const deleteWatch = (listingId, userId) => {
return { deleted: Boolean(res?.changes) };
};
/**
* Ensure a watch entry exists. Does not toggle; safe to call when row may already exist.
* Used by the status endpoint to auto-watch a listing when a status is set.
* @param {string} listingId
* @param {string} userId
* @returns {{watched:boolean}}
*/
export const ensureWatch = (listingId, userId) => {
if (!listingId || !userId) return { watched: false };
const { created } = createWatch(listingId, userId);
if (created) return { watched: true };
const exists =
SqliteConnection.query(
`SELECT 1 AS ok FROM watch_list WHERE listing_id = @listing_id AND user_id = @user_id LIMIT 1`,
{ listing_id: listingId, user_id: userId },
).length > 0;
return { watched: exists };
};
/**
* Toggle a watch entry. If exists -> delete, otherwise create.
* @param {string} listingId

View File

@@ -1,6 +1,6 @@
{
"name": "fredy",
"version": "22.0.0",
"version": "22.4.0",
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
"scripts": {
"prepare": "husky",
@@ -62,9 +62,9 @@
"Firefox ESR"
],
"dependencies": {
"@douyinfe/semi-icons": "^2.96.1",
"@douyinfe/semi-ui": "2.96.1",
"@douyinfe/semi-ui-19": "^2.96.1",
"@douyinfe/semi-icons": "^2.99.3",
"@douyinfe/semi-ui": "2.99.3",
"@douyinfe/semi-ui-19": "^2.99.3",
"@fastify/cookie": "^11.0.2",
"@fastify/helmet": "^13.0.2",
"@fastify/session": "^11.1.1",
@@ -73,12 +73,12 @@
"@modelcontextprotocol/sdk": "^1.29.0",
"@sendgrid/mail": "8.1.6",
"@turf/boolean-point-in-polygon": "^7.3.5",
"@vitejs/plugin-react": "6.0.1",
"@vitejs/plugin-react": "6.0.2",
"adm-zip": "^0.5.17",
"better-sqlite3": "^12.9.0",
"better-sqlite3": "^12.10.0",
"chart.js": "^4.5.1",
"cheerio": "^1.2.0",
"cloakbrowser": "^0.3.27",
"cloakbrowser": "^0.3.31",
"fastify": "^5.8.5",
"handlebars": "4.7.9",
"maplibre-gl": "^5.24.0",
@@ -86,41 +86,41 @@
"node-cron": "^4.2.1",
"node-fetch": "3.3.2",
"node-mailjet": "6.0.11",
"nodemailer": "^8.0.7",
"nodemailer": "^8.0.10",
"p-throttle": "^8.1.0",
"package-up": "^5.0.0",
"puppeteer-core": "^24.43.0",
"query-string": "9.3.1",
"react": "19.2.6",
"puppeteer-core": "^25.1.0",
"query-string": "9.4.0",
"react": "19.2.7",
"react-chartjs-2": "^5.3.1",
"react-dom": "19.2.6",
"react-dom": "19.2.7",
"react-range-slider-input": "^3.3.5",
"react-router": "7.15.0",
"react-router-dom": "7.15.0",
"resend": "^6.12.3",
"semver": "^7.7.4",
"react-router": "7.16.0",
"react-router-dom": "7.16.0",
"resend": "^6.12.4",
"semver": "^7.8.1",
"slack": "11.0.2",
"vite": "8.0.11",
"vite": "8.0.16",
"x-var": "^3.0.1",
"zustand": "^5.0.13"
"zustand": "^5.0.14"
},
"devDependencies": {
"@babel/core": "7.29.0",
"@babel/eslint-parser": "7.28.6",
"@babel/preset-env": "7.29.5",
"@babel/preset-react": "7.28.5",
"@babel/core": "7.29.7",
"@babel/eslint-parser": "7.29.7",
"@babel/preset-env": "7.29.7",
"@babel/preset-react": "7.29.7",
"@eslint/js": "^10.0.1",
"chalk": "^5.6.2",
"eslint": "10.3.0",
"eslint": "10.4.1",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-react": "7.37.5",
"globals": "^17.6.0",
"history": "5.3.0",
"husky": "9.1.7",
"less": "4.6.4",
"lint-staged": "16.4.0",
"lint-staged": "17.0.7",
"nodemon": "^3.1.14",
"prettier": "3.8.3",
"vitest": "^4.1.5"
"vitest": "^4.1.8"
}
}

View File

@@ -32,4 +32,7 @@ export const deletedIds = [];
export const deleteListingsById = (ids) => {
deletedIds.push(...ids);
};
export const deleteListingsByHash = (hashes) => {
deletedIds.push(...hashes);
};
/* eslint-enable no-unused-vars */

View File

@@ -0,0 +1,415 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// Mock external deps BEFORE importing the module under test.
vi.mock('node-fetch', () => ({ default: vi.fn() }));
vi.mock('../../lib/services/storage/jobStorage.js', () => ({
getJob: (jobKey) => ({ id: jobKey, name: jobKey }),
}));
vi.mock('../../lib/services/markdown.js', () => ({
markdown2Html: () => '',
}));
// Helpers to build mock fetch responses.
function jsonOk(body = { ok: true }) {
return {
ok: true,
status: 200,
text: async () => JSON.stringify(body),
};
}
function jsonErr(status, body) {
return {
ok: false,
status,
text: async () => JSON.stringify(body),
};
}
function imageOk(bytes = new Uint8Array([0xff, 0xd8, 0xff])) {
return {
ok: true,
status: 200,
headers: {
get: (h) => {
const k = h.toLowerCase();
if (k === 'content-type') return 'image/jpeg';
if (k === 'content-length') return String(bytes.byteLength);
return null;
},
},
arrayBuffer: async () => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength),
};
}
// Globals are mocked too so buildPhotoFormData (which uses global fetch) can be
// intercepted by the same single mock.
let mockNodeFetch;
let mockGlobalFetch;
let send;
beforeEach(async () => {
// Reset modules to get a fresh import with our mocks applied.
vi.resetModules();
const nodeFetchMod = await import('node-fetch');
mockNodeFetch = nodeFetchMod.default;
mockNodeFetch.mockReset();
mockGlobalFetch = vi.fn();
vi.stubGlobal('fetch', mockGlobalFetch);
({ send } = await import('../../lib/notification/adapter/telegram.js'));
});
afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
});
const baseConfig = {
id: 'telegram',
fields: { token: 'TKN', chatId: '999' },
};
describe('telegram send() - HTTP URL path (default for .jpg / .png)', () => {
it('POSTs JSON to sendPhoto for a .jpg image URL', async () => {
mockNodeFetch.mockResolvedValueOnce(jsonOk());
await send({
serviceName: 'immowelt',
newListings: [
{
id: 'a',
title: 'Listing',
link: 'https://example.com/a',
address: 'Addr',
price: '500€',
size: '50m²',
image: 'https://mms.immowelt.de/x/y/z/w/abc.jpg?ci_seal=hash&w=525&h=394',
},
],
notificationConfig: [baseConfig],
jobKey: 'Berlin',
});
expect(mockNodeFetch).toHaveBeenCalledTimes(1);
const [url, opts] = mockNodeFetch.mock.calls[0];
expect(url).toBe('https://api.telegram.org/botTKN/sendPhoto');
expect(opts.method).toBe('post');
expect(opts.headers?.['Content-Type']).toBe('application/json');
const body = JSON.parse(opts.body);
expect(body.chat_id).toBe('999');
expect(body.photo).toBe('https://mms.immowelt.de/x/y/z/w/abc.jpg?ci_seal=hash&w=525&h=394');
expect(body.parse_mode).toBe('HTML');
});
it('does NOT pre-fetch the image when using HTTP URL path', async () => {
mockNodeFetch.mockResolvedValueOnce(jsonOk());
await send({
serviceName: 'immowelt',
newListings: [
{
id: 'a',
title: 't',
link: 'l',
address: 'a',
price: '',
size: '',
image: 'https://example.com/x.jpg',
},
],
notificationConfig: [baseConfig],
jobKey: 'Berlin',
});
// global fetch (used by buildPhotoFormData) must not be called
expect(mockGlobalFetch).not.toHaveBeenCalled();
});
it('falls back to sendMessage when sendPhoto fails', async () => {
mockNodeFetch
.mockResolvedValueOnce(jsonErr(400, { ok: false, description: 'boom' }))
.mockResolvedValueOnce(jsonOk());
await send({
serviceName: 'immowelt',
newListings: [
{
id: 'a',
title: 't',
link: 'l',
address: 'a',
price: '',
size: '',
image: 'https://example.com/x.jpg',
},
],
notificationConfig: [baseConfig],
jobKey: 'Berlin',
});
expect(mockNodeFetch).toHaveBeenCalledTimes(2);
expect(mockNodeFetch.mock.calls[0][0]).toBe('https://api.telegram.org/botTKN/sendPhoto');
expect(mockNodeFetch.mock.calls[1][0]).toBe('https://api.telegram.org/botTKN/sendMessage');
});
});
describe('telegram send() - multipart path (.webp URLs)', () => {
it('pre-fetches the image then POSTs FormData to sendPhoto for a .webp URL', async () => {
// 1st: GET image via global fetch
mockGlobalFetch.mockResolvedValueOnce(imageOk());
// 2nd: POST sendPhoto via node-fetch
mockNodeFetch.mockResolvedValueOnce(jsonOk());
await send({
serviceName: 'immowelt',
newListings: [
{
id: 'a',
title: 'Listing',
link: 'https://example.com/a',
address: 'Addr',
price: '500€',
size: '50m²',
image: 'https://mms.immowelt.de/1/1/6/5/abc.webp?ci_seal=hash&w=525&h=394',
},
],
notificationConfig: [baseConfig],
jobKey: 'Berlin',
});
// image was fetched
expect(mockGlobalFetch).toHaveBeenCalledTimes(1);
expect(mockGlobalFetch.mock.calls[0][0]).toBe('https://mms.immowelt.de/1/1/6/5/abc.webp?ci_seal=hash&w=525&h=394');
// sendPhoto called via node-fetch with FormData
expect(mockNodeFetch).toHaveBeenCalledTimes(1);
const [url, opts] = mockNodeFetch.mock.calls[0];
expect(url).toBe('https://api.telegram.org/botTKN/sendPhoto');
expect(opts.method).toBe('post');
expect(opts.body).toBeInstanceOf(FormData);
// No explicit Content-Type header - fetch sets multipart boundary itself
expect(opts.headers).toBeUndefined();
expect(opts.body.get('chat_id')).toBe('999');
expect(opts.body.get('parse_mode')).toBe('HTML');
const photo = opts.body.get('photo');
expect(photo).toBeTruthy();
expect(photo.size).toBeGreaterThan(0);
});
it('falls back to sendMessage when the image pre-fetch fails for a .webp URL', async () => {
// image fetch fails (404 from CDN)
mockGlobalFetch.mockResolvedValueOnce({
ok: false,
status: 404,
headers: { get: () => null },
arrayBuffer: async () => new ArrayBuffer(0),
});
// then sendMessage succeeds via node-fetch
mockNodeFetch.mockResolvedValueOnce(jsonOk());
await send({
serviceName: 'immowelt',
newListings: [
{
id: 'a',
title: 't',
link: 'l',
address: 'a',
price: '',
size: '',
image: 'https://example.com/gone.webp',
},
],
notificationConfig: [baseConfig],
jobKey: 'Berlin',
});
expect(mockNodeFetch).toHaveBeenCalledTimes(1);
expect(mockNodeFetch.mock.calls[0][0]).toBe('https://api.telegram.org/botTKN/sendMessage');
});
it('falls back to sendMessage when multipart sendPhoto returns a Telegram error', async () => {
mockGlobalFetch.mockResolvedValueOnce(imageOk());
mockNodeFetch
.mockResolvedValueOnce(jsonErr(400, { description: 'broke' })) // multipart sendPhoto
.mockResolvedValueOnce(jsonOk()); // sendMessage fallback
await send({
serviceName: 'immowelt',
newListings: [
{
id: 'a',
title: 't',
link: 'l',
address: 'a',
price: '',
size: '',
image: 'https://example.com/x.webp',
},
],
notificationConfig: [baseConfig],
jobKey: 'Berlin',
});
expect(mockNodeFetch).toHaveBeenCalledTimes(2);
expect(mockNodeFetch.mock.calls[1][0]).toBe('https://api.telegram.org/botTKN/sendMessage');
});
});
describe('telegram send() - mixed batch (regression-safety)', () => {
it('handles a batch with both .jpg and .webp - jpg uses URL, webp uses multipart', async () => {
// .webp image fetch
mockGlobalFetch.mockResolvedValueOnce(imageOk());
// both sendPhoto calls succeed
mockNodeFetch
.mockResolvedValueOnce(jsonOk()) // could be either listing first
.mockResolvedValueOnce(jsonOk());
await send({
serviceName: 'immowelt',
newListings: [
{
id: 'jpg-listing',
title: 'a',
link: 'l',
address: 'a',
price: '',
size: '',
image: 'https://example.com/a.jpg',
},
{
id: 'webp-listing',
title: 'b',
link: 'l',
address: 'a',
price: '',
size: '',
image: 'https://example.com/b.webp',
},
],
notificationConfig: [baseConfig],
jobKey: 'Berlin',
});
expect(mockGlobalFetch).toHaveBeenCalledTimes(1); // only webp pre-fetches
expect(mockNodeFetch).toHaveBeenCalledTimes(2);
// Verify one call had FormData and one had JSON body
const bodies = mockNodeFetch.mock.calls.map((c) => c[1].body);
const hasFormData = bodies.some((b) => b instanceof FormData);
const hasJson = bodies.some((b) => typeof b === 'string' && b.startsWith('{'));
expect(hasFormData).toBe(true);
expect(hasJson).toBe(true);
});
it('uses sendMessage (not sendPhoto) when image is null', async () => {
mockNodeFetch.mockResolvedValueOnce(jsonOk());
await send({
serviceName: 'immowelt',
newListings: [
{
id: 'a',
title: 't',
link: 'l',
address: 'a',
price: '',
size: '',
image: null,
},
],
notificationConfig: [baseConfig],
jobKey: 'Berlin',
});
expect(mockNodeFetch).toHaveBeenCalledTimes(1);
expect(mockNodeFetch.mock.calls[0][0]).toBe('https://api.telegram.org/botTKN/sendMessage');
expect(mockGlobalFetch).not.toHaveBeenCalled();
});
});
describe('telegram send() - multiple chat IDs', () => {
const listing = {
id: '1',
title: 'Flat',
link: 'https://ex.com',
address: 'Berlin',
price: '800',
size: '50',
image: 'https://ex.com/img.jpg',
};
it('sends to every chat ID in a comma-separated list', async () => {
mockNodeFetch.mockResolvedValue(jsonOk());
await send({
serviceName: 'immoscout',
newListings: [listing],
notificationConfig: [{ id: 'telegram', fields: { token: 'TKN', chatId: '111, 222' } }],
jobKey: 'Berlin',
});
expect(mockNodeFetch).toHaveBeenCalledTimes(2);
const bodies = mockNodeFetch.mock.calls.map((c) => JSON.parse(c[1].body));
expect(bodies.map((b) => b.chat_id)).toEqual(expect.arrayContaining(['111', '222']));
});
it('trims whitespace around each chat ID', async () => {
mockNodeFetch.mockResolvedValue(jsonOk());
await send({
serviceName: 'immoscout',
newListings: [listing],
notificationConfig: [{ id: 'telegram', fields: { token: 'TKN', chatId: ' 333 , 444 ' } }],
jobKey: 'Berlin',
});
expect(mockNodeFetch).toHaveBeenCalledTimes(2);
const bodies = mockNodeFetch.mock.calls.map((c) => JSON.parse(c[1].body));
expect(bodies.map((b) => b.chat_id)).toEqual(expect.arrayContaining(['333', '444']));
});
it('sends each listing to each chat ID (N listings × M chats)', async () => {
mockNodeFetch.mockResolvedValue(jsonOk());
await send({
serviceName: 'immoscout',
newListings: [listing, { ...listing, id: '2' }],
notificationConfig: [{ id: 'telegram', fields: { token: 'TKN', chatId: '555, 666' } }],
jobKey: 'Berlin',
});
expect(mockNodeFetch).toHaveBeenCalledTimes(4);
});
});
describe('telegram send() - config validation', () => {
it('throws when telegram adapter config is missing', () => {
expect(() =>
send({
serviceName: 's',
newListings: [],
notificationConfig: [],
jobKey: 'k',
}),
).toThrow(/configuration missing/);
});
it('throws when token or chatId is missing', () => {
expect(() =>
send({
serviceName: 's',
newListings: [],
notificationConfig: [{ id: 'telegram', fields: { token: '' } }],
jobKey: 'k',
}),
).toThrow(/token.*chatId/);
});
});

View File

@@ -0,0 +1,287 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { shouldUseMultipart, buildPhotoFormData } from '../../lib/notification/adapter/telegramPhotoUploader.js';
describe('shouldUseMultipart', () => {
it('returns true for .webp URL with query string', () => {
expect(shouldUseMultipart('https://mms.immowelt.de/1/1/6/5/abc.webp?ci_seal=hash&w=525&h=394')).toBe(true);
});
it('returns true for .webp URL without query string', () => {
expect(shouldUseMultipart('https://example.com/photo.webp')).toBe(true);
});
it('returns true for uppercase .WEBP extension', () => {
expect(shouldUseMultipart('https://example.com/IMG.WEBP?x=1')).toBe(true);
});
it('returns false for .jpg URL with query string', () => {
expect(shouldUseMultipart('https://mms.immowelt.de/a/b/c/d/xyz.jpg?ci_seal=hash&w=525&h=394')).toBe(false);
});
it('returns false for .jpeg URL', () => {
expect(shouldUseMultipart('https://example.com/photo.jpeg')).toBe(false);
});
it('returns false for .png URL with query string', () => {
expect(shouldUseMultipart('https://example.com/photo.png?w=100')).toBe(false);
});
it('returns false for .gif URL', () => {
expect(shouldUseMultipart('https://example.com/photo.gif')).toBe(false);
});
it('returns false for null', () => {
expect(shouldUseMultipart(null)).toBe(false);
});
it('returns false for undefined', () => {
expect(shouldUseMultipart(undefined)).toBe(false);
});
it('returns false for empty string', () => {
expect(shouldUseMultipart('')).toBe(false);
});
it('returns false for malformed URL', () => {
expect(shouldUseMultipart('not a url')).toBe(false);
});
it('returns false for URL where webp is in the query but not the path', () => {
expect(shouldUseMultipart('https://example.com/photo.jpg?format=webp')).toBe(false);
});
it('returns false for URL with no extension at all', () => {
expect(shouldUseMultipart('https://example.com/photo')).toBe(false);
});
it('returns false for non-https schemes', () => {
// file/data/ftp URLs should not be relevant; safer to skip multipart
expect(shouldUseMultipart('http://example.com/photo.webp')).toBe(false);
});
});
describe('buildPhotoFormData', () => {
let mockFetch;
beforeEach(() => {
mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);
});
afterEach(() => {
vi.unstubAllGlobals();
});
function makeImageResponse({ contentType = 'image/jpeg', bytes = new Uint8Array([0xff, 0xd8, 0xff]) } = {}) {
const buf = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
return {
ok: true,
status: 200,
headers: {
get: (h) =>
h.toLowerCase() === 'content-type'
? contentType
: h.toLowerCase() === 'content-length'
? String(bytes.byteLength)
: null,
},
arrayBuffer: async () => buf,
};
}
it('fetches image with Accept header that excludes webp so the CDN transcodes to JPEG', async () => {
mockFetch.mockResolvedValueOnce(makeImageResponse());
await buildPhotoFormData({
chatId: '123',
imageUrl: 'https://example.com/photo.webp',
caption: 'hi',
parseMode: 'HTML',
});
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, opts] = mockFetch.mock.calls[0];
expect(url).toBe('https://example.com/photo.webp');
expect(opts?.headers?.Accept || opts?.headers?.accept).toMatch(/image\/jpeg/);
expect(opts?.headers?.Accept || opts?.headers?.accept).not.toMatch(/image\/webp/);
});
it('returns FormData containing chat_id, caption, parse_mode, and photo fields', async () => {
mockFetch.mockResolvedValueOnce(makeImageResponse());
const fd = await buildPhotoFormData({
chatId: '12345',
imageUrl: 'https://example.com/abc.webp',
caption: 'My caption',
parseMode: 'HTML',
});
expect(fd).toBeInstanceOf(FormData);
expect(fd.get('chat_id')).toBe('12345');
expect(fd.get('caption')).toBe('My caption');
expect(fd.get('parse_mode')).toBe('HTML');
const photo = fd.get('photo');
expect(photo).toBeTruthy();
// File-like (Blob); has a name and a size
expect(typeof photo.name).toBe('string');
expect(photo.size).toBeGreaterThan(0);
});
it('uses a .jpg filename (Telegram uses URL/filename extension for type detection)', async () => {
mockFetch.mockResolvedValueOnce(makeImageResponse());
const fd = await buildPhotoFormData({
chatId: '1',
imageUrl: 'https://example.com/source.webp',
caption: 'c',
parseMode: 'HTML',
});
const photo = fd.get('photo');
expect(photo.name).toMatch(/\.jpg$/i);
});
it('includes message_thread_id when provided', async () => {
mockFetch.mockResolvedValueOnce(makeImageResponse());
const fd = await buildPhotoFormData({
chatId: '1',
imageUrl: 'https://example.com/source.webp',
caption: 'c',
parseMode: 'HTML',
messageThreadId: 42,
});
expect(fd.get('message_thread_id')).toBe('42');
});
it('omits message_thread_id when not provided', async () => {
mockFetch.mockResolvedValueOnce(makeImageResponse());
const fd = await buildPhotoFormData({
chatId: '1',
imageUrl: 'https://example.com/source.webp',
caption: 'c',
parseMode: 'HTML',
});
expect(fd.get('message_thread_id')).toBeNull();
});
it('omits parse_mode when not provided (plain text mode)', async () => {
mockFetch.mockResolvedValueOnce(makeImageResponse());
const fd = await buildPhotoFormData({
chatId: '1',
imageUrl: 'https://example.com/source.webp',
caption: 'c',
});
expect(fd.get('parse_mode')).toBeNull();
});
it('throws when the image fetch returns non-200', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
headers: { get: () => null },
arrayBuffer: async () => new ArrayBuffer(0),
});
await expect(
buildPhotoFormData({
chatId: '1',
imageUrl: 'https://example.com/gone.webp',
caption: 'c',
parseMode: 'HTML',
}),
).rejects.toThrow(/404/);
});
it('throws when the image fetch throws (network error)', async () => {
mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED'));
await expect(
buildPhotoFormData({
chatId: '1',
imageUrl: 'https://example.com/x.webp',
caption: 'c',
parseMode: 'HTML',
}),
).rejects.toThrow(/ECONNREFUSED/);
});
it('throws when the image exceeds 10 MB (Telegram multipart limit)', async () => {
// 11 MB
const big = new Uint8Array(11 * 1024 * 1024);
mockFetch.mockResolvedValueOnce(makeImageResponse({ bytes: big }));
await expect(
buildPhotoFormData({
chatId: '1',
imageUrl: 'https://example.com/huge.webp',
caption: 'c',
parseMode: 'HTML',
}),
).rejects.toThrow(/size|large|10/i);
});
it('rejects early when content-length header advertises > 10 MB (avoids download)', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
headers: {
get: (h) => {
const k = h.toLowerCase();
if (k === 'content-type') return 'image/jpeg';
if (k === 'content-length') return String(50 * 1024 * 1024);
return null;
},
},
arrayBuffer: async () => {
throw new Error('should not be called - size check should reject first');
},
});
await expect(
buildPhotoFormData({
chatId: '1',
imageUrl: 'https://example.com/huge.webp',
caption: 'c',
parseMode: 'HTML',
}),
).rejects.toThrow(/size|large|10/i);
});
it('accepts exactly 10 MB images (boundary)', async () => {
const bytes = new Uint8Array(10 * 1024 * 1024);
mockFetch.mockResolvedValueOnce(makeImageResponse({ bytes }));
const fd = await buildPhotoFormData({
chatId: '1',
imageUrl: 'https://example.com/exact.webp',
caption: 'c',
parseMode: 'HTML',
});
expect(fd.get('photo').size).toBe(10 * 1024 * 1024);
});
it('coerces non-string chatId (number) to string in form data', async () => {
mockFetch.mockResolvedValueOnce(makeImageResponse());
const fd = await buildPhotoFormData({
chatId: 999,
imageUrl: 'https://example.com/x.webp',
caption: 'c',
parseMode: 'HTML',
});
expect(fd.get('chat_id')).toBe('999');
});
});

View File

@@ -38,6 +38,20 @@ async function tryReadFile(filepath) {
}
}
function withRealEstateType(data, realEstateType) {
if (!realEstateType?.length || !Array.isArray(data?.resultListItems)) {
return data;
}
const cloned = typeof structuredClone === 'function' ? structuredClone(data) : JSON.parse(JSON.stringify(data));
for (const item of cloned.resultListItems) {
if (item?.type === 'EXPOSE_RESULT' && item?.item) {
item.item.realEstateType = realEstateType;
}
}
return cloned;
}
/**
* Returns fixture HTML for the given URL by mapping hostname → provider name,
* then distinguishing list vs detail pages by comparing the URL path against
@@ -83,7 +97,10 @@ export function buildFetchMock() {
const raw = await tryReadFile(path.join(FIXTURES_DIR, 'immoscout_list.json'));
listData = raw ? JSON.parse(raw) : { resultListItems: [] };
}
return { ok: true, status: 200, json: () => Promise.resolve(listData) };
const requestedType = new URL(urlStr).searchParams.get('realestatetype');
const responseData = withRealEstateType(listData, requestedType);
return { ok: true, status: 200, json: () => Promise.resolve(responseData) };
}
if (urlStr.includes('api.mobile.immobilienscout24.de/expose/')) {

View File

@@ -0,0 +1,37 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { vi, describe, it, expect, beforeEach } from 'vitest';
// Mock the CloakBrowser launcher so no real Chromium binary is needed and we can
// assert which options get forwarded to it.
const { launchMock } = vi.hoisted(() => ({ launchMock: vi.fn() }));
vi.mock('cloakbrowser/puppeteer', () => ({
launch: launchMock,
}));
const { launchBrowser } = await import('../../../lib/services/extractor/puppeteerExtractor.js');
describe('launchBrowser proxy forwarding', () => {
beforeEach(() => {
launchMock.mockReset();
launchMock.mockResolvedValue({ close: async () => {} });
});
it('forwards proxyUrl to CloakBrowser as the proxy option', async () => {
await launchBrowser('https://www.immowelt.de/', { proxyUrl: 'http://user:pass@host:8080' });
expect(launchMock).toHaveBeenCalledTimes(1);
expect(launchMock.mock.calls[0][0]).toMatchObject({ proxy: 'http://user:pass@host:8080' });
});
it('does not set a proxy when no proxyUrl is given', async () => {
await launchBrowser('https://www.immowelt.de/', {});
expect(launchMock).toHaveBeenCalledTimes(1);
expect(launchMock.mock.calls[0][0].proxy).toBeUndefined();
});
});

View File

@@ -4,11 +4,16 @@
*/
import { convertWebToMobile } from '../../../lib/services/immoscout/immoscout-web-translator.js';
import { expect } from 'vitest';
import { expect, vi } from 'vitest';
import { readFile } from 'fs/promises';
import { buildFetchMock } from '../../offlineFixtures.js';
export const testData = JSON.parse(await readFile(new URL('./testdata.json', import.meta.url)));
if (process.env.TEST_MODE === 'offline') {
vi.stubGlobal('fetch', buildFetchMock());
}
describe('#immoscout-mobile URL conversion', () => {
// Test shape URL conversion
it('should convert a full web URL with shape to mobile URL', () => {
@@ -41,6 +46,60 @@ describe('#immoscout-mobile URL conversion', () => {
expect(queryParams.get('equipment').split(',')).toEqual(expect.arrayContaining(['garden', 'balcony']));
});
// Test URL conversion of SEO web path for max warmrent. The ImmoScout web UI
// generates this special SEO slug instead of explicit price/pricetype params
// when the user configures a "Warmmiete" filter (real-world URL).
it('should convert a SEO apartment max warmrent path to rent + price + pricetype', () => {
const webUrl =
'https://www.immobilienscout24.de/Suche/de/nordrhein-westfalen/duesseldorf/wohnung-bis-800-euro-warm?livingspace=-800.0&enteredFrom=result_list';
const converted = convertWebToMobile(webUrl);
const queryParams = new URL(converted).searchParams;
expect(queryParams.get('realestatetype')).toBe('apartmentrent');
expect(queryParams.get('price')).toBe('-800');
expect(queryParams.get('pricetype')).toBe('calculatedtotalrent');
expect(queryParams.get('geocodes')).toBe('/de/nordrhein-westfalen/duesseldorf');
expect(queryParams.get('livingspace')).toBe('-800.0');
});
// Same SEO pattern for houses ("haus-bis-X-euro-warm" → houserent).
it('should convert a SEO house max warmrent path to rent + price + pricetype', () => {
const webUrl = 'https://www.immobilienscout24.de/Suche/de/berlin/berlin/haus-bis-1500-euro-warm';
const converted = convertWebToMobile(webUrl);
const queryParams = new URL(converted).searchParams;
expect(queryParams.get('realestatetype')).toBe('houserent');
expect(queryParams.get('price')).toBe('-1500');
expect(queryParams.get('pricetype')).toBe('calculatedtotalrent');
});
// Sanity check: max coldrent ("Kaltmiete") does NOT use an SEO slug. The web
// UI keeps the regular "wohnung-mieten" path and passes explicit
// price + pricetype query params, which the existing translator already
// handles (real-world URL).
it('should convert a max coldrent search via the regular wohnung-mieten path', () => {
const webUrl =
'https://www.immobilienscout24.de/Suche/de/nordrhein-westfalen/duesseldorf/wohnung-mieten?price=-800.0&livingspace=-800.0&pricetype=rentpermonth&enteredFrom=result_list';
const converted = convertWebToMobile(webUrl);
const queryParams = new URL(converted).searchParams;
expect(queryParams.get('realestatetype')).toBe('apartmentrent');
expect(queryParams.get('price')).toBe('-800.0');
expect(queryParams.get('pricetype')).toBe('rentpermonth');
expect(queryParams.get('geocodes')).toBe('/de/nordrhein-westfalen/duesseldorf');
});
// Explicit query params win over the SEO slug's implicit defaults.
it('should let explicit query params override SEO path price defaults', () => {
const webUrl = 'https://www.immobilienscout24.de/Suche/de/berlin/berlin/wohnung-bis-800-euro-warm?price=100-500';
const converted = convertWebToMobile(webUrl);
const queryParams = new URL(converted).searchParams;
expect(queryParams.get('realestatetype')).toBe('apartmentrent');
expect(queryParams.get('price')).toBe('100-500');
expect(queryParams.get('pricetype')).toBe('calculatedtotalrent');
});
// Test URL conversion with unsupported query parameters
it('should remove unsupported query parameters', () => {
const webUrl = 'https://www.immobilienscout24.de/Suche/de/berlin/berlin/wohnung-mieten?minimuminternetspeed=100000';

View File

@@ -18,6 +18,7 @@ describe('services/jobs/jobExecutionService', () => {
const busPath = root + '/lib/services/events/event-bus.js';
const jobStoragePath = root + '/lib/services/storage/jobStorage.js';
const userStoragePath = root + '/lib/services/storage/userStorage.js';
const settingsStoragePath = root + '/lib/services/storage/settingsStorage.js';
const brokerPath = root + '/lib/services/sse/sse-broker.js';
const utilsPath = root + '/lib/utils.js';
const loggerPath = root + '/lib/services/logger.js';
@@ -33,11 +34,15 @@ describe('services/jobs/jobExecutionService', () => {
getUsers: () => state.users.slice(),
getUser: (id) => state.users.find((u) => u.id === id) || null,
}));
vi.doMock(settingsStoragePath, () => ({
getSettings: async () => ({}),
}));
vi.doMock(brokerPath, () => ({
sendToUsers: (...args) => calls.sent.push(args),
}));
vi.doMock(utilsPath, () => ({
duringWorkingHoursOrNotSet: () => false,
getPackageVersion: async () => '0.0.0-test',
}));
vi.doMock(loggerPath, () => {
const m = { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} };

View File

@@ -0,0 +1,193 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { vi, describe, it, expect, beforeEach } from 'vitest';
// We mock SqliteConnection so we can assert which SQL the storage layer
// runs and with which params, without spinning up a real SQLite DB.
const calls = {
execute: [],
query: [],
};
const sqliteMock = {
execute: (sql, params) => {
calls.execute.push({ sql, params });
// Default: pretend 1 row was affected (so setListingStatus reports success).
return { changes: 1 };
},
query: (sql, params) => {
calls.query.push({ sql, params });
// Return shape varies by test — overridden via queryHandler when needed.
if (sqliteMock.__queryHandler) return sqliteMock.__queryHandler(sql, params);
return [];
},
__queryHandler: null,
};
vi.mock('../../lib/services/storage/SqliteConnection.js', () => ({
default: sqliteMock,
}));
describe('listingsStorage.setListingStatus', () => {
let listingsStorage;
beforeEach(async () => {
calls.execute.length = 0;
calls.query.length = 0;
sqliteMock.__queryHandler = null;
listingsStorage = await import('../../lib/services/storage/listingsStorage.js');
});
it('runs an UPDATE storing a JSON payload with status and setAt', () => {
const before = Date.now();
const changes = listingsStorage.setListingStatus('listing-1', 'Applied');
const after = Date.now();
expect(changes).toBe(1);
expect(calls.execute).toHaveLength(1);
expect(calls.execute[0].sql).toMatch(/UPDATE listings SET status = @status WHERE id = @id/);
expect(calls.execute[0].params.id).toBe('listing-1');
const parsed = JSON.parse(calls.execute[0].params.status);
expect(parsed.status).toBe('applied');
expect(parsed.setAt).toBeGreaterThanOrEqual(before);
expect(parsed.setAt).toBeLessThanOrEqual(after);
});
it('accepts null to clear the status (no JSON wrapping)', () => {
listingsStorage.setListingStatus('listing-2', null);
expect(calls.execute[0].params).toEqual({ id: 'listing-2', status: null });
});
it('rejects invalid statuses', () => {
expect(() => listingsStorage.setListingStatus('listing-3', 'maybe')).toThrow(/Invalid listing status/);
expect(calls.execute).toHaveLength(0);
});
it('returns 0 when no id is supplied (no SQL is run)', () => {
const result = listingsStorage.setListingStatus(null, 'applied');
expect(result).toBe(0);
expect(calls.execute).toHaveLength(0);
});
});
describe('listingsStorage.queryListings statusFilter', () => {
let listingsStorage;
beforeEach(async () => {
calls.execute.length = 0;
calls.query.length = 0;
// Return empty rows for both the count and the page-fetch queries.
sqliteMock.__queryHandler = (sql) => {
if (/COUNT\(1\)/.test(sql)) return [{ cnt: 0 }];
return [];
};
listingsStorage = await import('../../lib/services/storage/listingsStorage.js');
});
it("adds 'l.status IS NULL' to WHERE when statusFilter is 'none'", () => {
listingsStorage.queryListings({ statusFilter: 'none', userId: 'u1', isAdmin: true });
const pageQuery = calls.query.find((c) => !/COUNT\(1\)/.test(c.sql));
expect(pageQuery.sql).toMatch(/\(l\.status IS NULL\)/);
});
it('extracts the inner status field via json_extract for a concrete status', () => {
listingsStorage.queryListings({ statusFilter: 'applied', userId: 'u1', isAdmin: true });
const pageQuery = calls.query.find((c) => !/COUNT\(1\)/.test(c.sql));
expect(pageQuery.sql).toMatch(/json_extract\(l\.status, '\$\.status'\) = @statusValue/);
expect(pageQuery.params.statusValue).toBe('applied');
});
it('ignores unknown statusFilter values silently', () => {
listingsStorage.queryListings({ statusFilter: 'bogus', userId: 'u1', isAdmin: true });
const pageQuery = calls.query.find((c) => !/COUNT\(1\)/.test(c.sql));
expect(pageQuery.sql).not.toMatch(/status/i);
});
it('parses the JSON status payload of returned rows into an object', () => {
sqliteMock.__queryHandler = (sql) => {
if (/COUNT\(1\)/.test(sql)) return [{ cnt: 2 }];
return [
{ id: 'a', status: JSON.stringify({ status: 'applied', setAt: 1700000000000 }) },
{ id: 'b', status: null },
];
};
const result = listingsStorage.queryListings({ userId: 'u1', isAdmin: true });
expect(result.result[0].status).toEqual({ status: 'applied', setAt: 1700000000000 });
expect(result.result[1].status).toBeNull();
});
});
describe('listingsStorage.getListingById', () => {
let listingsStorage;
beforeEach(async () => {
calls.execute.length = 0;
calls.query.length = 0;
listingsStorage = await import('../../lib/services/storage/listingsStorage.js');
});
it('parses the JSON status payload of the returned row', () => {
sqliteMock.__queryHandler = () => [
{ id: 'a', status: JSON.stringify({ status: 'rejected', setAt: 1700000000001 }) },
];
const row = listingsStorage.getListingById('a', 'u1', true);
expect(row.status).toEqual({ status: 'rejected', setAt: 1700000000001 });
});
it('returns null status untouched', () => {
sqliteMock.__queryHandler = () => [{ id: 'a', status: null }];
const row = listingsStorage.getListingById('a', 'u1', true);
expect(row.status).toBeNull();
});
it('returns null when no row is found', () => {
sqliteMock.__queryHandler = () => [];
const row = listingsStorage.getListingById('missing', 'u1', true);
expect(row).toBeNull();
});
});
describe('watchListStorage.ensureWatch', () => {
let watchListStorage;
beforeEach(async () => {
calls.execute.length = 0;
calls.query.length = 0;
sqliteMock.__queryHandler = null;
watchListStorage = await import('../../lib/services/storage/watchListStorage.js');
});
it('inserts and reports watched=true on first call', () => {
// After INSERT, createWatch queries for existence and gets a row back.
sqliteMock.__queryHandler = () => [{ ok: 1 }];
const result = watchListStorage.ensureWatch('listing-1', 'user-1');
expect(result).toEqual({ watched: true });
// INSERT should have been issued.
expect(calls.execute.some((c) => /INSERT INTO watch_list/.test(c.sql))).toBe(true);
});
it('returns watched=true when an entry already exists', () => {
// Simulate ON CONFLICT being a no-op: execute reports no changes, then SELECT confirms row exists.
sqliteMock.execute = (sql, params) => {
calls.execute.push({ sql, params });
return { changes: 0 };
};
sqliteMock.__queryHandler = () => [{ ok: 1 }];
const result = watchListStorage.ensureWatch('listing-2', 'user-2');
expect(result).toEqual({ watched: true });
// Restore execute to default for subsequent tests.
sqliteMock.execute = (sql, params) => {
calls.execute.push({ sql, params });
return { changes: 1 };
};
});
it('returns watched=false when listingId or userId is missing', () => {
expect(watchListStorage.ensureWatch(null, 'u')).toEqual({ watched: false });
expect(watchListStorage.ensureWatch('l', null)).toEqual({ watched: false });
expect(calls.execute).toHaveLength(0);
});
});

View File

@@ -155,6 +155,7 @@ const routes = {
'GET /api/dashboard': dashboard,
'GET /api/demo': { demoMode: false },
'POST /api/user/settings/news-hash': {},
'POST /api/user/settings/listing-deletion-preference': {},
};
const server = http.createServer((req, res) => {

View File

@@ -95,7 +95,10 @@ async function downloadHtmlProvider(name, providerConfig, launchBrowser, closeBr
const browser = await launchBrowser(providerConfig.url, {});
try {
const html = await puppeteerExtractor(providerConfig.url, providerConfig.waitForSelector, { browser });
const html = await puppeteerExtractor(providerConfig.url, providerConfig.waitForSelector, {
browser,
name: 'dowload_fixtures',
});
if (!html) {
console.warn(` Failed to download ${name}`);

View File

@@ -18,7 +18,7 @@ import Jobs from './views/jobs/Jobs';
import './App.less';
import TrackingModal from './components/tracking/TrackingModal.jsx';
import { Banner } from '@douyinfe/semi-ui-19';
import { Banner, LocaleProvider } 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';
@@ -29,6 +29,17 @@ import WatchlistManagement from './views/listings/management/WatchlistManagement
import Dashboard from './views/dashboard/Dashboard.jsx';
import ListingDetail from './views/listings/ListingDetail.jsx';
import NewsModal from './components/news/NewsModal.jsx';
import { I18nProvider, availableLanguages } from './services/i18n/i18n.jsx';
const semiLocaleModules = import.meta.glob('/node_modules/@douyinfe/semi-ui-19/lib/es/locale/source/*.js', {
eager: true,
});
const semiLocales = {};
for (const [path, mod] of Object.entries(semiLocaleModules)) {
const name = path.match(/\/source\/(\w+)\.js$/)?.[1];
if (name) semiLocales[name] = mod.default ?? mod;
}
export default function FredyApp() {
const actions = useActions();
@@ -36,6 +47,7 @@ export default function FredyApp() {
const currentUser = useSelector((state) => state.user.currentUser);
const versionUpdate = useSelector((state) => state.versionUpdate.versionUpdate);
const settings = useSelector((state) => state.generalSettings.settings);
const language = useSelector((state) => state.userSettings.settings.language);
useEffect(() => {
async function init() {
@@ -63,78 +75,89 @@ export default function FredyApp() {
const isAdmin = () => currentUser != null && currentUser.isAdmin;
const { Sider, Content } = Layout;
return loading ? null : needsLogin() ? (
<Routes>
<Route path="/login" element={<Login />} />
<Route path="*" element={<Navigate to="/login" replace />} />
</Routes>
) : (
<Layout className="app">
<Sider>
<Navigation isAdmin={isAdmin()} />
</Sider>
<Layout className="app__main">
<Content className="app__content">
{versionUpdate?.newVersion && <VersionBanner />}
{settings.demoMode && (
<>
<Banner
fullMode={true}
type="info"
bordered
closeIcon={null}
description="You're currently viewing the demo version of Fredy. Jobs won't scrape websites, and any changes you make will be reverted at midnight."
/>
<br />
</>
)}
{settings.analyticsEnabled === null && !settings.demoMode && <TrackingModal />}
{!settings.demoMode && <NewsModal />}
return loading ? null : (
<I18nProvider language={language ?? 'en'}>
<LocaleProvider
locale={
semiLocales[availableLanguages.find((l) => l.code === (language ?? 'en'))?.semiLocale] ?? semiLocales['en_US']
}
>
{needsLogin() ? (
<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={<Navigate to="/generalSettings" replace />} />
<Route path="/generalSettings" element={<GeneralSettings />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/login" element={<Login />} />
<Route path="*" element={<Navigate to="/login" replace />} />
</Routes>
</Content>
<FredyFooter />
</Layout>
</Layout>
) : (
<Layout className="app">
<Sider>
<Navigation isAdmin={isAdmin()} />
</Sider>
<Layout className="app__main">
<Content className="app__content">
{versionUpdate?.newVersion && <VersionBanner />}
{settings.demoMode && (
<>
<Banner
fullMode={true}
type="info"
bordered
closeIcon={null}
description="You're currently viewing the demo version of Fredy. Jobs won't scrape websites, and any changes you make will be reverted at midnight."
/>
<br />
</>
)}
{settings.analyticsEnabled === null && !settings.demoMode && <TrackingModal />}
{!settings.demoMode && <NewsModal />}
<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/watchlist" element={<Listings mode="watchlist" />} />
<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={<Navigate to="/generalSettings" replace />} />
<Route path="/generalSettings" element={<GeneralSettings />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
</Routes>
</Content>
<FredyFooter />
</Layout>
</Layout>
)}
</LocaleProvider>
</I18nProvider>
);
}

BIN
ui/src/assets/news/1.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 835 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 367 KiB

View File

@@ -1,16 +1,11 @@
{
"key": "00e6b81777a275f5a140fc9101cb943810db6a69f6eb3927319c5aee0c876221",
"key": "00e6b81777a275f5a140fc9101cb94eef0db6a69f6eb3927319c5aee0c876221",
"content":
[
{
"title": "Table overview for listings",
"text": "Thanks to https://github.com/datenwurm, we now have a table overview for listings. If you decide to use the table view, the decision will be stored.",
"media": "1.png"
},
{
"title": "Table overview for jobs",
"text": "Based on datenwurm's, work, I created a table overview for jobs. If you decide to use the table view, the decision will be stored.",
"media": "2.png"
"title": "Fredy goes multilingual!",
"text": "Fredy now supports multiple languages (Starting with german and english). You can select the language in the user-settings.",
"media": "1.jpg"
}
]
}

View File

@@ -3,8 +3,9 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { useState } from 'react';
import { Modal, Radio, RadioGroup, Typography } from '@douyinfe/semi-ui-19';
import { useState, useEffect } from 'react';
import { Modal, Radio, RadioGroup, Typography, Checkbox } from '@douyinfe/semi-ui-19';
import { useTranslation } from '../services/i18n/i18n.jsx';
const { Text } = Typography;
@@ -12,56 +13,71 @@ const ListingDeletionModal = ({
visible,
onConfirm,
onCancel,
title = 'Delete Listings',
title,
showOptions = true,
message = 'How would you like to delete the selected listing(s)?',
message,
defaultDeleteType = 'soft',
}) => {
const t = useTranslation();
const resolvedTitle = title ?? t('listing.deletion.title');
const resolvedMessage = message ?? t('listing.deletion.message');
const [deleteType, setDeleteType] = useState('soft');
const [remember, setRemember] = useState(false);
useEffect(() => {
if (visible) {
setDeleteType(defaultDeleteType);
setRemember(false);
}
}, [visible, defaultDeleteType]);
const handleOk = () => {
onConfirm(!showOptions || deleteType === 'hard');
if (showOptions) {
onConfirm(deleteType === 'hard', remember);
} else {
onConfirm(true);
}
};
return (
<Modal
title={title}
title={resolvedTitle}
visible={visible}
onOk={handleOk}
onCancel={onCancel}
okText="Confirm"
cancelText="Cancel"
okText={t('listing.deletion.confirm')}
cancelText={t('listing.deletion.cancel')}
style={{ maxWidth: '500px' }}
>
<div style={{ marginBottom: 16 }}>
<Text>{message}</Text>
<Text>{resolvedMessage}</Text>
</div>
{showOptions && (
<RadioGroup value={deleteType} onChange={(e) => setDeleteType(e.target.value)} style={{ width: '100%' }}>
<Radio value="soft" style={{ alignItems: 'flex-start', width: '100%' }}>
<div style={{ marginLeft: 8 }}>
<Text strong>Mark as deleted (Soft Delete)</Text>
<br />
<Text type="secondary">
Listings are kept in the database but marked as hidden. They will <b>not</b> re-appear during the next
scraping session.
</Text>
</div>
</Radio>
<Radio value="hard" style={{ marginTop: 16, alignItems: 'flex-start', width: '100%' }}>
<div style={{ marginLeft: 8 }}>
<Text strong>Remove from database (Hard Delete)</Text>
<br />
<Text type="secondary">
Listings are completely removed from the database.
<>
<RadioGroup value={deleteType} onChange={(e) => setDeleteType(e.target.value)} style={{ width: '100%' }}>
<Radio value="soft" style={{ alignItems: 'flex-start', width: '100%' }}>
<div style={{ marginLeft: 8 }}>
<Text strong>{t('listing.deletion.softLabel')}</Text>
<br />
<Text type="warning">
Consequence: They might re-appear when scraping the next time because Fredy won't know they were
previously found.
<Text type="secondary">{t('listing.deletion.softDescription')}</Text>
</div>
</Radio>
<Radio value="hard" style={{ marginTop: 16, alignItems: 'flex-start', width: '100%' }}>
<div style={{ marginLeft: 8 }}>
<Text strong>{t('listing.deletion.hardLabel')}</Text>
<br />
<Text type="secondary">
{t('listing.deletion.hardDescription')}
<br />
<Text type="warning">{t('listing.deletion.hardConsequence')}</Text>
</Text>
</Text>
</div>
</Radio>
</RadioGroup>
</div>
</Radio>
</RadioGroup>
<Checkbox checked={remember} onChange={(e) => setRemember(e.target.checked)} style={{ marginTop: 16 }}>
{t('listing.deletion.rememberChoice')}
</Checkbox>
</>
)}
</Modal>
);

View File

@@ -8,10 +8,12 @@ import { Pie } from 'react-chartjs-2';
import { Chart as ChartJS, ArcElement, Tooltip, Legend, Title as ChartTitle } from 'chart.js';
import './ChartCard.less';
import { useTranslation } from '../../services/i18n/i18n.jsx';
ChartJS.register(ArcElement, Tooltip, Legend, ChartTitle);
export default function PieChartCard({ data = [] }) {
const t = useTranslation();
const { labels, values } = React.useMemo(() => {
if (data && typeof data === 'object' && !Array.isArray(data)) {
const lbls = Array.isArray(data.labels) ? data.labels : [];
@@ -92,6 +94,12 @@ export default function PieChartCard({ data = [] }) {
const isEmpty = !labels || labels.length === 0 || !values || values.length === 0;
return (
<>{isEmpty ? <div className="chartCard__no__data">No Data</div> : <Pie data={chartData} options={options} />}</>
<>
{isEmpty ? (
<div className="chartCard__no__data">{t('dashboard.noData')}</div>
) : (
<Pie data={chartData} options={options} />
)}
</>
);
}

View File

@@ -6,16 +6,18 @@
import './FredyFooter.less';
import { useSelector } from '../../services/state/store.js';
import { Layout } from '@douyinfe/semi-ui-19';
import { useTranslation } from '../../services/i18n/i18n.jsx';
export default function FredyFooter() {
const t = useTranslation();
const { Footer } = Layout;
const version = useSelector((state) => state.versionUpdate.versionUpdate);
return (
<Footer className="fredyFooter">
<span className="fredyFooter__version">Fredy v{version?.localFredyVersion || 'N/A'}</span>
<span className="fredyFooter__version">Fredy v{version?.localFredyVersion || t('common.na')}</span>
<span className="fredyFooter__credit">
Made with by{' '}
{t('footer.madeWith')}{' '}
<a href="https://github.com/orangecoding" target="_blank" rel="noreferrer">
Christian Kellner
</a>

View File

@@ -48,18 +48,22 @@ import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-i
import JobsTable from '../../table/JobsTable.jsx';
import './JobGrid.less';
import { useTranslation } from '../../../services/i18n/i18n.jsx';
const { Text, Title } = Typography;
const getPopoverContent = (text) => <article className="jobPopoverContent">{text}</article>;
const JobGrid = () => {
const t = useTranslation();
const jobsData = useSelector((state) => state.jobsData);
const actions = useActions();
const navigate = useNavigate();
const userSettings = useSelector((state) => state.userSettings.settings);
const viewMode = userSettings?.jobs_view_mode ?? 'grid';
const listingDeletionPref = userSettings?.listing_deletion_preference;
const defaultDeleteType = listingDeletionPref?.hardDelete ? 'hard' : 'soft';
const [page, setPage] = useState(1);
const pageSize = 12;
@@ -102,7 +106,7 @@ const JobGrid = () => {
actions.jobsData.setJobRunning(data.jobId, !!data.running);
// notify finish if it was triggered by this view
if (pendingJobIdRef.current === data.jobId && data.running === false) {
Toast.success('Job finished');
Toast.success(t('jobs.toastFinished'));
pendingJobIdRef.current = null;
}
}
@@ -142,26 +146,34 @@ const JobGrid = () => {
};
const onListingRemoval = (jobId) => {
setPendingDeletion({ type: 'listings', jobId });
const deletion = { type: 'listings', jobId };
if (listingDeletionPref?.skipPrompt) {
confirmDeletion(listingDeletionPref.hardDelete, false, deletion);
return;
}
setPendingDeletion(deletion);
setDeleteModalVisible(true);
};
const confirmDeletion = async (hardDelete) => {
const { type, jobId } = pendingDeletion;
const confirmDeletion = async (hardDelete, remember, deletion = pendingDeletion) => {
const { type, jobId } = deletion;
try {
if (remember && type === 'listings') {
await actions.userSettings.setListingDeletionPreference({ skipPrompt: true, hardDelete });
}
if (type === 'job') {
await xhrDelete('/api/jobs', { jobId });
Toast.success('Job and listings successfully removed');
Toast.success(t('jobs.toastDeletedWithListings'));
} else if (type === 'listings') {
await xhrDelete('/api/listings/job', { jobId, hardDelete });
Toast.success('Listings successfully removed');
Toast.success(t('jobs.toastListingsDeleted'));
}
loadData();
if (type === 'job') {
actions.jobsData.getJobs(); // refresh select list too
}
} catch (error) {
Toast.error(error.message || 'Error performing deletion');
Toast.error(error.message || t('jobs.toastDeleteError'));
} finally {
setDeleteModalVisible(false);
setPendingDeletion(null);
@@ -171,10 +183,10 @@ const JobGrid = () => {
const onJobStatusChanged = async (jobId, status) => {
try {
await xhrPut(`/api/jobs/${jobId}/status`, { status });
Toast.success('Job status successfully changed');
Toast.success(t('jobs.toastStatusChanged'));
loadData();
} catch (error) {
Toast.error(error);
Toast.error(error.error);
}
};
@@ -182,21 +194,21 @@ const JobGrid = () => {
try {
const response = await xhrPost(`/api/jobs/${jobId}/run`);
if (response.status === 202) {
Toast.success('Job run started');
Toast.success(t('jobs.toastRunStarted'));
} else {
Toast.info('Job run requested');
Toast.info(t('jobs.toastRunRequested'));
}
pendingJobIdRef.current = jobId;
loadData();
} catch (error) {
if (error?.status === 409) {
Toast.warning(error?.json?.message || 'Job is already running');
Toast.warning(error?.json?.message || t('jobs.toastAlreadyRunning'));
} else if (error?.status === 403) {
Toast.error('You are not allowed to run this job');
Toast.error(t('jobs.toastNotAllowed'));
} else if (error?.status === 404) {
Toast.error('Job not found');
Toast.error(t('jobs.toastNotFound'));
} else {
Toast.error('Failed to trigger job');
Toast.error(t('jobs.toastRunFailed'));
}
}
};
@@ -212,7 +224,7 @@ const JobGrid = () => {
className="jobGrid__topbar__search"
prefix={<IconSearch />}
showClear
placeholder="Search"
placeholder={t('jobs.searchPlaceholder')}
onChange={handleFilterChange}
/>
@@ -225,39 +237,44 @@ const JobGrid = () => {
setActivityFilter(v === 'all' ? null : v === 'true');
}}
>
<Radio value="all">All</Radio>
<Radio value="true">Active</Radio>
<Radio value="false">Inactive</Radio>
<Radio value="all">{t('jobs.filterAll')}</Radio>
<Radio value="true">{t('jobs.filterActive')}</Radio>
<Radio value="false">{t('jobs.filterInactive')}</Radio>
</RadioGroup>
<Select prefix="Sort by" style={{ width: 200 }} value={sortField} onChange={(val) => setSortField(val)}>
<Select.Option value="name">Name</Select.Option>
<Select.Option value="numberOfFoundListings">Number of Listings</Select.Option>
<Select.Option value="enabled">Status</Select.Option>
<Select
prefix={t('jobs.sortPrefix')}
style={{ width: 200 }}
value={sortField}
onChange={(val) => setSortField(val)}
>
<Select.Option value="name">{t('jobs.sortByName')}</Select.Option>
<Select.Option value="numberOfFoundListings">{t('jobs.sortByListings')}</Select.Option>
<Select.Option value="enabled">{t('jobs.sortByStatus')}</Select.Option>
</Select>
<Button
icon={sortDir === 'asc' ? <IconArrowUp /> : <IconArrowDown />}
onClick={() => setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))}
title={sortDir === 'asc' ? 'Ascending' : 'Descending'}
title={sortDir === 'asc' ? t('jobs.sortAscending') : t('jobs.sortDescending')}
/>
<div className="jobGrid__topbar__view-toggle">
<Tooltip content="Grid view">
<Tooltip content={t('jobs.tooltipGridView')}>
<Button
icon={<IconGridView />}
theme={viewMode === 'grid' ? 'solid' : 'borderless'}
onClick={() => actions.userSettings.setJobsViewMode('grid')}
aria-label="Grid view"
aria-label={t('common.ariaGridView')}
aria-pressed={viewMode === 'grid'}
/>
</Tooltip>
<Tooltip content="Table view">
<Tooltip content={t('jobs.tooltipTableView')}>
<Button
icon={<IconList />}
theme={viewMode === 'table' ? 'solid' : 'borderless'}
onClick={() => actions.userSettings.setJobsViewMode('table')}
aria-label="Table view"
aria-label={t('common.ariaTableView')}
aria-pressed={viewMode === 'table'}
/>
</Tooltip>
@@ -268,7 +285,7 @@ const JobGrid = () => {
<Empty
image={<IllustrationNoResult />}
darkModeImage={<IllustrationNoResultDark />}
description="No jobs available yet..."
description={t('jobs.empty')}
/>
)}
@@ -286,7 +303,7 @@ const JobGrid = () => {
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
{job.isOnlyShared && (
<Popover content={getPopoverContent('This job has been shared with you — read only.')}>
<Popover content={getPopoverContent(t('jobs.cardSharedReadOnly'))}>
<div>
<IconAlertTriangle style={{ color: 'rgba(var(--semi-yellow-7), 1)' }} />
</div>
@@ -294,7 +311,7 @@ const JobGrid = () => {
)}
{job.running && (
<Tag color="green" variant="light" size="small">
RUNNING
{t('jobs.cardRunning')}
</Tag>
)}
</div>
@@ -304,19 +321,19 @@ const JobGrid = () => {
<div className="jobGrid__card__stat jobGrid__card__stat--blue">
<span className="jobGrid__card__stat__number">{job.numberOfFoundListings || 0}</span>
<span className="jobGrid__card__stat__label">
<IconHome size="small" /> Listings
<IconHome size="small" /> {t('jobs.cardListings')}
</span>
</div>
<div className="jobGrid__card__stat jobGrid__card__stat--orange">
<span className="jobGrid__card__stat__number">{job.provider?.length || 0}</span>
<span className="jobGrid__card__stat__label">
<IconBriefcase size="small" /> Providers
<IconBriefcase size="small" /> {t('jobs.cardProviders')}
</span>
</div>
<div className="jobGrid__card__stat jobGrid__card__stat--purple">
<span className="jobGrid__card__stat__number">{job.notificationAdapter?.length || 0}</span>
<span className="jobGrid__card__stat__label">
<IconBell size="small" /> Adapters
<IconBell size="small" /> {t('jobs.cardAdapters')}
</span>
</div>
</div>
@@ -332,11 +349,11 @@ const JobGrid = () => {
size="small"
/>
<Text type="secondary" size="small">
Active
{t('jobs.cardActive')}
</Text>
</div>
<div className="jobGrid__actions">
<Popover content={getPopoverContent('Run Job')}>
<Popover content={getPopoverContent(t('jobs.popoverRunJob'))}>
<div>
<Button
type="primary"
@@ -349,7 +366,7 @@ const JobGrid = () => {
/>
</div>
</Popover>
<Popover content={getPopoverContent('Edit a Job')}>
<Popover content={getPopoverContent(t('jobs.popoverEditJob'))}>
<div>
<Button
type="secondary"
@@ -360,7 +377,7 @@ const JobGrid = () => {
/>
</div>
</Popover>
<Popover content={getPopoverContent('Clone Job')}>
<Popover content={getPopoverContent(t('jobs.popoverCloneJob'))}>
<div>
<Button
type="tertiary"
@@ -371,7 +388,7 @@ const JobGrid = () => {
/>
</div>
</Popover>
<Popover content={getPopoverContent('Delete all found Listings of this Job')}>
<Popover content={getPopoverContent(t('jobs.popoverDeleteListings'))}>
<div>
<Button
type="danger"
@@ -382,7 +399,7 @@ const JobGrid = () => {
/>
</div>
</Popover>
<Popover content={getPopoverContent('Delete Job')}>
<Popover content={getPopoverContent(t('jobs.popoverDeleteJob'))}>
<div>
<Button
type="danger"
@@ -423,13 +440,10 @@ const JobGrid = () => {
)}
<ListingDeletionModal
visible={deleteModalVisible}
title={pendingDeletion?.type === 'job' ? 'Delete Job' : 'Delete Listings'}
title={pendingDeletion?.type === 'job' ? t('jobs.deletion.title') : t('listing.deletion.title')}
showOptions={pendingDeletion?.type !== 'job'}
message={
pendingDeletion?.type === 'job'
? 'Are you sure you want to delete this job? All associated listings will be removed from the database.'
: 'How would you like to delete the selected listing(s)?'
}
defaultDeleteType={defaultDeleteType}
message={pendingDeletion?.type === 'job' ? t('jobs.deletion.message') : t('listing.deletion.message')}
onConfirm={confirmDeletion}
onCancel={() => {
setDeleteModalVisible(false);

View File

@@ -16,113 +16,133 @@ import {
} from '@douyinfe/semi-icons';
import no_image from '../../../assets/no_image.png';
import * as timeService from '../../../services/time/timeService.js';
import StatusControl from '../../listings/StatusControl.jsx';
import './ListingsGrid.less';
import { useTranslation, useLocale } from '../../../services/i18n/i18n.jsx';
/**
* @param {{ listings: object[], onWatch: Function, onNavigate: Function, onDelete: Function }} props
* @param {{ listings: object[], onWatch: Function, onNavigate: Function, onDelete: Function, onStatusChange: Function }} props
*/
const ListingsGrid = ({ listings, onWatch, onNavigate, onDelete }) => (
<div className="listingsGrid__grid">
{listings.map((item) => (
<div
key={item.id}
className="listingsGrid__card"
style={{ cursor: 'pointer' }}
role="button"
tabIndex={0}
onClick={() => onNavigate(item.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onNavigate(item.id);
}}
>
<div className="listingsGrid__card__image-wrapper">
<img
src={item.image_url || no_image}
alt={item.title}
onError={(e) => {
e.target.src = no_image;
}}
/>
{!item.is_active && (
<div className="listingsGrid__card__inactive-watermark">
<span>Inactive</span>
</div>
)}
<button
type="button"
className="listingsGrid__card__star"
onClick={(e) => onWatch(e, item)}
aria-label={item.isWatched === 1 ? 'Remove from watchlist' : 'Add to watchlist'}
>
{item.isWatched === 1 ? <IconStar /> : <IconStarStroked />}
</button>
</div>
<div className="listingsGrid__card__body">
<div className="listingsGrid__card__title" title={item.title}>
{item.title}
const ListingsGrid = ({ listings, onWatch, onNavigate, onDelete, onStatusChange }) => {
const t = useTranslation();
const locale = useLocale();
return (
<div className="listingsGrid__grid">
{listings.map((item) => (
<div
key={item.id}
className="listingsGrid__card"
style={{ cursor: 'pointer' }}
role="button"
tabIndex={0}
onClick={() => onNavigate(item.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onNavigate(item.id);
}}
>
<div className="listingsGrid__card__image-wrapper">
<img
src={item.image_url || no_image}
alt={item.title}
onError={(e) => {
e.target.src = no_image;
}}
/>
{!item.is_active && (
<div className="listingsGrid__card__inactive-watermark">
<span>{t('listings.cardInactive')}</span>
</div>
)}
<Tooltip
content={
item.isWatched === 1 ? t('listings.tooltipRemoveFromWatchlist') : t('listings.tooltipAddToWatchlist')
}
>
<button
type="button"
className="listingsGrid__card__star"
onClick={(e) => onWatch(e, item)}
aria-label={
item.isWatched === 1 ? t('listings.tooltipRemoveFromWatchlist') : t('listings.tooltipAddToWatchlist')
}
>
{item.isWatched === 1 ? <IconStar /> : <IconStarStroked />}
</button>
</Tooltip>
</div>
{item.price && (
<div className="listingsGrid__card__price">
<IconCart size="small" />
{item.price}
<div className="listingsGrid__card__body">
<div className="listingsGrid__card__title" title={item.title}>
{item.title}
</div>
)}
{item.address && (
{item.price && (
<div className="listingsGrid__card__price">
<IconCart size="small" />
{item.price}
</div>
)}
{item.address && (
<div className="listingsGrid__card__meta">
<IconMapPin />
{item.address}
</div>
)}
<div className="listingsGrid__card__meta">
<IconMapPin />
{item.address}
<IconBriefcase />
{item.provider}
</div>
)}
<div className="listingsGrid__card__meta">
<IconBriefcase />
{item.provider}
<div className="listingsGrid__card__provider">{timeService.format(item.created_at, false, locale)}</div>
</div>
<div className="listingsGrid__card__provider">{timeService.format(item.created_at, false)}</div>
</div>
<div className="listingsGrid__card__actions" onClick={(e) => e.stopPropagation()}>
<Tooltip content="Original Listing">
<Button
size="small"
icon={<IconLink />}
style={{ color: '#60a5fa' }}
theme="borderless"
onClick={(e) => {
e.stopPropagation();
window.open(item.link, '_blank');
}}
<div className="listingsGrid__card__actions" onClick={(e) => e.stopPropagation()}>
<StatusControl
status={item.status?.status ?? null}
compact
onChange={(next) => onStatusChange?.(item, next)}
onTriggerClick={(e) => e.stopPropagation()}
/>
</Tooltip>
<Tooltip content="View in Fredy">
<Button
size="small"
icon={<IconEyeOpened />}
style={{ color: '#34d399' }}
theme="borderless"
onClick={(e) => {
e.stopPropagation();
onNavigate(item.id);
}}
/>
</Tooltip>
<Tooltip content="Remove">
<Button
size="small"
icon={<IconDelete />}
style={{ color: '#fb7185' }}
theme="borderless"
onClick={(e) => {
e.stopPropagation();
onDelete(item.id);
}}
/>
</Tooltip>
<Tooltip content={t('listings.tooltipOriginalListing')}>
<Button
size="small"
icon={<IconLink />}
style={{ color: '#60a5fa' }}
theme="borderless"
onClick={(e) => {
e.stopPropagation();
window.open(item.link, '_blank');
}}
/>
</Tooltip>
<Tooltip content={t('listings.tooltipViewInFredy')}>
<Button
size="small"
icon={<IconEyeOpened />}
style={{ color: '#34d399' }}
theme="borderless"
onClick={(e) => {
e.stopPropagation();
onNavigate(item.id);
}}
/>
</Tooltip>
<Tooltip content={t('listings.tooltipRemove')}>
<Button
size="small"
icon={<IconDelete />}
style={{ color: '#fb7185' }}
theme="borderless"
onClick={(e) => {
e.stopPropagation();
onDelete(item.id);
}}
/>
</Tooltip>
</div>
</div>
</div>
))}
</div>
);
))}
</div>
);
};
export default ListingsGrid;

View File

@@ -11,12 +11,11 @@
border: 1px solid @color-border !important;
border-radius: @radius-card !important;
overflow: hidden;
transition: transform @transition-card, box-shadow @transition-card;
transition: box-shadow @transition-card;
display: flex;
flex-direction: column;
&:hover {
transform: translateY(-2px);
box-shadow: 0 6px 24px -4px rgba(0,0,0,0.6);
}

View File

@@ -22,8 +22,11 @@ import ListingsTable from '../table/ListingsTable.jsx';
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
import './ListingsOverview.less';
import { useTranslation } from '../../services/i18n/i18n.jsx';
const ListingsOverview = () => {
const ListingsOverview = ({ mode = 'all' }) => {
const t = useTranslation();
const isWatchlistMode = mode === 'watchlist';
const listingsData = useSelector((state) => state.listingsData);
const providers = useSelector((state) => state.provider);
const jobs = useSelector((state) => state.jobsData.jobs);
@@ -33,6 +36,8 @@ const ListingsOverview = () => {
const sp = useSearchParams();
const viewMode = userSettings?.listings_view_mode ?? 'grid';
const listingDeletionPref = userSettings?.listing_deletion_preference;
const defaultDeleteType = listingDeletionPref?.hardDelete ? 'hard' : 'soft';
const [page, setPage] = useSearchParamState(sp, 'page', 1, parseNumber);
const pageSize = 40;
@@ -44,9 +49,13 @@ const ListingsOverview = () => {
const [jobNameFilter, setJobNameFilter] = useSearchParamState(sp, 'job', null, parseString);
const [activityFilter, setActivityFilter] = useSearchParamState(sp, 'active', null, parseNullableBoolean);
const [providerFilter, setProviderFilter] = useSearchParamState(sp, 'provider', null, parseString);
const [statusFilter, setStatusFilter] = useSearchParamState(sp, 'status', null, parseString);
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
const [listingToDelete, setListingToDelete] = useState(null);
// In watchlist mode the watch filter is forced to "watched only" — regardless of the URL.
const effectiveWatchListFilter = isWatchlistMode ? true : watchListFilter;
const loadData = () => {
actions.listingsData.getListingsData({
page,
@@ -54,13 +63,30 @@ const ListingsOverview = () => {
sortfield: sortField,
sortdir: sortDir,
freeTextFilter,
filter: { watchListFilter, jobNameFilter, activityFilter, providerFilter },
filter: {
watchListFilter: effectiveWatchListFilter,
jobNameFilter,
activityFilter,
providerFilter,
statusFilter,
},
});
};
useEffect(() => {
loadData();
}, [page, sortField, sortDir, freeTextFilter, providerFilter, activityFilter, jobNameFilter, watchListFilter]);
}, [
page,
sortField,
sortDir,
freeTextFilter,
providerFilter,
activityFilter,
jobNameFilter,
watchListFilter,
statusFilter,
isWatchlistMode,
]);
const handleFilterChange = useMemo(
() =>
@@ -82,28 +108,48 @@ const ListingsOverview = () => {
e.stopPropagation();
try {
await xhrPost('/api/listings/watch', { listingId: item.id });
Toast.success(item.isWatched === 1 ? 'Listing removed from Watchlist' : 'Listing added to Watchlist');
Toast.success(
item.isWatched === 1 ? t('listings.toastRemovedFromWatchlist') : t('listings.toastAddedToWatchlist'),
);
loadData();
} catch (e) {
console.error(e);
Toast.error('Failed to operate Watchlist');
Toast.error(t('listings.toastWatchlistError'));
}
};
const handleStatusChange = async (item, nextStatus) => {
try {
await actions.listingsData.setListingStatus(item.id, nextStatus);
Toast.success(nextStatus ? `Marked as ${nextStatus}` : t('listings.toastStatusCleared'));
loadData();
} catch (e) {
console.error(e);
Toast.error(t('listings.toastStatusUpdateError'));
}
};
const handleDelete = (id) => {
if (listingDeletionPref?.skipPrompt) {
confirmDeletion(listingDeletionPref.hardDelete, false, id);
return;
}
setListingToDelete(id);
setDeleteModalVisible(true);
};
const handleNavigate = (id) => navigate(`/listings/listing/${id}`);
const confirmDeletion = async (hardDelete) => {
const confirmDeletion = async (hardDelete, remember, id = listingToDelete) => {
try {
await xhrDelete('/api/listings/', { ids: [listingToDelete], hardDelete });
Toast.success('Listing successfully removed');
if (remember) {
await actions.userSettings.setListingDeletionPreference({ skipPrompt: true, hardDelete });
}
await xhrDelete('/api/listings/', { ids: [id], hardDelete });
Toast.success(t('listings.toastDeleted'));
loadData();
} catch (error) {
Toast.error(error.message || 'Error deleting listing');
Toast.error(error.message || t('listings.toastDeleteError'));
} finally {
setDeleteModalVisible(false);
setListingToDelete(null);
@@ -119,7 +165,7 @@ const ListingsOverview = () => {
className="listingsOverview__topbar__search"
prefix={<IconSearch />}
showClear
placeholder="Search"
placeholder={t('listings.searchPlaceholder')}
defaultValue={freeTextFilter ?? ''}
onChange={handleFilterChange}
/>
@@ -134,28 +180,46 @@ const ListingsOverview = () => {
setPage(1);
}}
>
<Radio value="all">All</Radio>
<Radio value="true">Active</Radio>
<Radio value="false">Inactive</Radio>
<Radio value="all">{t('listings.filterAll')}</Radio>
<Radio value="true">{t('listings.filterActive')}</Radio>
<Radio value="false">{t('listings.filterInactive')}</Radio>
</RadioGroup>
<RadioGroup
type="button"
buttonSize="middle"
value={watchListFilter === null ? 'all' : String(watchListFilter)}
onChange={(e) => {
const v = e.target.value;
setWatchListFilter(v === 'all' ? null : v === 'true');
setPage(1);
}}
>
<Radio value="all">All</Radio>
<Radio value="true">Watched</Radio>
<Radio value="false">Unwatched</Radio>
</RadioGroup>
{!isWatchlistMode && (
<RadioGroup
type="button"
buttonSize="middle"
value={watchListFilter === null ? 'all' : String(watchListFilter)}
onChange={(e) => {
const v = e.target.value;
setWatchListFilter(v === 'all' ? null : v === 'true');
setPage(1);
}}
>
<Radio value="all">{t('listings.filterAll')}</Radio>
<Radio value="true">{t('listings.filterWatched')}</Radio>
<Radio value="false">{t('listings.filterUnwatched')}</Radio>
</RadioGroup>
)}
<Select
placeholder="Provider"
placeholder={t('listings.filterStatusPlaceholder')}
showClear
onChange={(val) => {
setStatusFilter(val ?? null);
setPage(1);
}}
value={statusFilter}
style={{ width: 150 }}
>
<Select.Option value="applied">{t('listings.filterStatusApplied')}</Select.Option>
<Select.Option value="rejected">{t('listings.filterStatusRejected')}</Select.Option>
<Select.Option value="accepted">{t('listings.filterStatusAccepted')}</Select.Option>
<Select.Option value="none">{t('listings.filterStatusNone')}</Select.Option>
</Select>
<Select
placeholder={t('listings.filterProviderPlaceholder')}
showClear
onChange={(val) => {
setProviderFilter(val);
@@ -172,7 +236,7 @@ const ListingsOverview = () => {
</Select>
<Select
placeholder="Job"
placeholder={t('listings.filterJobPlaceholder')}
showClear
onChange={(val) => {
setJobNameFilter(val);
@@ -188,35 +252,41 @@ const ListingsOverview = () => {
))}
</Select>
<Select prefix="Sort by" style={{ width: 185 }} value={sortField} onChange={(val) => setSortField(val)}>
<Select.Option value="job_name">Job Name</Select.Option>
<Select.Option value="created_at">Listing Date</Select.Option>
<Select.Option value="price">Price</Select.Option>
<Select.Option value="provider">Provider</Select.Option>
<Select
prefix={t('listings.sortPrefix')}
className="listingsOverview__topbar__sort"
style={{ width: 220 }}
value={sortField}
onChange={(val) => setSortField(val)}
>
<Select.Option value="job_name">{t('listings.sortByJobName')}</Select.Option>
<Select.Option value="created_at">{t('listings.sortByDate')}</Select.Option>
<Select.Option value="price">{t('listings.sortByPrice')}</Select.Option>
<Select.Option value="provider">{t('listings.sortByProvider')}</Select.Option>
</Select>
<Button
icon={sortDir === 'asc' ? <IconArrowUp /> : <IconArrowDown />}
onClick={() => setSortDir(sortDir === 'asc' ? 'desc' : 'asc')}
title={sortDir === 'asc' ? 'Ascending' : 'Descending'}
title={sortDir === 'asc' ? t('listings.sortAscending') : t('listings.sortDescending')}
/>
<div className="listingsOverview__topbar__view-toggle">
<Tooltip content="Grid view">
<Tooltip content={t('listings.tooltipGridView')}>
<Button
icon={<IconGridView />}
theme={viewMode === 'grid' ? 'solid' : 'borderless'}
onClick={() => actions.userSettings.setListingsViewMode('grid')}
aria-label="Grid view"
aria-label={t('common.ariaGridView')}
aria-pressed={viewMode === 'grid'}
/>
</Tooltip>
<Tooltip content="Table view">
<Tooltip content={t('listings.tooltipTableView')}>
<Button
icon={<IconList />}
theme={viewMode === 'table' ? 'solid' : 'borderless'}
onClick={() => actions.userSettings.setListingsViewMode('table')}
aria-label="Table view"
aria-label={t('common.ariaTableView')}
aria-pressed={viewMode === 'table'}
/>
</Tooltip>
@@ -227,14 +297,26 @@ const ListingsOverview = () => {
<Empty
image={<IllustrationNoResult />}
darkModeImage={<IllustrationNoResultDark />}
description="No listings available yet..."
description={t('listings.empty')}
/>
)}
{viewMode === 'grid' ? (
<ListingsGrid listings={listings} onWatch={handleWatch} onNavigate={handleNavigate} onDelete={handleDelete} />
<ListingsGrid
listings={listings}
onWatch={handleWatch}
onNavigate={handleNavigate}
onDelete={handleDelete}
onStatusChange={handleStatusChange}
/>
) : (
<ListingsTable listings={listings} onWatch={handleWatch} onNavigate={handleNavigate} onDelete={handleDelete} />
<ListingsTable
listings={listings}
onWatch={handleWatch}
onNavigate={handleNavigate}
onDelete={handleDelete}
onStatusChange={handleStatusChange}
/>
)}
{listings.length > 0 && (
@@ -251,6 +333,7 @@ const ListingsOverview = () => {
<ListingDeletionModal
visible={deleteModalVisible}
defaultDeleteType={defaultDeleteType}
onConfirm={confirmDeletion}
onCancel={() => {
setDeleteModalVisible(false);

View File

@@ -19,6 +19,14 @@
flex-shrink: 0;
}
&__sort {
flex-shrink: 0;
.semi-select-prefix {
white-space: nowrap;
}
}
@media (max-width: 768px) {
.listingsOverview__topbar__search {
width: 100%;

View File

@@ -0,0 +1,113 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { useState } from 'react';
import { Dropdown, Button, Tooltip } from '@douyinfe/semi-ui-19';
import { IconChevronDown } from '@douyinfe/semi-icons';
import './StatusControl.less';
import { useTranslation } from '../../services/i18n/i18n.jsx';
/**
* @typedef {('applied'|'rejected'|'accepted'|null)} ListingStatus
*/
/**
* Shared control for setting a listing's user-decision status
* (Applied / Rejected / Accepted).
*
* Both compact (table/grid rows) and full (listing detail header) modes
* render a Button that picks up the project's CI tokens via the
* .status-btn classes, with a small size variant for compact contexts.
*
* @param {Object} props
* @param {ListingStatus} props.status - The current status value.
* @param {(next: ListingStatus) => void} props.onChange - Called with the new status when the user picks one.
* @param {boolean} [props.compact=false] - When true, renders smaller for table/grid rows; full size otherwise.
* @param {(e: React.MouseEvent) => void} [props.onTriggerClick] - Optional click handler to stop propagation on the trigger.
*/
export default function StatusControl({ status = null, onChange, compact = false, onTriggerClick }) {
const t = useTranslation();
const [open, setOpen] = useState(false);
const [tooltipOpen, setTooltipOpen] = useState(false);
const STATUS_OPTIONS = [
{ value: null, label: t('listings.status.none') },
{ value: 'applied', label: t('listings.status.applied') },
{ value: 'rejected', label: t('listings.status.rejected') },
{ value: 'accepted', label: t('listings.status.accepted') },
];
const STATUS_TOOLTIP = t('listings.status.tooltip');
const optionFor = (status) => STATUS_OPTIONS.find((o) => o.value === status) ?? STATUS_OPTIONS[0];
const current = optionFor(status);
const handlePick = (next) => {
setOpen(false);
if (next === status) return;
onChange?.(next);
};
const menu = (
<Dropdown.Menu>
{STATUS_OPTIONS.map((opt) => (
<Dropdown.Item
key={opt.value ?? '__none__'}
active={opt.value === status}
onClick={() => handlePick(opt.value)}
>
{opt.label}
</Dropdown.Item>
))}
</Dropdown.Menu>
);
const className = ['status-btn', compact ? 'status-btn--compact' : null, status ? `status-btn--${status}` : null]
.filter(Boolean)
.join(' ');
const trigger = (
<Tooltip
content={STATUS_TOOLTIP}
position="top"
trigger="custom"
visible={tooltipOpen && !open}
onVisibleChange={setTooltipOpen}
>
<Button
size={compact ? 'small' : 'default'}
theme="borderless"
icon={<IconChevronDown />}
iconPosition="right"
onMouseEnter={() => setTooltipOpen(true)}
onMouseLeave={() => setTooltipOpen(false)}
onClick={(e) => {
onTriggerClick?.(e);
setTooltipOpen(false);
setOpen((o) => !o);
}}
className={className}
>
{status ? current.label : t('listings.status.statusLabel')}
</Button>
</Tooltip>
);
return (
<Dropdown
trigger="custom"
visible={open}
onVisibleChange={setOpen}
onClickOutSide={() => setOpen(false)}
position="bottom"
render={menu}
stopPropagation
>
<span className="status-btn__anchor">{trigger}</span>
</Dropdown>
);
}

View File

@@ -0,0 +1,64 @@
@import '../../tokens.less';
// Wrapper span used as the Dropdown's positioning anchor so the menu opens
// directly below the visible button rather than the implicit wrapper of the
// hover tooltip (which can have a different bounding box).
.status-btn__anchor {
display: inline-block;
line-height: 0;
}
// StatusControl shared base. Matches dimensions and border treatment
// of the surrounding Watched / Open listing / Delete buttons in the
// detail view, and shrinks via the --compact modifier for table rows
// and grid cards.
.status-btn {
color: @color-muted !important;
border: 1px solid @color-border-bright !important;
border-radius: @radius-btn !important;
background: transparent !important;
transition: color @transition-fast, border-color @transition-fast, background @transition-fast;
&:hover {
color: @color-text !important;
background: rgba(255, 255, 255, 0.06) !important;
}
&--compact {
height: 24px !important;
padding: 0 8px !important;
font-size: @text-sm !important;
border-radius: @radius-chip !important;
.semi-icon {
font-size: 12px !important;
}
}
&--applied {
color: @color-info !important;
border-color: rgba(96, 165, 250, 0.4) !important;
background: rgba(96, 165, 250, 0.08) !important;
&:hover {
background: rgba(96, 165, 250, 0.14) !important;
}
}
&--rejected {
color: @color-error !important;
border-color: rgba(251, 113, 133, 0.4) !important;
background: rgba(251, 113, 133, 0.08) !important;
&:hover {
background: rgba(251, 113, 133, 0.14) !important;
}
}
&--accepted {
color: @color-success !important;
border-color: rgba(52, 211, 153, 0.4) !important;
background: rgba(52, 211, 153, 0.08) !important;
&:hover {
background: rgba(52, 211, 153, 0.14) !important;
}
}
}

View File

@@ -8,6 +8,7 @@ import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import '@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw.css';
import { fixMapboxDrawCompatibility, addDrawingControl, setupAreaFilterEventListeners } from './MapDrawingExtension.js';
import { getBoundsFromCoords } from '../../views/listings/mapUtils.js';
import './Map.less';
export const GERMANY_BOUNDS = [
@@ -66,6 +67,7 @@ export default function Map({
const mapContainerRef = useRef(null);
const mapRef = useRef(null);
const drawRef = useRef(null);
const hasFittedToInitialAreaRef = useRef(false);
// Initialize map - ONLY when container changes, never reinitialize
useEffect(() => {
@@ -128,6 +130,17 @@ export default function Map({
} catch (error) {
console.error('Error loading spatial filter:', error);
}
if (!hasFittedToInitialAreaRef.current) {
const coords = initialSpatialFilter.features.flatMap((feature) =>
feature.geometry?.type === 'Polygon' ? feature.geometry.coordinates.flat() : [],
);
const bounds = getBoundsFromCoords(coords);
if (bounds) {
mapRef.current.fitBounds(bounds, { padding: 50, maxZoom: 15, duration: 0 });
hasFittedToInitialAreaRef.current = true;
}
}
}
// Setup drawing event listeners

View File

@@ -112,9 +112,9 @@
.semi-navigation-item {
border-radius: @radius-btn !important;
border: 1px solid transparent !important;
color: @color-muted !important;
transition: background @transition-fast, color @transition-fast !important;
margin: 2px 8px !important;
transition: background @transition-fast, color @transition-fast, border-color @transition-fast !important;
&:hover {
color: @color-text !important;
@@ -123,7 +123,7 @@
&.semi-navigation-item-selected,
&[aria-selected="true"] {
background: rgba(224,74,56,0.12) !important;
border: 1px solid rgba(224,74,56,0.25) !important;
border-color: rgba(224,74,56,0.25) !important;
color: @color-text !important;
.semi-navigation-item-icon {

View File

@@ -13,8 +13,10 @@ import { useLocation, useNavigate } from 'react-router-dom';
import './Navigate.less';
import { useScreenWidth } from '../../hooks/screenWidth.js';
import { useTranslation } from '../../services/i18n/i18n.jsx';
export default function Navigation({ isAdmin }) {
const t = useTranslation();
const navigate = useNavigate();
const location = useLocation();
@@ -28,15 +30,16 @@ export default function Navigation({ isAdmin }) {
}, [width]);
const items = [
{ itemKey: '/dashboard', text: 'Dashboard', icon: <IconHistogram /> },
{ itemKey: '/jobs', text: 'Jobs', icon: <IconTerminal /> },
{ itemKey: '/dashboard', text: t('nav.dashboard'), icon: <IconHistogram /> },
{ itemKey: '/jobs', text: t('nav.jobs'), icon: <IconTerminal /> },
{
itemKey: 'listings',
text: 'Listings',
text: t('nav.listings'),
icon: <IconStar />,
items: [
{ itemKey: '/listings', text: 'Overview' },
{ itemKey: '/map', text: 'Map View' },
{ itemKey: '/listings', text: t('nav.listingsOverview') },
{ itemKey: '/map', text: t('nav.mapView') },
{ itemKey: '/listings/watchlist', text: t('nav.watchlist') },
],
},
];
@@ -44,23 +47,39 @@ export default function Navigation({ isAdmin }) {
if (isAdmin) {
items.push({
itemKey: 'settings',
text: 'Settings',
text: t('nav.settings'),
icon: <IconSetting />,
items: [
{ itemKey: '/users', text: 'User Management' },
{ itemKey: '/generalSettings', text: 'Settings' },
{ itemKey: '/users', text: t('nav.userManagement') },
{ itemKey: '/generalSettings', text: t('nav.settingsPage') },
],
});
} else {
items.push({
itemKey: 'settings',
text: 'Settings',
text: t('nav.settings'),
icon: <IconSetting />,
items: [{ itemKey: '/generalSettings', text: 'Settings' }],
items: [{ itemKey: '/generalSettings', text: t('nav.settingsPage') }],
});
}
function parsePathName(name) {
// Collect every leaf itemKey that looks like a route (starts with '/').
// Prefer the longest exact-prefix match so nested routes like
// '/listings/watchlist' resolve to themselves instead of being collapsed
// to '/listings'.
const allKeys = [];
const collect = (nodes) => {
for (const n of nodes) {
if (typeof n.itemKey === 'string' && n.itemKey.startsWith('/')) allKeys.push(n.itemKey);
if (Array.isArray(n.items)) collect(n.items);
}
};
collect(items);
const longestMatch = allKeys
.filter((k) => name === k || name.startsWith(k + '/'))
.sort((a, b) => b.length - a.length)[0];
if (longestMatch) return longestMatch;
const split = name.split('/').filter((s) => s.length !== 0);
return '/' + split[0];
}
@@ -87,7 +106,7 @@ export default function Navigation({ isAdmin }) {
<button
className="navigate__toggle-btn"
onClick={() => setCollapsed(!collapsed)}
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
title={collapsed ? t('nav.expandSidebar') : t('nav.collapseSidebar')}
>
<IconSidebar size="default" />
</button>

View File

@@ -10,10 +10,12 @@ import newsConfig from '../../assets/news/news.json';
import { useActions, useSelector } from '../../services/state/store';
import './NewsModal.less';
import { useTranslation } from '../../services/i18n/i18n.jsx';
const newsMedia = import.meta.glob('../../assets/news/*', { eager: true, query: '?url', import: 'default' });
const NewsModal = () => {
const t = useTranslation();
const screenWidth = useScreenWidth();
const newsHash = useSelector((state) => state.userSettings.settings.news_hash);
const userSettingsLoaded = useSelector((state) => state.userSettings.loaded);
@@ -38,7 +40,7 @@ const NewsModal = () => {
(item.media.includes('mp4') ? (
<video controls width="500">
<source src={newsMedia[`../../assets/news/${item.media}`]} type="video/mp4" />
Your browser does not support the video tag.
{t('news.videoFallback')}
</video>
) : (
<img

View File

@@ -4,13 +4,15 @@
*/
import insufficientPermission from '../../assets/insufficient_permission.png';
import { useTranslation } from '../../services/i18n/i18n.jsx';
export default function InsufficientPermission() {
const t = useTranslation();
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', flexDirection: 'column' }}>
<img src={insufficientPermission} height={250} />
<br />
<h4>Insufficient permission :(</h4>
<h4>{t('permission.title')}</h4>
</div>
);
}

View File

@@ -17,112 +17,116 @@ import {
} from '@douyinfe/semi-icons';
import './JobsTable.less';
import { useTranslation } from '../../services/i18n/i18n.jsx';
/**
* @param {{ jobs: object[], onRun: Function, onEdit: Function, onClone: Function, onDeleteListings: Function, onDeleteJob: Function, onStatusChange: Function }} props
*/
const JobsTable = ({ jobs, onRun, onEdit, onClone, onDeleteListings, onDeleteJob, onStatusChange }) => (
<div className="jobsTable">
{jobs.map((job) => (
<div key={job.id} className={`jobsTable__row${!job.enabled ? ' jobsTable__row--inactive' : ''}`}>
<div className="jobsTable__row__dot">
<span
className={`jobsTable__row__dot__indicator${job.enabled ? ' jobsTable__row__dot__indicator--active' : ''}`}
/>
</div>
const JobsTable = ({ jobs, onRun, onEdit, onClone, onDeleteListings, onDeleteJob, onStatusChange }) => {
const t = useTranslation();
return (
<div className="jobsTable">
{jobs.map((job) => (
<div key={job.id} className={`jobsTable__row${!job.enabled ? ' jobsTable__row--inactive' : ''}`}>
<div className="jobsTable__row__dot">
<span
className={`jobsTable__row__dot__indicator${job.enabled ? ' jobsTable__row__dot__indicator--active' : ''}`}
/>
</div>
<div className="jobsTable__row__name" title={job.name}>
{job.name}
</div>
<div className="jobsTable__row__name" title={job.name}>
{job.name}
</div>
<div className="jobsTable__row__stat jobsTable__row__stat--blue">
<IconHome size="small" />
{job.numberOfFoundListings || 0}
</div>
<div className="jobsTable__row__stat jobsTable__row__stat--blue">
<IconHome size="small" />
{job.numberOfFoundListings || 0}
</div>
<div className="jobsTable__row__stat jobsTable__row__stat--orange">
<IconBriefcase size="small" />
{job.provider?.length || 0}
</div>
<div className="jobsTable__row__stat jobsTable__row__stat--orange">
<IconBriefcase size="small" />
{job.provider?.length || 0}
</div>
<div className="jobsTable__row__stat jobsTable__row__stat--purple">
<IconBell size="small" />
{job.notificationAdapter?.length || 0}
</div>
<div className="jobsTable__row__stat jobsTable__row__stat--purple">
<IconBell size="small" />
{job.notificationAdapter?.length || 0}
</div>
<div className="jobsTable__row__badges">
<Switch
size="small"
checked={job.enabled}
disabled={job.isOnlyShared}
onChange={(checked) => onStatusChange(job.id, checked)}
/>
{job.running && (
<Tag color="green" variant="light" size="small">
RUNNING
</Tag>
)}
{job.isOnlyShared && (
<Tooltip content="Shared with you — read only">
<span style={{ display: 'flex', alignItems: 'center' }}>
<IconAlertTriangle style={{ color: 'rgba(var(--semi-yellow-7), 1)' }} />
</span>
<div className="jobsTable__row__badges">
<Switch
size="small"
checked={job.enabled}
disabled={job.isOnlyShared}
onChange={(checked) => onStatusChange(job.id, checked)}
/>
{job.running && (
<Tag color="green" variant="light" size="small">
{t('jobs.cardRunning')}
</Tag>
)}
{job.isOnlyShared && (
<Tooltip content={t('jobs.tableSharedTooltip')}>
<span style={{ display: 'flex', alignItems: 'center' }}>
<IconAlertTriangle style={{ color: 'rgba(var(--semi-yellow-7), 1)' }} />
</span>
</Tooltip>
)}
</div>
<div className="jobsTable__row__actions">
<Tooltip content={t('jobs.tableRunJob')}>
<Button
type="primary"
style={{ background: '#21aa21b5' }}
size="small"
theme="solid"
icon={<IconPlayCircle />}
disabled={job.isOnlyShared || job.running}
onClick={() => onRun(job.id)}
/>
</Tooltip>
)}
<Tooltip content={t('jobs.tableEditJob')}>
<Button
type="secondary"
size="small"
icon={<IconEdit />}
disabled={job.isOnlyShared}
onClick={() => onEdit(job.id)}
/>
</Tooltip>
<Tooltip content={t('jobs.tableCloneJob')}>
<Button
type="tertiary"
size="small"
icon={<IconCopy />}
disabled={job.isOnlyShared}
onClick={() => onClone(job.id)}
/>
</Tooltip>
<Tooltip content={t('jobs.tableDeleteListings')}>
<Button
type="danger"
size="small"
icon={<IconDescend2 />}
disabled={job.isOnlyShared}
onClick={() => onDeleteListings(job.id)}
/>
</Tooltip>
<Tooltip content={t('jobs.tableDeleteJob')}>
<Button
type="danger"
size="small"
icon={<IconDelete />}
disabled={job.isOnlyShared}
onClick={() => onDeleteJob(job.id)}
/>
</Tooltip>
</div>
</div>
<div className="jobsTable__row__actions">
<Tooltip content="Run Job">
<Button
type="primary"
style={{ background: '#21aa21b5' }}
size="small"
theme="solid"
icon={<IconPlayCircle />}
disabled={job.isOnlyShared || job.running}
onClick={() => onRun(job.id)}
/>
</Tooltip>
<Tooltip content="Edit Job">
<Button
type="secondary"
size="small"
icon={<IconEdit />}
disabled={job.isOnlyShared}
onClick={() => onEdit(job.id)}
/>
</Tooltip>
<Tooltip content="Clone Job">
<Button
type="tertiary"
size="small"
icon={<IconCopy />}
disabled={job.isOnlyShared}
onClick={() => onClone(job.id)}
/>
</Tooltip>
<Tooltip content="Delete all found Listings">
<Button
type="danger"
size="small"
icon={<IconDescend2 />}
disabled={job.isOnlyShared}
onClick={() => onDeleteListings(job.id)}
/>
</Tooltip>
<Tooltip content="Delete Job">
<Button
type="danger"
size="small"
icon={<IconDelete />}
disabled={job.isOnlyShared}
onClick={() => onDeleteJob(job.id)}
/>
</Tooltip>
</div>
</div>
))}
</div>
);
))}
</div>
);
};
export default JobsTable;

View File

@@ -6,7 +6,6 @@
import { Button, Tooltip } from '@douyinfe/semi-ui-19';
import {
IconBriefcase,
IconCart,
IconDelete,
IconLink,
IconMapPin,
@@ -15,118 +14,132 @@ import {
IconEyeOpened,
} from '@douyinfe/semi-icons';
import no_image from '../../assets/no_image.png';
import { formatEuroPrice } from '../../services/price/priceService.js';
import * as timeService from '../../services/time/timeService.js';
import StatusControl from '../listings/StatusControl.jsx';
import './ListingsTable.less';
import { useTranslation, useLocale } from '../../services/i18n/i18n.jsx';
/**
* @param {{ listings: object[], onWatch: Function, onNavigate: Function, onDelete: Function }} props
* @param {{ listings: object[], onWatch: Function, onNavigate: Function, onDelete: Function, onStatusChange: Function }} props
*/
const ListingsTable = ({ listings, onWatch, onNavigate, onDelete }) => (
<div className="listingsTable">
{listings.map((item) => (
<div
key={item.id}
className={`listingsTable__row${!item.is_active ? ' listingsTable__row--inactive' : ''}`}
role="button"
tabIndex={0}
onClick={() => onNavigate(item.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onNavigate(item.id);
}}
>
<div className="listingsTable__row__thumb">
<img
src={item.image_url || no_image}
alt={item.title}
onError={(e) => {
e.target.src = no_image;
}}
/>
</div>
<div className="listingsTable__row__title" title={item.title}>
{item.title}
</div>
<div className="listingsTable__row__price">
{item.price ? (
<>
<IconCart size="small" />
{item.price}
</>
) : (
<span className="listingsTable__row__empty"></span>
)}
</div>
<div className="listingsTable__row__address">
{item.address ? (
<>
<IconMapPin size="small" />
{item.address}
</>
) : (
<span className="listingsTable__row__empty"></span>
)}
</div>
<div className="listingsTable__row__meta">
<IconBriefcase size="small" />
{item.provider}
</div>
<div className="listingsTable__row__date">{timeService.format(item.created_at, false)}</div>
<div className="listingsTable__row__actions" onClick={(e) => e.stopPropagation()}>
<button
type="button"
className="listingsTable__row__star"
onClick={(e) => onWatch(e, item)}
aria-label={item.isWatched === 1 ? 'Remove from watchlist' : 'Add to watchlist'}
>
{item.isWatched === 1 ? <IconStar /> : <IconStarStroked />}
</button>
<Tooltip content="Original Listing">
<Button
size="small"
icon={<IconLink />}
style={{ color: '#60a5fa' }}
theme="borderless"
onClick={(e) => {
e.stopPropagation();
window.open(item.link, '_blank');
const ListingsTable = ({ listings, onWatch, onNavigate, onDelete, onStatusChange }) => {
const t = useTranslation();
const locale = useLocale();
return (
<div className="listingsTable">
{listings.map((item) => (
<div
key={item.id}
className={`listingsTable__row${!item.is_active ? ' listingsTable__row--inactive' : ''}`}
role="button"
tabIndex={0}
onClick={() => onNavigate(item.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onNavigate(item.id);
}}
>
<div className="listingsTable__row__thumb">
<img
src={item.image_url || no_image}
alt={item.title}
onError={(e) => {
e.target.src = no_image;
}}
/>
</Tooltip>
<Tooltip content="View in Fredy">
<Button
size="small"
icon={<IconEyeOpened />}
style={{ color: '#34d399' }}
theme="borderless"
onClick={(e) => {
e.stopPropagation();
onNavigate(item.id);
}}
</div>
<div className="listingsTable__row__title" title={item.title}>
{item.title}
</div>
<div className="listingsTable__row__price">
{item.price ? formatEuroPrice(item.price) : <span className="listingsTable__row__empty">---</span>}
</div>
<div className="listingsTable__row__address">
{item.address ? (
<>
<IconMapPin size="small" />
{item.address}
</>
) : (
<span className="listingsTable__row__empty">---</span>
)}
</div>
<div className="listingsTable__row__meta">
<IconBriefcase size="small" />
{item.provider}
</div>
<div className="listingsTable__row__date">{timeService.format(item.created_at, false, locale)}</div>
<div className="listingsTable__row__actions" onClick={(e) => e.stopPropagation()}>
<StatusControl
status={item.status?.status ?? null}
compact
onChange={(next) => onStatusChange?.(item, next)}
onTriggerClick={(e) => e.stopPropagation()}
/>
</Tooltip>
<Tooltip content="Remove">
<Button
size="small"
icon={<IconDelete />}
style={{ color: '#fb7185' }}
theme="borderless"
onClick={(e) => {
e.stopPropagation();
onDelete(item.id);
}}
/>
</Tooltip>
<Tooltip
content={
item.isWatched === 1 ? t('listings.tooltipRemoveFromWatchlist') : t('listings.tooltipAddToWatchlist')
}
>
<button
type="button"
className="listingsTable__row__star"
onClick={(e) => onWatch(e, item)}
aria-label={
item.isWatched === 1 ? t('listings.tooltipRemoveFromWatchlist') : t('listings.tooltipAddToWatchlist')
}
>
{item.isWatched === 1 ? <IconStar /> : <IconStarStroked />}
</button>
</Tooltip>
<Tooltip content={t('listings.tooltipOriginalListing')}>
<Button
size="small"
icon={<IconLink />}
style={{ color: '#60a5fa' }}
theme="borderless"
onClick={(e) => {
e.stopPropagation();
window.open(item.link, '_blank');
}}
/>
</Tooltip>
<Tooltip content={t('listings.tooltipViewInFredy')}>
<Button
size="small"
icon={<IconEyeOpened />}
style={{ color: '#34d399' }}
theme="borderless"
onClick={(e) => {
e.stopPropagation();
onNavigate(item.id);
}}
/>
</Tooltip>
<Tooltip content={t('listings.tooltipRemove')}>
<Button
size="small"
icon={<IconDelete />}
style={{ color: '#fb7185' }}
theme="borderless"
onClick={(e) => {
e.stopPropagation();
onDelete(item.id);
}}
/>
</Tooltip>
</div>
</div>
</div>
))}
</div>
);
))}
</div>
);
};
export default ListingsTable;

View File

@@ -55,8 +55,11 @@
color: @color-success;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
white-space: nowrap;
text-align: right;
font-variant-numeric: tabular-nums;
}
&__address {
@@ -94,26 +97,33 @@
}
&__star {
width: 28px;
height: 28px;
background: transparent;
border: none;
width: 30px;
height: 30px;
background: rgba(0,0,0,0.28);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0;
transition: background @transition-fast;
transition: background @transition-fast, border-color @transition-fast, transform @transition-fast;
flex-shrink: 0;
&:hover {
background: rgba(0, 0, 0, 0.1);
background: rgba(0,0,0,0.48);
border-color: rgba(255,255,255,0.22);
transform: translateY(-1px);
}
&:focus-visible {
outline: none;
box-shadow: 0 0 0 2px rgba(224,74,56,0.35);
}
svg {
color: @color-accent;
font-size: 14px;
font-size: 15px;
}
}

View File

@@ -5,15 +5,17 @@
import { Empty, Table, Button } from '@douyinfe/semi-ui-19';
import { IconDelete, IconEdit } from '@douyinfe/semi-icons';
import { useTranslation } from '../../services/i18n/i18n.jsx';
export default function NotificationAdapterTable({ notificationAdapter = [], onRemove, onEdit } = {}) {
const t = useTranslation();
return (
<Table
pagination={false}
empty={<Empty description="No notification adapters found." />}
empty={<Empty description={t('notification.tableEmptyState')} />}
columns={[
{
title: 'Name',
title: t('notification.tableColumnName'),
dataIndex: 'name',
},

View File

@@ -6,23 +6,25 @@
import { Empty, Table, Button } from '@douyinfe/semi-ui-19';
import { IconDelete, IconEdit } from '@douyinfe/semi-icons';
import { Typography } from '@douyinfe/semi-ui';
import { useTranslation } from '../../services/i18n/i18n.jsx';
export default function ProviderTable({ providerData = [], onRemove, onEdit } = {}) {
const t = useTranslation();
const { Text } = Typography;
return (
<Table
pagination={false}
empty={<Empty description="No providers found." />}
empty={<Empty description={t('provider.tableEmptyState')} />}
columns={[
{
title: 'Name',
title: t('provider.tableColumnName'),
dataIndex: 'name',
},
{
title: 'URL',
title: t('provider.tableColumnUrl'),
dataIndex: 'url',
render: (_, data) => {
return <Text link={{ href: data.url, target: '_blank' }}>Open Provider</Text>;
return <Text link={{ href: data.url, target: '_blank' }}>{t('provider.tableOpenProvider')}</Text>;
},
},
{

View File

@@ -7,19 +7,25 @@ import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-i
import { format } from '../../services/time/timeService';
import { Table, Button, Empty, Tag } from '@douyinfe/semi-ui-19';
import { IconDelete, IconEdit } from '@douyinfe/semi-icons';
const empty = (
<Empty image={<IllustrationNoResult />} darkModeImage={<IllustrationNoResultDark />} description="No users found." />
);
import { useTranslation, useLocale } from '../../services/i18n/i18n.jsx';
export default function UserTable({ user = [], onUserRemoval, onUserEdit } = {}) {
const t = useTranslation();
const locale = useLocale();
const empty = (
<Empty
image={<IllustrationNoResult />}
darkModeImage={<IllustrationNoResultDark />}
description={t('users.emptyState')}
/>
);
return (
<Table
pagination={false}
empty={empty}
columns={[
{
title: 'User',
title: t('users.tableColumnUser'),
dataIndex: 'username',
render: (value, record) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
@@ -38,23 +44,23 @@ export default function UserTable({ user = [], onUserRemoval, onUserEdit } = {})
padding: '0 8px',
}}
>
ADMIN
{t('users.tableAdminBadge')}
</Tag>
)}
</div>
),
},
{
title: 'Last login',
title: t('users.tableColumnLastLogin'),
dataIndex: 'lastLogin',
render: (value) => (value == null ? '---' : format(value)),
render: (value) => (value == null ? '---' : format(value, true, locale)),
},
{
title: 'Jobs',
title: t('users.tableColumnJobs'),
dataIndex: 'numberOfJobs',
},
{
title: 'MCP Token',
title: t('users.tableColumnMcpToken'),
dataIndex: 'mcpToken',
render: (value) => (
<span

View File

@@ -9,6 +9,7 @@ import { xhrPost } from '../../services/xhr.js';
import './TrackingModal.less';
import inDevelopment from '../../services/developmentMode.js';
import { useTranslation } from '../../services/i18n/i18n.jsx';
const saveResponse = async (analyticsEnabled) => {
await xhrPost('/api/admin/generalSettings', {
@@ -17,6 +18,8 @@ const saveResponse = async (analyticsEnabled) => {
};
export default function TrackingModal() {
const t = useTranslation();
if (inDevelopment()) {
return null;
}
@@ -34,27 +37,17 @@ export default function TrackingModal() {
}}
maskClosable={false}
closable={false}
okText="Yes! I want to help"
cancelText="No, thanks"
okText={t('tracking.okText')}
cancelText={t('tracking.cancelText')}
>
<Logo white />
<div className="trackingModal__description">
<p>Hey 👋</p>
<p>Fed up with popups? Yeah, me too. But this ones important, and I promise it will only appear once ;)</p>
<p>
Fredy is completely free (and will always remain free). If youd like, you can support me by donating through
my GitHub, but theres absolutely no obligation to do so.
</p>
<p>
However, it would be a huge help if youd allow me to collect some analytical data. Wait, before you click
"no", let me explain. If you agree, Fredy will send a ping once every 6 hours to my internal tracking project.
(Will be open-sourced soon)
</p>
<p>
The data includes: names of active adapters/providers, OS, architecture, Node version, and language. The
information is entirely anonymous and helps me understand which adapters/providers are most frequently used.
</p>
<p>Thanks🤘</p>
<p>{t('tracking.greeting')}</p>
<p>{t('tracking.paragraph1')}</p>
<p>{t('tracking.paragraph2')}</p>
<p>{t('tracking.paragraph3')}</p>
<p>{t('tracking.paragraph4')}</p>
<p>{t('tracking.thanks')}</p>
</div>
</Modal>
);

View File

@@ -9,10 +9,12 @@ import { IconAlertCircle, IconArrowRight } from '@douyinfe/semi-icons';
import { useSelector } from '../../services/state/store.js';
import './VersionBanner.less';
import { useTranslation } from '../../services/i18n/i18n.jsx';
const { Text } = Typography;
export default function VersionBanner() {
const t = useTranslation();
const [modalVisible, setModalVisible] = useState(false);
const versionUpdate = useSelector((state) => state.versionUpdate.versionUpdate);
@@ -28,13 +30,13 @@ export default function VersionBanner() {
<Space spacing={8} align="center">
<IconAlertCircle size="small" />
<Text strong size="small">
New version available
{t('version.newVersionAvailable')}
</Text>
<Tag color="amber" size="small" shape="circle">
{versionUpdate.version}
</Tag>
<Text type="tertiary" size="small">
Current: {versionUpdate.localFredyVersion}
{t('version.currentLabel', { version: versionUpdate.localFredyVersion })}
</Text>
</Space>
<Button
@@ -44,7 +46,7 @@ export default function VersionBanner() {
iconPosition="right"
onClick={() => setModalVisible(true)}
>
Release notes
{t('version.releaseNotes')}
</Button>
</div>
}
@@ -54,7 +56,7 @@ export default function VersionBanner() {
<Space spacing={8} align="center">
<Text strong>Fredy {versionUpdate.version}</Text>
<Tag color="amber" size="small">
New
{t('version.newBadge')}
</Tag>
</Space>
}
@@ -63,21 +65,21 @@ export default function VersionBanner() {
width={640}
footer={
<Space>
<Button onClick={() => setModalVisible(false)}>Close</Button>
<Button onClick={() => setModalVisible(false)}>{t('version.modalClose')}</Button>
<Button
type="primary"
icon={<IconArrowRight />}
iconPosition="right"
onClick={() => window.open(versionUpdate.url, '_blank')}
>
View on GitHub
{t('version.viewOnGithub')}
</Button>
</Space>
}
>
<Descriptions row size="small" className="versionBanner__details">
<Descriptions.Item itemKey="Your Version">{versionUpdate.localFredyVersion}</Descriptions.Item>
<Descriptions.Item itemKey="Latest Version">{versionUpdate.version}</Descriptions.Item>
<Descriptions.Item itemKey={t('version.yourVersion')}>{versionUpdate.localFredyVersion}</Descriptions.Item>
<Descriptions.Item itemKey={t('version.latestVersion')}>{versionUpdate.version}</Descriptions.Item>
</Descriptions>
<div className="versionBanner__notes">
<MarkdownRender raw={versionUpdate.body} />

View File

@@ -1,5 +1,5 @@
.versionBanner {
margin-bottom: 0 !important;
margin-bottom: 16px;
.semi-banner-body {
padding: 6px 16px;

456
ui/src/locales/de.json Normal file
View File

@@ -0,0 +1,456 @@
{
"_meta": {
"flag": "🇩🇪",
"name": "Deutsch",
"locale": "de-DE",
"semiLocale": "de"
},
"app.demoBanner": "Du nutzt gerade die Demo-Version von Fredy. Es werden keine Immo-Sites gescrapt, alle Änderungen werden um Mitternacht zurückgesetzt.",
"nav.dashboard": "Dashboard",
"nav.jobs": "Jobs",
"nav.listings": "Inserate",
"nav.listingsOverview": "Übersicht",
"nav.mapView": "Kartenansicht",
"nav.watchlist": "Watchlist",
"nav.settings": "Einstellungen",
"nav.userManagement": "Benutzerverwaltung",
"nav.settingsPage": "Einstellungen",
"nav.expandSidebar": "Seitenleiste ausklappen",
"nav.collapseSidebar": "Seitenleiste einklappen",
"login.usernamePlaceholder": "Benutzername",
"login.passwordPlaceholder": "Passwort",
"login.loginButton": "Anmelden",
"login.errorMandatory": "Benutzername und Passwort sind Pflichtfelder.",
"login.errorInvalid": "Anmeldung fehlgeschlagen. Bitte überprüfe Benutzername und Passwort.",
"login.demoBanner": "Dies ist die Demo-Version von Fredy. Verwende 'demo' als Benutzername & Passwort zum Einloggen.",
"dashboard.title": "Dashboard",
"dashboard.sectionGeneral": "Allgemein",
"dashboard.sectionOverview": "Übersicht",
"dashboard.sectionProviderInsights": "Anbieter-Einblicke",
"dashboard.searchInterval": "Suchintervall",
"dashboard.searchIntervalDesc": "Zeitintervall für Job-Ausführung",
"dashboard.lastSearch": "Letzte Suche",
"dashboard.lastSearchDesc": "Zeitstempel der letzten Ausführung",
"dashboard.nextSearch": "Nächste Suche",
"dashboard.nextSearchDesc": "Zeitstempel der nächsten Ausführung",
"dashboard.searchNow": "Jetzt suchen",
"dashboard.searchNowDesc": "Suche sofort starten",
"dashboard.searchNowButton": "Jetzt suchen",
"dashboard.searchNowStarted": "Fredy-Suche erfolgreich gestartet.",
"dashboard.searchNowFailed": "Suche konnte nicht gestartet werden",
"dashboard.kpiJobs": "Jobs",
"dashboard.kpiJobsDesc": "Gesamtanzahl der Jobs",
"dashboard.kpiListings": "Inserate",
"dashboard.kpiListingsDesc": "Insgesamt gefundene Inserate",
"dashboard.kpiActiveListings": "Aktive Inserate",
"dashboard.kpiActiveListingsDesc": "Gesamtanzahl aktiver Inserate",
"dashboard.kpiMedianPrice": "Medianpreis",
"dashboard.kpiMedianPriceDesc": "Medianpreis der Inserate",
"jobs.title": "Jobs",
"jobs.newJob": "Neuer Job",
"jobs.searchPlaceholder": "Suchen",
"jobs.filterAll": "Alle",
"jobs.filterActive": "Aktiv",
"jobs.filterInactive": "Inaktiv",
"jobs.sortByName": "Name",
"jobs.sortByListings": "Anzahl Inserate",
"jobs.sortByStatus": "Status",
"jobs.sortPrefix": "Sortieren nach",
"jobs.sortAscending": "Aufsteigend",
"jobs.sortDescending": "Absteigend",
"jobs.tooltipGridView": "Rasteransicht",
"jobs.tooltipTableView": "Tabellenansicht",
"jobs.empty": "Noch keine Jobs vorhanden...",
"jobs.cardListings": "Inserate",
"jobs.cardProviders": "Anbieter",
"jobs.cardAdapters": "Adapter",
"jobs.cardActive": "Aktiv",
"jobs.cardSharedReadOnly": "Dieser Job wurde mit dir geteilt (nur lesbar).",
"jobs.cardRunning": "LÄUFT",
"jobs.popoverRunJob": "Job ausführen",
"jobs.popoverEditJob": "Job bearbeiten",
"jobs.popoverCloneJob": "Job klonen",
"jobs.popoverDeleteListings": "Alle gefundenen Inserate dieses Jobs löschen",
"jobs.popoverDeleteJob": "Job löschen",
"jobs.toastFinished": "Job abgeschlossen",
"jobs.toastRunStarted": "Job-Ausführung gestartet",
"jobs.toastRunRequested": "Job-Ausführung angefordert",
"jobs.toastAlreadyRunning": "Job läuft bereits",
"jobs.toastNotAllowed": "Du bist nicht berechtigt, diesen Job auszuführen",
"jobs.toastNotFound": "Job nicht gefunden",
"jobs.toastRunFailed": "Job konnte nicht gestartet werden",
"jobs.toastStatusChanged": "Job-Status erfolgreich geändert",
"jobs.toastDeletedWithListings": "Job und Inserate erfolgreich entfernt",
"jobs.toastListingsDeleted": "Inserate erfolgreich entfernt",
"jobs.toastDeleteError": "Fehler beim Löschen",
"jobs.tableSharedTooltip": "Mit dir geteilt (nur lesbar)",
"jobs.tableRunJob": "Job ausführen",
"jobs.tableEditJob": "Job bearbeiten",
"jobs.tableCloneJob": "Job klonen",
"jobs.tableDeleteListings": "Alle gefundenen Inserate löschen",
"jobs.tableDeleteJob": "Job löschen",
"jobs.mutation.editTitle": "Job bearbeiten",
"jobs.mutation.createTitle": "Neuen Job erstellen",
"jobs.mutation.back": "Zurück",
"jobs.mutation.save": "Speichern",
"jobs.mutation.cancel": "Abbrechen",
"jobs.mutation.saved": "Job erfolgreich gespeichert...",
"jobs.mutation.sectionName": "Name",
"jobs.mutation.namePlaceholder": "Name",
"jobs.mutation.sectionProviders": "Anbieter",
"jobs.mutation.providersHelp": "Ein Anbieter ist der Dienst (z. B. ImmoScout24, Kleinanzeigen), den Fredy nach neuen Inseraten durchsucht. Fredy öffnet einen neuen Tab mit der Website des Anbieters. Du musst deine Suchparameter anpassen und auf 'Suchen' klicken. Sobald die Ergebnisse angezeigt werden, kopiere die Browser-URL hier hinein.",
"jobs.mutation.addProvider": "Neuen Anbieter hinzufügen",
"jobs.mutation.sectionNotifications": "Benachrichtigungs-Adapter",
"jobs.mutation.notificationsHelp": "Fredy unterstützt mehrere Wege, dich über neue Inserate zu benachrichtigen. Diese werden als Benachrichtigungs-Adapter bezeichnet. Du kannst zwischen E-Mail, Telegram u. a. wählen.",
"jobs.mutation.addNotification": "Neuen Benachrichtigungs-Adapter hinzufügen",
"jobs.mutation.sectionBlacklist": "Blacklist",
"jobs.mutation.blacklistHelp": "Wenn ein Inserat eines dieser Wörter enthält, wird es herausgefiltert. Gib ein Wort ein und bestätige mit Enter.",
"jobs.mutation.blacklistPlaceholder": "Wort zum Filtern hinzufügen...",
"jobs.mutation.sectionCriteriaFilter": "Kriterienfilter",
"jobs.mutation.criteriaFilterHelp": "Inserate nach bestimmten Kriterien filtern. Nur Zahlen sind erlaubt. Felder können leer gelassen werden, wenn kein Filter gewünscht ist.",
"jobs.mutation.criteriaNumberPlaceholder": "Zahl eingeben",
"jobs.mutation.filterMaxPrice": "Höchstpreis",
"jobs.mutation.filterMinSize": "Mindestgröße (m²)",
"jobs.mutation.filterMinRooms": "Mindestzimmeranzahl",
"jobs.mutation.sectionAreaFilter": "Gebietsfilter",
"jobs.mutation.areaFilterHelp": "Definiere mehrere geografische Gebiete auf der Karte, um Inserate zu filtern. Beginne mit dem Zeichnen durch Klick auf das Quadrat-Symbol oben links auf der Karte. Klicke auf die Karte, um Punkte des Polygons hinzuzufügen. Wähle den ersten Punkt, um das Polygon zu schließen. Klicke danach auf eine freie Kartenfläche, um das Polygon anzuwenden (Farbe wechselt von Gelb zu Blau). Um ein Polygon zu löschen, wähle es zuerst aus und klicke dann auf das Papierkorb-Symbol.",
"jobs.mutation.sectionSharing": "Mit Benutzer teilen",
"jobs.mutation.sharingHelp": "Du kannst diesen Job mit anderen Benutzern teilen. Diese können die Inserate einsehen, aber nur du (als Ersteller) kannst den Job bearbeiten. Admins sind aus dieser Liste ausgeblendet, da sie Zugriff auf alles haben.",
"jobs.mutation.sharingNoUsers": "Keine Benutzer zum Teilen gefunden. Bitte erstelle weitere Nicht-Admin-Benutzer.",
"jobs.mutation.sharingSearchPlaceholder": "Benutzer suchen",
"jobs.mutation.sectionActivation": "Job-Aktivierung",
"jobs.mutation.activationHelp": "Gibt an, ob der Job aktiviert ist. Inaktive Jobs werden ignoriert, wenn Fredy nach neuen Inseraten sucht.",
"jobs.deletion.title": "Job löschen",
"jobs.deletion.message": "Bist du sicher, dass du diesen Job löschen möchtest? Alle zugehörigen Inserate werden aus der Datenbank entfernt.",
"listings.title": "Inserate",
"listings.watchlistTitle": "Watchlist",
"listings.searchPlaceholder": "Suchen",
"listings.filterAll": "Alle",
"listings.filterActive": "Aktiv",
"listings.filterInactive": "Inaktiv",
"listings.filterWatched": "Beobachtet",
"listings.filterUnwatched": "Nicht beobachtet",
"listings.filterStatusPlaceholder": "Status",
"listings.filterStatusApplied": "Beworben",
"listings.filterStatusRejected": "Abgelehnt",
"listings.filterStatusAccepted": "Angenommen",
"listings.filterStatusNone": "Kein Status",
"listings.filterProviderPlaceholder": "Anbieter",
"listings.filterJobPlaceholder": "Job",
"listings.sortByJobName": "Job-Name",
"listings.sortByDate": "Inserat-Datum",
"listings.sortByPrice": "Preis",
"listings.sortByProvider": "Anbieter",
"listings.sortPrefix": "Sortieren nach",
"listings.sortAscending": "Aufsteigend",
"listings.sortDescending": "Absteigend",
"listings.tooltipGridView": "Rasteransicht",
"listings.tooltipTableView": "Tabellenansicht",
"listings.empty": "Noch keine Inserate vorhanden...",
"listings.toastAddedToWatchlist": "Inserat zur Watchlist hinzugefügt",
"listings.toastRemovedFromWatchlist": "Inserat von der Watchlist entfernt",
"listings.toastWatchlistError": "Watchlist-Aktion fehlgeschlagen",
"listings.toastStatusCleared": "Status zurückgesetzt",
"listings.toastStatusMarked": "Als {{status}} markiert",
"listings.toastStatusUpdateError": "Status konnte nicht aktualisiert werden",
"listings.toastDeleted": "Inserat erfolgreich entfernt",
"listings.toastDeleteError": "Fehler beim Löschen des Inserats",
"listings.cardInactive": "Inaktiv",
"listings.tooltipAddToWatchlist": "Zur Watchlist hinzufügen",
"listings.tooltipRemoveFromWatchlist": "Von der Watchlist entfernen",
"listings.tooltipOriginalListing": "Original-Inserat",
"listings.tooltipViewInFredy": "In Fredy anzeigen",
"listings.tooltipRemove": "Entfernen",
"listing.detail.back": "Zurück",
"listing.detail.defaultTitle": "Inserat-Details",
"listing.detail.noAddress": "Keine Adresse angegeben",
"listing.detail.watch": "Beobachten",
"listing.detail.watched": "Beobachtet",
"listing.detail.openListing": "Inserat öffnen",
"listing.detail.delete": "Löschen",
"listing.detail.noImageAlt": "Kein Bild verfügbar",
"listing.detail.notesTitle": "Notizen",
"listing.detail.notesPlaceholder": "Deine privaten Notizen zu diesem Inserat…",
"listing.detail.storeNotes": "Notizen speichern",
"listing.detail.detailsTitle": "Details",
"listing.detail.descriptionTitle": "Beschreibung",
"listing.detail.noDescription": "Keine Beschreibung verfügbar.",
"listing.detail.distanceToHome": "Entfernung nach Hause:",
"listing.detail.locationTitle": "Lage",
"listing.detail.noGeoWarning": "Dieses Inserat hat keine gültigen Geokoordinaten und kann daher nicht auf der Karte angezeigt werden.",
"listing.detail.fieldPrice": "Preis",
"listing.detail.fieldPriceHelp": "Der Angebotspreis dieses Inserats laut Anbieter.",
"listing.detail.fieldSize": "Größe",
"listing.detail.fieldSizeHelp": "Wohnfläche des Inserats in Quadratmetern.",
"listing.detail.fieldRooms": "Zimmer",
"listing.detail.fieldRoomsHelp": "Anzahl der Zimmer im Inserat.",
"listing.detail.fieldJob": "Job",
"listing.detail.fieldJobHelp": "Der Fredy-Job, der dieses Inserat gefunden hat.",
"listing.detail.fieldProvider": "Anbieter",
"listing.detail.fieldProviderHelp": "Das Immobilienportal, von dem dieses Inserat gescrapt wurde.",
"listing.detail.fieldAdded": "Hinzugefügt",
"listing.detail.fieldAddedHelp": "Wann Fredy dieses Inserat erstmals zur Datenbank hinzugefügt hat.",
"listing.detail.fieldStatus": "Status",
"listing.detail.statusApplied": "Beworben",
"listing.detail.statusAccepted": "Akzeptiert",
"listing.detail.statusRejected": "Abgelehnt",
"listing.detail.statusSetAt": "(gesetzt am {{date}})",
"listing.detail.fieldStatusHelp": "Der von dir gesetzte Status für dieses Inserat und wann du ihn gesetzt hast.",
"listing.detail.fieldRoomsValue": "{{count}} Zimmer",
"listing.detail.mapPopupListingLocation": "Inserat-Standort",
"listing.detail.mapPopupHomeAddress": "Heimatadresse",
"listing.detail.toastDeleted": "Inserat erfolgreich entfernt",
"listing.detail.toastDeleteError": "Fehler beim Löschen des Inserats",
"listing.detail.toastWatchlistAdded": "Zur Watchlist hinzugefügt",
"listing.detail.toastWatchlistRemoved": "Von der Watchlist entfernt",
"listing.detail.toastWatchlistError": "Watchlist-Aktion fehlgeschlagen",
"listing.detail.toastNotesSaved": "Notizen gespeichert",
"listing.detail.toastNotesError": "Notizen konnten nicht gespeichert werden",
"listing.detail.toastLoadError": "Inserat-Details konnten nicht geladen werden",
"listing.deletion.title": "Inserate löschen",
"listing.deletion.message": "Wie möchtest du die ausgewählten Inserate löschen?",
"listing.deletion.confirm": "Bestätigen",
"listing.deletion.cancel": "Abbrechen",
"listing.deletion.softLabel": "Als gelöscht markieren (Soft Delete)",
"listing.deletion.softDescription": "Inserate bleiben in der Datenbank, werden aber als ausgeblendet markiert. Sie erscheinen beim nächsten Scraping nicht erneut.",
"listing.deletion.hardLabel": "Aus Datenbank entfernen (Hard Delete)",
"listing.deletion.hardDescription": "Inserate werden vollständig aus der Datenbank entfernt.",
"listing.deletion.hardConsequence": "Folge: Sie könnten beim nächsten Scraping wieder erscheinen, da Fredy nicht weiß, dass sie bereits gefunden wurden.",
"listing.deletion.rememberChoice": "Meine Wahl merken und diesen Dialog beim nächsten Mal überspringen",
"listings.status.none": "Kein",
"listings.status.applied": "Beworben",
"listings.status.rejected": "Abgelehnt",
"listings.status.accepted": "Angenommen",
"listings.status.statusLabel": "Status",
"listings.status.tooltip": "Verfolge deinen Stand bei diesem Inserat: Beworben, sobald du Kontakt aufgenommen hast, Abgelehnt wenn es nicht geklappt hat, oder Angenommen wenn du es bekommen hast.",
"map.title": "Kartenansicht",
"map.noHomeAddress": "Keine Heimatadresse gesetzt. Konfiguriere sie in den Benutzereinstellungen, um den Entfernungsfilter zu nutzen.",
"map.onlyValidAddresses": "Auf dieser Karte werden nur Inserate mit gültigen Adressen angezeigt.",
"map.filterJobLabel": "Job",
"map.filterJobPlaceholder": "Alle Jobs",
"map.filterDistanceLabel": "Entfernung",
"map.filterDistanceNone": "Keine",
"map.filterPriceLabel": "Preis (€)",
"map.filterStyleLabel": "Stil",
"map.filterStyleStandard": "Standard",
"map.filterStyleSatellite": "Satellit",
"map.filter3dBuildings": "3D-Gebäude",
"map.popupPrice": "Preis:",
"map.popupAddress": "Adresse:",
"map.popupJob": "Job:",
"map.popupProvider": "Anbieter:",
"map.popupSize": "Größe:",
"map.popupViewDetails": "Details anzeigen",
"map.popupRemove": "Entfernen",
"map.popupHomeAddress": "Heimatadresse",
"map.noHomeAddressBefore": "Keine Heimadresse gesetzt. Konfiguriere sie in den ",
"map.noHomeAddressLink": "Benutzereinstellungen",
"map.noHomeAddressAfter": ", um den Entfernungsfilter zu nutzen.",
"map.toastDeleted": "Inserat erfolgreich entfernt",
"map.toastDeleteError": "Fehler beim Löschen des Inserats",
"users.title": "Benutzer",
"users.newUser": "Neuer Benutzer",
"users.tableColumnUser": "Benutzer",
"users.tableColumnLastLogin": "Letzter Login",
"users.tableColumnJobs": "Jobs",
"users.tableColumnMcpToken": "MCP-Token",
"users.tableAdminBadge": "ADMIN",
"users.emptyState": "Keine Benutzer gefunden.",
"users.toastRemoved": "Benutzer erfolgreich entfernt",
"users.removalModal.title": "Benutzer entfernen",
"users.removalModal.message": "Das Entfernen dieses Benutzers entfernt auch alle zugehörigen Jobs.",
"users.mutation.editTitle": "Benutzer bearbeiten",
"users.mutation.newTitle": "Neuer Benutzer",
"users.mutation.back": "Zurück",
"users.mutation.save": "Speichern",
"users.mutation.cancel": "Abbrechen",
"users.mutation.saved": "Benutzer erfolgreich gespeichert...",
"users.mutation.sectionUsername": "Benutzername",
"users.mutation.usernameHelp": "Der Benutzername für den Login bei Fredy",
"users.mutation.usernamePlaceholder": "Benutzername",
"users.mutation.sectionPassword": "Passwort",
"users.mutation.passwordHelp": "Das Passwort für den Login bei Fredy",
"users.mutation.passwordPlaceholder": "Passwort",
"users.mutation.sectionRetypePassword": "Passwort wiederholen",
"users.mutation.retypePasswordHelp": "Passwort wiederholen, um die Übereinstimmung zu prüfen",
"users.mutation.retypePasswordPlaceholder": "Passwort wiederholen",
"users.mutation.sectionIsAdmin": "Ist der Benutzer ein Admin?",
"users.mutation.isAdminHelp": "Aktivieren, wenn der Benutzer ein Administrator ist",
"settings.title": "Einstellungen",
"settings.tabSystem": "System",
"settings.tabExecution": "Ausführung",
"settings.tabUserSettings": "Benutzereinstellungen",
"settings.tabBackup": "Backup & Wiederherstellung",
"settings.save": "Speichern",
"settings.port": "Port",
"settings.portHelp": "Der Port, auf dem Fredy läuft.",
"settings.portPlaceholder": "Port",
"settings.baseUrl": "Basis-URL",
"settings.baseUrlHelp": "Öffentliche URL, unter der Fredy erreichbar ist (z. B. http://192.168.1.10:9998). Wird für 'In Fredy öffnen'-Links in Benachrichtigungen verwendet.",
"settings.baseUrlPlaceholder": "Basis-URL",
"settings.sqlitePath": "SQLite-Datenbankpfad",
"settings.sqlitePathHelp": "Das Verzeichnis, in dem Fredy seine SQLite-Datenbankdateien speichert.",
"settings.sqlitePathWarning": "Das Ändern dieses Pfades kann zu Datenverlust führen. Starte Fredy sofort nach dem Speichern neu.",
"settings.sqlitePathPlaceholder": "Datenbankordnerpfad",
"settings.analytics": "Analysen",
"settings.analyticsHelp": "Anonyme Nutzungsdaten zur Verbesserung von Fredy (Anbieter, Adapter-Namen, Betriebssystem, Node-Version und Architektur).",
"settings.analyticsEnable": "Analysen aktivieren",
"settings.demoMode": "Demo-Modus",
"settings.demoModeHelp": "Im Demo-Modus sucht Fredy nicht nach Immobilien und alle Daten werden um Mitternacht auf Standardwerte zurückgesetzt.",
"settings.demoModeEnable": "Demo-Modus aktivieren",
"settings.searchInterval": "Suchintervall",
"settings.searchIntervalHelp": "Intervall in Minuten für Anfragen an konfigurierte Dienste. Gehe nicht unter 5 Minuten, um nicht als Bot erkannt zu werden.",
"settings.searchIntervalPlaceholder": "Intervall in Minuten",
"settings.searchIntervalSuffix": "Minuten",
"settings.workingHours": "Arbeitszeiten",
"settings.workingHoursHelp": "Fredy sucht nur während dieser Zeiten nach Inseraten. Leer lassen, um rund um die Uhr zu suchen.",
"settings.workingHoursFrom": "Von",
"settings.workingHoursUntil": "Bis",
"settings.proxyUrl": "Proxy-URL",
"settings.proxyUrlHelp": "Optional. Leitet den Scraping-Browser durch einen Proxy. Server/Rechenzentrum-IPs werden von Anbietern (z. B. Immowelt) unabhängig vom Browser-Fingerprint häufig blockiert. Ein deutscher Wohnproxy lässt Anfragen wie einen normalen Haushalt erscheinen. Format: http://benutzer:passwort@host:port oder socks5://benutzer:passwort@host:port. Leer lassen zum Deaktivieren.",
"settings.proxyUrlPlaceholder": "http://benutzer:passwort@host:port",
"settings.homeAddress": "Heimatadresse",
"settings.homeAddressHelp": "Wird zur Berechnung der Entfernung zwischen deinem Standort und jedem Inserat verwendet. Eine Aktualisierung berechnet die Entfernungen für alle aktiven Inserate neu.",
"settings.homeAddressPlaceholder": "Heimatadresse eingeben",
"settings.homeAddressGeoError": "Adresse gefunden, konnte aber nicht genau geokodiert werden.",
"settings.providerDetails": "Anbieter-Details",
"settings.providerDetailsHelp": "Zusätzliche Details (Beschreibung, Attribute, Maklerinfos) für Inserate abrufen. Erfordert einen zusätzlichen API-Aufruf pro Inserat.",
"settings.providerDetailsWarning": "Das Aktivieren dieser Funktion erhöht die API-Anfragen an Anbieter erheblich und erhöht das Risiko von Rate-Limiting oder Blockierung. Auf eigene Gefahr verwenden.",
"settings.providerDetailsPlaceholder": "Anbieter für Detail-Abruf auswählen...",
"settings.providerDetailsUpdated": "Anbieter-Detail-Einstellung aktualisiert.",
"settings.providerDetailsUpdateError": "Einstellung konnte nicht aktualisiert werden.",
"settings.listingDeletion": "Inserate löschen",
"settings.listingDeletionHelp": "Wähle den Standard-Löschmodus. Soft Delete blendet Inserate aus ohne erneutes Scraping; Hard Delete entfernt sie aus der Datenbank.",
"settings.listingDeletionSoftLabel": "Als gelöscht markieren (Soft Delete)",
"settings.listingDeletionSoftDesc": "Inserate bleiben in der Datenbank, werden aber als ausgeblendet markiert. Sie erscheinen beim nächsten Scraping nicht erneut.",
"settings.listingDeletionHardLabel": "Aus Datenbank entfernen (Hard Delete)",
"settings.listingDeletionHardDesc": "Inserate werden vollständig aus der Datenbank entfernt.",
"settings.listingDeletionHardConsequence": "Folge: Sie könnten beim nächsten Scraping wieder erscheinen, da Fredy nicht weiß, dass sie bereits gefunden wurden.",
"settings.listingDeletionSkipPrompt": "Bestätigungsdialog überspringen",
"settings.userSettingsSaved": "Einstellungen gespeichert. Entfernungsberechnungen laufen im Hintergrund.",
"settings.userSettingsSaveError": "Fehler beim Speichern der Einstellungen",
"settings.backupDownload": "Backup herunterladen",
"settings.backupRestoreFromZip": "Aus ZIP wiederherstellen",
"settings.backupHelp": "Lade ein gezipptes Backup deiner Datenbank herunter oder stelle es aus einem Backup-ZIP wieder her.",
"settings.backupSectionName": "Backup & Wiederherstellung",
"settings.backupDemoWarning": "Backup und Wiederherstellung sind im Demo-Modus nicht verfügbar.",
"settings.backupDownloadError": "Unerwarteter Fehler beim Herunterladen des Backups.",
"settings.backupAnalyzeError": "Backup konnte nicht analysiert werden.",
"settings.backupRestoreCompleted": "Wiederherstellung abgeschlossen. Bitte starte das Fredy-Backend jetzt neu!",
"settings.backupRestoreError": "Unerwarteter Fehler bei der Wiederherstellung des Backups.",
"settings.restoreModalTitle": "Datenbank wiederherstellen",
"settings.restoreNow": "Jetzt wiederherstellen",
"settings.restoreAnyway": "Trotzdem wiederherstellen",
"settings.restoreProblemDetected": "Problem erkannt",
"settings.restoreMigrationsApplied": "Automatische Migrationen werden angewendet",
"settings.restoreCompatible": "Backup ist kompatibel",
"settings.restoreMigrationInfo": "Backup-Migration: {{backupMigration}} | Erforderliche Migration: {{requiredMigration}}",
"settings.toastIntervalEmpty": "Das Intervall darf nicht leer sein.",
"settings.toastPortEmpty": "Der Port darf nicht leer sein.",
"settings.toastWorkingHoursIncomplete": "Arbeitszeiten Von und Bis müssen beide gesetzt sein, wenn eines davon gesetzt wurde.",
"settings.toastSqlitePathEmpty": "Der SQLite-Datenbankpfad darf nicht leer sein.",
"settings.toastSavedReloading": "Einstellungen erfolgreich gespeichert. Der Browser wird in 3 Sekunden neu geladen.",
"settings.toastSaveError": "Fehler beim Speichern der Einstellungen.",
"watchlist.sectionName": "Benachrichtigung für Watchlist",
"watchlist.sectionHelp": "Du kannst bei Änderungen an Inseraten auf deiner Watchlist benachrichtigt werden.",
"watchlist.noteTitle": "Hinweis",
"watchlist.noteDescription": "Du erhältst Benachrichtigungen nur für Inserate auf deiner Watchlist. Um Inserate hinzuzufügen, öffne den Bereich 'Inserate' und markiere die gewünschten.",
"watchlist.notifyMeWhen": "Benachrichtige mich wenn:",
"watchlist.activityChanges": "Inserat-Status ändert sich (z. B. Inserat wird inaktiv)",
"watchlist.priceChanges": "Inserat-Preis ändert sich",
"watchlist.notifyMeWith": "Benachrichtige mich per:",
"watchlist.selectNotificationMethod": "Benachrichtigungsmethode auswählen",
"watchlist.addNotificationTitle": "Benachrichtigungsmethode hinzufügen",
"watchlist.addNotificationDescription": "Wenn sich etwas geändert hat, benachrichtigt dich Fredy über den ausgewählten Benachrichtigungs-Adapter. Hinweis: Einige Adapter wie SQLite sind hier nicht verfügbar.",
"notification.defaultTitle": "Neuen Benachrichtigungs-Adapter hinzufügen",
"notification.description": "Wenn Fredy neue Inserate findet, möchten wir dich darüber informieren. Dazu können Benachrichtigungs-Adapter konfiguriert werden. Es gibt mehrere Wege, wie Fredy neue Inserate an dich senden kann. Wähle deinen Kanal...",
"notification.selectPlaceholder": "Benachrichtigungs-Adapter auswählen",
"notification.try": "Testen",
"notification.cancel": "Abbrechen",
"notification.save": "Speichern",
"notification.trySuccess": "Es scheint geklappt zu haben! Bitte überprüfe deinen Dienst.",
"notification.tryError": "Das hat leider nicht funktioniert :-( Ich habe folgenden Fehler erhalten: {{error}}",
"notification.errorTitle": "Fehler",
"notification.successTitle": "Super!",
"notification.validationAllMandatory": "Alle Felder sind Pflichtfelder und müssen ausgefüllt werden.",
"notification.validationNumberField": "Ein Zahlenfeld darf nur Zahlen enthalten und muss größer 0 sein.",
"notification.validationBooleanField": "Ein Boolean-Feld darf keinen anderen Typ haben.",
"notification.infoTitle": "Information",
"notification.tableEmptyState": "Keine Benachrichtigungs-Adapter gefunden.",
"notification.tableColumnName": "Name",
"provider.defaultTitle": "Neuen Anbieter hinzufügen",
"provider.editTitle": "Bestehenden Anbieter bearbeiten",
"provider.save": "Speichern",
"provider.description": "Anbieter sind das Herzstück von Fredy. Wir unterstützen mehrere Anbieter wie Immowelt, Immoscout usw. Wähle einen Anbieter aus der Liste. Fredy öffnet dann die URL des Anbieters in einem neuen Tab.",
"provider.descriptionStep2": "Du musst deine Suchparameter konfigurieren, so wie du es bei einer normalen Suche auf der Anbieter-Website tun würdest. Wenn die Suchergebnisse angezeigt werden, kopiere die URL und füge sie in das Textfeld ein.",
"provider.editDescription": "Du kannst jetzt die URL des Anbieters {{name}} im Eingabefeld unten bearbeiten.",
"provider.selectPlaceholder": "Anbieter auswählen",
"provider.urlPlaceholder": "Anbieter-URL",
"provider.validationSelectAndUrl": "Bitte wähle einen Anbieter aus und kopiere die Browser-URL in das Textfeld, nachdem du deine Suchparameter konfiguriert hast.",
"provider.validationInvalidUrl": "Die kopierte URL ist ungültig.",
"provider.errorTitle": "Fehler",
"provider.tableEmptyState": "Keine Anbieter gefunden.",
"provider.tableColumnName": "Name",
"provider.tableColumnUrl": "URL",
"provider.tableOpenProvider": "Anbieter öffnen",
"news.videoFallback": "Dein Browser unterstützt das Video-Tag nicht.",
"version.newVersionAvailable": "Neue Version verfügbar",
"version.currentLabel": "Aktuell: {{version}}",
"version.releaseNotes": "Versionshinweise",
"version.newBadge": "Neu",
"version.modalClose": "Schließen",
"version.viewOnGithub": "Auf GitHub ansehen",
"version.yourVersion": "Deine Version",
"version.latestVersion": "Neueste Version",
"tracking.okText": "Ja! Ich möchte helfen",
"tracking.cancelText": "Nein, danke",
"tracking.greeting": "Hey 👋",
"tracking.paragraph1": "Genug von Popups? Ich auch. Aber dieses hier ist wichtig, und es wird nur einmal erscheinen ;)",
"tracking.paragraph2": "Fredy ist völlig kostenlos (und wird es immer bleiben). Wenn du möchtest, kannst du mich über GitHub unterstützen, aber das ist absolut keine Pflicht.",
"tracking.paragraph3": "Es wäre jedoch eine große Hilfe, wenn du mir erlaubst, einige Analysedaten zu sammeln. Warte, bevor du auf 'Nein' klickst, lass mich erklären. Wenn du zustimmst, sendet Fredy alle 6 Stunden einen Ping an mein internes Tracking-Projekt. (Wird bald open-source)",
"tracking.paragraph4": "Die Daten umfassen: Namen aktiver Adapter/Anbieter, Betriebssystem, Architektur, Node-Version und Sprache. Die Informationen sind vollständig anonym und helfen mir zu verstehen, welche Adapter/Anbieter am häufigsten genutzt werden.",
"tracking.thanks": "Danke🤘",
"permission.title": "Unzureichende Berechtigung :(",
"footer.madeWith": "Mit ❤️ entwickelt von",
"dashboard.noData": "Keine Daten",
"common.save": "Speichern",
"common.cancel": "Abbrechen",
"common.delete": "Löschen",
"common.edit": "Bearbeiten",
"common.back": "Zurück",
"common.confirm": "Bestätigen",
"common.yes": "Ja",
"common.no": "Nein",
"common.na": "k. A.",
"common.loading": "Laden...",
"common.ariaGridView": "Rasteransicht",
"common.ariaTableView": "Tabellenansicht",
"common.startNow": "Jetzt starten",
"settings.language": "Sprache",
"settings.languageHelp": "Die Sprache der Benutzeroberfläche.",
"settings.languageSaveError": "Spracheinstellung konnte nicht gespeichert werden."
}

456
ui/src/locales/en.json Normal file
View File

@@ -0,0 +1,456 @@
{
"_meta": {
"flag": "🇬🇧",
"name": "English",
"locale": "en-US",
"semiLocale": "en_US"
},
"app.demoBanner": "You're currently viewing the demo version of Fredy. Jobs won't scrape websites, and any changes you make will be reverted at midnight.",
"nav.dashboard": "Dashboard",
"nav.jobs": "Jobs",
"nav.listings": "Listings",
"nav.listingsOverview": "Overview",
"nav.mapView": "Map View",
"nav.watchlist": "Watchlist",
"nav.settings": "Settings",
"nav.userManagement": "User Management",
"nav.settingsPage": "Settings",
"nav.expandSidebar": "Expand sidebar",
"nav.collapseSidebar": "Collapse sidebar",
"login.usernamePlaceholder": "Username",
"login.passwordPlaceholder": "Password",
"login.loginButton": "Login",
"login.errorMandatory": "Username and password are mandatory.",
"login.errorInvalid": "Login unsuccessful. Please check your username and password.",
"login.demoBanner": "This is the demo version of Fredy. Use 'demo' as both the username and password to log in.",
"dashboard.title": "Dashboard",
"dashboard.sectionGeneral": "General",
"dashboard.sectionOverview": "Overview",
"dashboard.sectionProviderInsights": "Provider Insights",
"dashboard.searchInterval": "Search Interval",
"dashboard.searchIntervalDesc": "Time interval for job execution",
"dashboard.lastSearch": "Last Search",
"dashboard.lastSearchDesc": "Last execution timestamp",
"dashboard.nextSearch": "Next Search",
"dashboard.nextSearchDesc": "Next execution timestamp",
"dashboard.searchNow": "Search Now",
"dashboard.searchNowDesc": "Run a search now",
"dashboard.searchNowButton": "Search now",
"dashboard.searchNowStarted": "Successfully triggered Fredy search.",
"dashboard.searchNowFailed": "Failed to trigger search",
"dashboard.kpiJobs": "Jobs",
"dashboard.kpiJobsDesc": "Total number of jobs",
"dashboard.kpiListings": "Listings",
"dashboard.kpiListingsDesc": "Total listings found",
"dashboard.kpiActiveListings": "Active Listings",
"dashboard.kpiActiveListingsDesc": "Total active listings",
"dashboard.kpiMedianPrice": "Median Price",
"dashboard.kpiMedianPriceDesc": "Median Price of listings",
"jobs.title": "Jobs",
"jobs.newJob": "New Job",
"jobs.searchPlaceholder": "Search",
"jobs.filterAll": "All",
"jobs.filterActive": "Active",
"jobs.filterInactive": "Inactive",
"jobs.sortByName": "Name",
"jobs.sortByListings": "Number of Listings",
"jobs.sortByStatus": "Status",
"jobs.sortPrefix": "Sort by",
"jobs.sortAscending": "Ascending",
"jobs.sortDescending": "Descending",
"jobs.tooltipGridView": "Grid view",
"jobs.tooltipTableView": "Table view",
"jobs.empty": "No jobs available yet...",
"jobs.cardListings": "Listings",
"jobs.cardProviders": "Providers",
"jobs.cardAdapters": "Adapters",
"jobs.cardActive": "Active",
"jobs.cardSharedReadOnly": "This job has been shared with you - read only.",
"jobs.cardRunning": "RUNNING",
"jobs.popoverRunJob": "Run Job",
"jobs.popoverEditJob": "Edit a Job",
"jobs.popoverCloneJob": "Clone Job",
"jobs.popoverDeleteListings": "Delete all found Listings of this Job",
"jobs.popoverDeleteJob": "Delete Job",
"jobs.toastFinished": "Job finished",
"jobs.toastRunStarted": "Job run started",
"jobs.toastRunRequested": "Job run requested",
"jobs.toastAlreadyRunning": "Job is already running",
"jobs.toastNotAllowed": "You are not allowed to run this job",
"jobs.toastNotFound": "Job not found",
"jobs.toastRunFailed": "Failed to trigger job",
"jobs.toastStatusChanged": "Job status successfully changed",
"jobs.toastDeletedWithListings": "Job and listings successfully removed",
"jobs.toastListingsDeleted": "Listings successfully removed",
"jobs.toastDeleteError": "Error performing deletion",
"jobs.tableSharedTooltip": "Shared with you - read only",
"jobs.tableRunJob": "Run Job",
"jobs.tableEditJob": "Edit Job",
"jobs.tableCloneJob": "Clone Job",
"jobs.tableDeleteListings": "Delete all found Listings",
"jobs.tableDeleteJob": "Delete Job",
"jobs.mutation.editTitle": "Edit Job",
"jobs.mutation.createTitle": "Create new Job",
"jobs.mutation.back": "Back",
"jobs.mutation.save": "Save",
"jobs.mutation.cancel": "Cancel",
"jobs.mutation.saved": "Job successfully saved...",
"jobs.mutation.sectionName": "Name",
"jobs.mutation.namePlaceholder": "Name",
"jobs.mutation.sectionProviders": "Providers",
"jobs.mutation.providersHelp": "A provider is essentially the service (e.g. ImmoScout24, Kleinanzeigen) that Fredy searches for new listings. Fredy will open a new tab pointing to the website of this provider. You have to adjust your search parameter and click on \"Search\". If the results are being shown, copy the browser URL in here.",
"jobs.mutation.addProvider": "Add new Provider",
"jobs.mutation.sectionNotifications": "Notification Adapters",
"jobs.mutation.notificationsHelp": "Fredy supports multiple ways to notify you about new findings. These are called notification adapter. You can chose between email, Telegram etc.",
"jobs.mutation.addNotification": "Add new Notification Adapter",
"jobs.mutation.sectionBlacklist": "Blacklist",
"jobs.mutation.blacklistHelp": "If a listing contains one of these words, it will be filtered out. Type in a word, then hit enter.",
"jobs.mutation.blacklistPlaceholder": "Add a word for filtering...",
"jobs.mutation.sectionCriteriaFilter": "Criteria Filter",
"jobs.mutation.criteriaFilterHelp": "Filter listings by specific criteria. Only numbers are allowed. You can leave fields empty if you don't want to filter by them.",
"jobs.mutation.criteriaNumberPlaceholder": "Add a number",
"jobs.mutation.filterMaxPrice": "Max Price",
"jobs.mutation.filterMinSize": "Min Size (m²)",
"jobs.mutation.filterMinRooms": "Min Rooms",
"jobs.mutation.sectionAreaFilter": "Area Filter",
"jobs.mutation.areaFilterHelp": "Define multiple geographic areas on the map to filter listings. Start drawing by clicking on the square symbol in the top left corner of the map. Click on the map to add points of the polygon. Select the first point to close the polygon. After that, click on a free area of the map to apply this polygon (the color will change from yellow to blue). To delete a polygon, select it first and then click on the trash symbol.",
"jobs.mutation.sectionSharing": "Sharing with user",
"jobs.mutation.sharingHelp": "You can share this job with other users. They will be able to see the listings, but only (as the creator) you can edit the job. Admins are filtered from this list as they have access to everything.",
"jobs.mutation.sharingNoUsers": "No users found to share this Job to. Please create additional non-admin user.",
"jobs.mutation.sharingSearchPlaceholder": "Search user",
"jobs.mutation.sectionActivation": "Job activation",
"jobs.mutation.activationHelp": "Whether or not the job is activated. Inactive jobs will be ignored when Fredy checks for new listings.",
"jobs.deletion.title": "Delete Job",
"jobs.deletion.message": "Are you sure you want to delete this job? All associated listings will be removed from the database.",
"listings.title": "Listings",
"listings.watchlistTitle": "Watchlist",
"listings.searchPlaceholder": "Search",
"listings.filterAll": "All",
"listings.filterActive": "Active",
"listings.filterInactive": "Inactive",
"listings.filterWatched": "Watched",
"listings.filterUnwatched": "Unwatched",
"listings.filterStatusPlaceholder": "Status",
"listings.filterStatusApplied": "Applied",
"listings.filterStatusRejected": "Rejected",
"listings.filterStatusAccepted": "Accepted",
"listings.filterStatusNone": "No status",
"listings.filterProviderPlaceholder": "Provider",
"listings.filterJobPlaceholder": "Job",
"listings.sortByJobName": "Job Name",
"listings.sortByDate": "Listing Date",
"listings.sortByPrice": "Price",
"listings.sortByProvider": "Provider",
"listings.sortPrefix": "Sort by",
"listings.sortAscending": "Ascending",
"listings.sortDescending": "Descending",
"listings.tooltipGridView": "Grid view",
"listings.tooltipTableView": "Table view",
"listings.empty": "No listings available yet...",
"listings.toastAddedToWatchlist": "Listing added to Watchlist",
"listings.toastRemovedFromWatchlist": "Listing removed from Watchlist",
"listings.toastWatchlistError": "Failed to operate Watchlist",
"listings.toastStatusCleared": "Status cleared",
"listings.toastStatusMarked": "Marked as {{status}}",
"listings.toastStatusUpdateError": "Failed to update status",
"listings.toastDeleted": "Listing successfully removed",
"listings.toastDeleteError": "Error deleting listing",
"listings.cardInactive": "Inactive",
"listings.tooltipAddToWatchlist": "Add to Watchlist",
"listings.tooltipRemoveFromWatchlist": "Remove from Watchlist",
"listings.tooltipOriginalListing": "Original Listing",
"listings.tooltipViewInFredy": "View in Fredy",
"listings.tooltipRemove": "Remove",
"listing.detail.back": "Back",
"listing.detail.defaultTitle": "Listing Detail",
"listing.detail.noAddress": "No address provided",
"listing.detail.watch": "Watch",
"listing.detail.watched": "Watched",
"listing.detail.openListing": "Open listing",
"listing.detail.delete": "Delete",
"listing.detail.noImageAlt": "No image available",
"listing.detail.notesTitle": "Notes",
"listing.detail.notesPlaceholder": "Your private notes about this listing…",
"listing.detail.storeNotes": "Store notes",
"listing.detail.detailsTitle": "Details",
"listing.detail.descriptionTitle": "Description",
"listing.detail.noDescription": "No description available.",
"listing.detail.distanceToHome": "Distance to home:",
"listing.detail.locationTitle": "Location",
"listing.detail.noGeoWarning": "This listing has no valid geocoordinates, so we cannot show it on the map.",
"listing.detail.fieldPrice": "Price",
"listing.detail.fieldPriceHelp": "The asking price of this listing, as reported by the provider.",
"listing.detail.fieldSize": "Size",
"listing.detail.fieldSizeHelp": "Living space of the listing in square meters.",
"listing.detail.fieldRooms": "Rooms",
"listing.detail.fieldRoomsHelp": "Number of rooms in the listing.",
"listing.detail.fieldJob": "Job",
"listing.detail.fieldJobHelp": "The Fredy job that found this listing.",
"listing.detail.fieldProvider": "Provider",
"listing.detail.fieldProviderHelp": "The real estate portal where this listing was scraped from.",
"listing.detail.fieldAdded": "Added",
"listing.detail.fieldAddedHelp": "When Fredy first added this listing to your database.",
"listing.detail.fieldStatus": "Status",
"listing.detail.statusApplied": "Applied",
"listing.detail.statusAccepted": "Accepted",
"listing.detail.statusRejected": "Rejected",
"listing.detail.statusSetAt": "(set {{date}})",
"listing.detail.fieldStatusHelp": "The status you marked for this listing and when you set it.",
"listing.detail.fieldRoomsValue": "{{count}} Rooms",
"listing.detail.mapPopupListingLocation": "Listing Location",
"listing.detail.mapPopupHomeAddress": "Home Address",
"listing.detail.toastDeleted": "Listing successfully removed",
"listing.detail.toastDeleteError": "Error deleting listing",
"listing.detail.toastWatchlistAdded": "Added to Watchlist",
"listing.detail.toastWatchlistRemoved": "Removed from Watchlist",
"listing.detail.toastWatchlistError": "Failed to operate Watchlist",
"listing.detail.toastNotesSaved": "Notes saved",
"listing.detail.toastNotesError": "Failed to save notes",
"listing.detail.toastLoadError": "Failed to load listing details",
"listing.deletion.title": "Delete Listings",
"listing.deletion.message": "How would you like to delete the selected listing(s)?",
"listing.deletion.confirm": "Confirm",
"listing.deletion.cancel": "Cancel",
"listing.deletion.softLabel": "Mark as deleted (Soft Delete)",
"listing.deletion.softDescription": "Listings are kept in the database but marked as hidden. They will not re-appear during the next scraping session.",
"listing.deletion.hardLabel": "Remove from database (Hard Delete)",
"listing.deletion.hardDescription": "Listings are completely removed from the database.",
"listing.deletion.hardConsequence": "Consequence: They might re-appear when scraping the next time because Fredy won't know they were previously found.",
"listing.deletion.rememberChoice": "Remember my choice and skip this dialog next time",
"listings.status.none": "None",
"listings.status.applied": "Applied",
"listings.status.rejected": "Rejected",
"listings.status.accepted": "Accepted",
"listings.status.statusLabel": "Status",
"listings.status.tooltip": "Track where you stand with this listing: Applied once you have reached out, Rejected if it did not work out, or Accepted if you got it.",
"map.title": "Map View",
"map.noHomeAddress": "No home address set. Configure it in user settings to use the distance filter.",
"map.onlyValidAddresses": "Only listings with valid addresses are shown on this map.",
"map.filterJobLabel": "Job",
"map.filterJobPlaceholder": "All jobs",
"map.filterDistanceLabel": "Distance",
"map.filterDistanceNone": "None",
"map.filterPriceLabel": "Price (€)",
"map.filterStyleLabel": "Style",
"map.filterStyleStandard": "Standard",
"map.filterStyleSatellite": "Satellite",
"map.filter3dBuildings": "3D Buildings",
"map.popupPrice": "Price:",
"map.popupAddress": "Address:",
"map.popupJob": "Job:",
"map.popupProvider": "Provider:",
"map.popupSize": "Size:",
"map.popupViewDetails": "View Details",
"map.popupRemove": "Remove",
"map.popupHomeAddress": "Home Address",
"map.noHomeAddressBefore": "No home address set. Configure it in ",
"map.noHomeAddressLink": "user settings",
"map.noHomeAddressAfter": " to use the distance filter.",
"map.toastDeleted": "Listing successfully removed",
"map.toastDeleteError": "Error deleting listing",
"users.title": "Users",
"users.newUser": "New User",
"users.tableColumnUser": "User",
"users.tableColumnLastLogin": "Last login",
"users.tableColumnJobs": "Jobs",
"users.tableColumnMcpToken": "MCP Token",
"users.tableAdminBadge": "ADMIN",
"users.emptyState": "No users found.",
"users.toastRemoved": "User successfully removed",
"users.removalModal.title": "Removing user",
"users.removalModal.message": "Removing this user will also remove all associated jobs.",
"users.mutation.editTitle": "Edit User",
"users.mutation.newTitle": "New User",
"users.mutation.back": "Back",
"users.mutation.save": "Save",
"users.mutation.cancel": "Cancel",
"users.mutation.saved": "User successfully saved...",
"users.mutation.sectionUsername": "Username",
"users.mutation.usernameHelp": "The username used to login to Fredy",
"users.mutation.usernamePlaceholder": "Username",
"users.mutation.sectionPassword": "Password",
"users.mutation.passwordHelp": "The password used to login to Fredy",
"users.mutation.passwordPlaceholder": "Password",
"users.mutation.sectionRetypePassword": "Retype password",
"users.mutation.retypePasswordHelp": "Retype the password to make sure they match",
"users.mutation.retypePasswordPlaceholder": "Retype password",
"users.mutation.sectionIsAdmin": "Is user an admin?",
"users.mutation.isAdminHelp": "Check this if the user is an administrator",
"settings.title": "Settings",
"settings.tabSystem": "System",
"settings.tabExecution": "Execution",
"settings.tabUserSettings": "User Settings",
"settings.tabBackup": "Backup & Restore",
"settings.save": "Save",
"settings.port": "Port",
"settings.portHelp": "The port on which Fredy is running.",
"settings.portPlaceholder": "Port",
"settings.baseUrl": "Base URL",
"settings.baseUrlHelp": "Public URL where Fredy is reachable (e.g. http://192.168.1.10:9998). Used for 'Open in Fredy' links in notifications.",
"settings.baseUrlPlaceholder": "Base-Url",
"settings.sqlitePath": "SQLite Database Path",
"settings.sqlitePathHelp": "The directory where Fredy stores its SQLite database files.",
"settings.sqlitePathWarning": "Changing this path may result in data loss. Restart Fredy immediately after saving.",
"settings.sqlitePathPlaceholder": "Database folder path",
"settings.analytics": "Analytics",
"settings.analyticsHelp": "Anonymous usage data to help improve Fredy - provider names, adapter names, OS, Node version, and architecture.",
"settings.analyticsEnable": "Enable analytics",
"settings.demoMode": "Demo Mode",
"settings.demoModeHelp": "In demo mode, Fredy will not search for real estates and all data resets to defaults at midnight.",
"settings.demoModeEnable": "Enable demo mode",
"settings.searchInterval": "Search Interval",
"settings.searchIntervalHelp": "Interval in minutes for running queries against configured services. Do not go below 5 minutes to avoid being detected as a bot.",
"settings.searchIntervalPlaceholder": "Interval in minutes",
"settings.searchIntervalSuffix": "minutes",
"settings.workingHours": "Working Hours",
"settings.workingHoursHelp": "Fredy will only search for listings during these hours. Leave empty to search around the clock.",
"settings.workingHoursFrom": "From",
"settings.workingHoursUntil": "Until",
"settings.proxyUrl": "Proxy URL",
"settings.proxyUrlHelp": "Optional. Routes the scraping browser through a proxy. Server/datacenter IPs are frequently blocked by providers (e.g. immowelt) regardless of browser fingerprint, a German residential proxy makes requests look like a normal household and is the most effective fix. Format: http://user:pass@host:port or socks5://user:pass@host:port. Leave empty to disable.",
"settings.proxyUrlPlaceholder": "http://user:pass@host:port",
"settings.homeAddress": "Home Address",
"settings.homeAddressHelp": "Used to calculate distances between your location and each listing. Updating this recalculates distances for all active listings.",
"settings.homeAddressPlaceholder": "Enter your home address",
"settings.homeAddressGeoError": "Address found but could not be geocoded accurately.",
"settings.providerDetails": "Provider Details",
"settings.providerDetailsHelp": "Fetch additional details (description, attributes, agent info) for listings. Needs an extra API call per listing.",
"settings.providerDetailsWarning": "Enabling this significantly increases API requests to providers that have implemented this feature, raising the chance of rate limiting or blocking. Use at your own risk.",
"settings.providerDetailsPlaceholder": "Select providers to fetch details from...",
"settings.providerDetailsUpdated": "Provider details setting updated.",
"settings.providerDetailsUpdateError": "Failed to update setting.",
"settings.listingDeletion": "Listing deletion",
"settings.listingDeletionHelp": "Choose the default deletion mode. Soft delete hides them without re-scraping; hard delete removes them from the database.",
"settings.listingDeletionSoftLabel": "Mark as deleted (Soft Delete)",
"settings.listingDeletionSoftDesc": "Listings are kept in the database but marked as hidden. They will not re-appear during the next scraping session.",
"settings.listingDeletionHardLabel": "Remove from database (Hard Delete)",
"settings.listingDeletionHardDesc": "Listings are completely removed from the database.",
"settings.listingDeletionHardConsequence": "Consequence: They might re-appear when scraping the next time because Fredy won't know they were previously found.",
"settings.listingDeletionSkipPrompt": "Skip confirmation dialog",
"settings.userSettingsSaved": "Settings saved. Distance calculations are running in the background.",
"settings.userSettingsSaveError": "Error while saving settings",
"settings.backupDownload": "Download Backup",
"settings.backupRestoreFromZip": "Restore from Zip",
"settings.backupHelp": "Download a zipped backup of your database or restore from a backup zip.",
"settings.backupSectionName": "Backup & Restore",
"settings.backupDemoWarning": "Backup and restore are not available in demo mode.",
"settings.backupDownloadError": "Unexpected error while downloading backup.",
"settings.backupAnalyzeError": "Failed to analyze backup.",
"settings.backupRestoreCompleted": "Restore completed. Please restart the Fredy backend now!",
"settings.backupRestoreError": "Unexpected error while restoring backup.",
"settings.restoreModalTitle": "Restore database",
"settings.restoreNow": "Restore now",
"settings.restoreAnyway": "Restore anyway",
"settings.restoreProblemDetected": "Problem detected",
"settings.restoreMigrationsApplied": "Automatic migrations will be applied",
"settings.restoreCompatible": "Backup is compatible",
"settings.restoreMigrationInfo": "Backup migration: {{backupMigration}} | Required migration: {{requiredMigration}}",
"settings.toastIntervalEmpty": "Interval may not be empty.",
"settings.toastPortEmpty": "Port may not be empty.",
"settings.toastWorkingHoursIncomplete": "Working hours to and from must be set if either to or from has been set before.",
"settings.toastSqlitePathEmpty": "SQLite db path cannot be empty.",
"settings.toastSavedReloading": "Settings stored successfully. We will reload your browser in 3 seconds.",
"settings.toastSaveError": "Error while trying to store settings.",
"watchlist.sectionName": "Notification for Watch List",
"watchlist.sectionHelp": "You can get notified for changes on listings from your watch list.",
"watchlist.noteTitle": "Note",
"watchlist.noteDescription": "You'll receive notifications only for listings that are on your watch list. To add listings to it, open the 'Listings' section and tag the ones you want to follow.",
"watchlist.notifyMeWhen": "Notify me when:",
"watchlist.activityChanges": "Listing state changes (e.g. listing becomes inactive)",
"watchlist.priceChanges": "Listing price changes",
"watchlist.notifyMeWith": "Notify me with:",
"watchlist.selectNotificationMethod": "Select notification method",
"watchlist.addNotificationTitle": "Add notification method",
"watchlist.addNotificationDescription": "When something has changed, Fredy will notify you using the selected notification adapter. Note, some adapter like SqLite are not available here.",
"notification.defaultTitle": "Adding a new Notification Adapter",
"notification.description": "When Fredy finds new listings, we like to report them to you. To do so, notification adapter can be configured. There are multiple ways how Fredy can send new listings to you. Chose your weapon...",
"notification.selectPlaceholder": "Select a notification adapter",
"notification.try": "Try",
"notification.cancel": "Cancel",
"notification.save": "Save",
"notification.trySuccess": "It seems like it worked! Please check your service.",
"notification.tryError": "This did not work :-( I've received the following error: {{error}}",
"notification.errorTitle": "Error",
"notification.successTitle": "Yay!",
"notification.validationAllMandatory": "All fields are mandatory and must be set.",
"notification.validationNumberField": "A number field cannot contain anything else and must be > 0.",
"notification.validationBooleanField": "A boolean field cannot be of a different type.",
"notification.infoTitle": "Information",
"notification.tableEmptyState": "No notification adapters found.",
"notification.tableColumnName": "Name",
"provider.defaultTitle": "Adding a new Provider",
"provider.editTitle": "Editing an existing Provider",
"provider.save": "Save",
"provider.description": "Provider are the heart of Fredy. We're supporting multiple Provider such as Immowelt, Immoscout etc. Select a provider from the list below. Fredy will then open the provider's url in a new tab.",
"provider.descriptionStep2": "You will need to configure your search parameter like you would do when you do a regular search on the provider's website. When the search results are shown on the website, copy the url and paste it into the textfield below.",
"provider.editDescription": "You can now edit the {{name}} provider's URL in the input field below.",
"provider.selectPlaceholder": "Select a provider",
"provider.urlPlaceholder": "Provider Url",
"provider.validationSelectAndUrl": "Please select a provider and copy the browser url into the textfield after configuring your search parameter.",
"provider.validationInvalidUrl": "The url you have copied is not valid.",
"provider.errorTitle": "Error",
"provider.tableEmptyState": "No providers found.",
"provider.tableColumnName": "Name",
"provider.tableColumnUrl": "URL",
"provider.tableOpenProvider": "Open Provider",
"news.videoFallback": "Your browser does not support the video tag.",
"version.newVersionAvailable": "New version available",
"version.currentLabel": "Current: {{version}}",
"version.releaseNotes": "Release notes",
"version.newBadge": "New",
"version.modalClose": "Close",
"version.viewOnGithub": "View on GitHub",
"version.yourVersion": "Your Version",
"version.latestVersion": "Latest Version",
"tracking.okText": "Yes! I want to help",
"tracking.cancelText": "No, thanks",
"tracking.greeting": "Hey 👋",
"tracking.paragraph1": "Fed up with popups? Yeah, me too. But this one's important, and I promise it will only appear once ;)",
"tracking.paragraph2": "Fredy is completely free (and will always remain free). If you'd like, you can support me by donating through my GitHub, but there's absolutely no obligation to do so.",
"tracking.paragraph3": "However, it would be a huge help if you'd allow me to collect some analytical data. Wait, before you click \"no\", let me explain. If you agree, Fredy will send a ping once every 6 hours to my internal tracking project. (Will be open-sourced soon)",
"tracking.paragraph4": "The data includes: names of active adapters/providers, OS, architecture, Node version, and language. The information is entirely anonymous and helps me understand which adapters/providers are most frequently used.",
"tracking.thanks": "Thanks🤘",
"permission.title": "Insufficient permission :(",
"footer.madeWith": "Made with ❤️ by",
"dashboard.noData": "No Data",
"common.save": "Save",
"common.cancel": "Cancel",
"common.delete": "Delete",
"common.edit": "Edit",
"common.back": "Back",
"common.confirm": "Confirm",
"common.yes": "Yes",
"common.no": "No",
"common.na": "N/A",
"common.loading": "Loading...",
"common.ariaGridView": "Grid view",
"common.ariaTableView": "Table view",
"common.startNow": "Start now",
"settings.language": "Language",
"settings.languageHelp": "The language used throughout the interface.",
"settings.languageSaveError": "Failed to save language preference."
}

View File

@@ -0,0 +1,136 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { createContext, useContext, useMemo } from 'react';
// Auto-discover all locale JSON files at build time
const localeModules = import.meta.glob('../../locales/*.json', { eager: true });
/**
* Build resources object: { en: {...translations}, de: {...translations}, ... }
* Strips _meta from each locale file.
* @type {Record<string, Record<string, string>>}
*/
const resources = {};
/**
* Build availableLanguages array: [{ code, flag, name, locale }, ...]
* Uses _meta from each locale file with fallbacks.
* @type {Array<{code: string, flag: string, name: string, locale: string}>}
*/
const availableLanguages = [];
/** Maps language code to BCP 47 locale string (e.g. 'de' → 'de-DE') */
const localeMap = {};
for (const [path, module] of Object.entries(localeModules)) {
// Extract locale code from path: '../../locales/en.json' -> 'en'
const match = path.match(/\/(\w+)\.json$/);
if (!match) continue;
const code = match[1];
const localeData = module.default || module;
// Extract _meta and build resources
const { _meta, ...translations } = localeData;
resources[code] = translations;
// Build availableLanguages entry
const flag = _meta?.flag || '';
const name = _meta?.name || code;
const locale = _meta?.locale || code;
const semiLocale = _meta?.semiLocale || null;
localeMap[code] = locale;
availableLanguages.push({ code, flag, name, locale, semiLocale });
}
if (availableLanguages.length === 0) {
console.warn('i18n: No locale files found in locales/');
}
if (!resources.en) {
console.error('i18n: English locale (en.json) is required as the fallback language');
}
/**
* Translation context
* @type {React.Context<{t: (key: string, vars?: Record<string, string>) => string, locale: string}>}
*/
const TranslationContext = createContext(null);
/**
* I18nProvider component
* Accepts a language prop and provides a t() function via context.
* Falls back to English, then to key itself if translation missing.
* Supports {{varName}} interpolation.
*
* @param {Object} props
* @param {string} props.language - Active language code (e.g., 'en', 'de')
* @param {React.ReactNode} props.children - Child components
* @returns {React.ReactNode}
*/
export function I18nProvider({ language = 'en', children }) {
/**
* Translate a key with optional variable interpolation
* @param {string} key - Translation key (e.g., 'nav.dashboard')
* @param {Record<string, string>} [vars] - Variables for {{varName}} interpolation
* @returns {string}
*/
const t = (key, vars = {}) => {
// Try active language
let translation = resources[language]?.[key];
// Fallback to English
if (!translation) {
translation = resources.en?.[key];
}
// Fallback to key itself
if (!translation) {
translation = key;
}
// Interpolate variables: replace {{varName}} with values
if (vars && Object.keys(vars).length > 0) {
translation = translation.replace(/\{\{(\w+)\}\}/g, (match, varName) => {
return vars[varName] !== undefined ? String(vars[varName]) : match;
});
}
return translation;
};
const locale = localeMap[language] ?? localeMap.en ?? 'en-US';
const value = useMemo(() => ({ t, locale }), [language]);
return <TranslationContext.Provider value={value}>{children}</TranslationContext.Provider>;
}
/**
* Hook to access the translation function from context.
* @returns {(key: string, vars?: Record<string, string>) => string}
*/
export function useTranslation() {
const context = useContext(TranslationContext);
if (!context) {
throw new Error('useTranslation must be used within an I18nProvider');
}
return context.t;
}
/**
* Hook to access the active BCP 47 locale string (e.g. 'de-DE', 'en-US').
* Use this with Intl APIs for locale-aware date/number formatting.
* @returns {string}
*/
export function useLocale() {
const context = useContext(TranslationContext);
if (!context) {
throw new Error('useLocale must be used within an I18nProvider');
}
return context.locale;
}
// Export resources and availableLanguages for other uses
export { resources, availableLanguages };

View File

@@ -0,0 +1,22 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
const euroPriceFormatter = new Intl.NumberFormat('de-DE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
/**
* @param {number|string} price
* @returns {string}
*/
export const formatEuroPrice = (price) => {
const parsedPrice = Number(price);
if (!Number.isFinite(parsedPrice)) {
return `${price}`;
}
return `${euroPriceFormatter.format(parsedPrice)}`;
};

View File

@@ -260,6 +260,22 @@ export const useFredyState = create(
console.error('Error while trying to get resource for api/listings/map. Error:', Exception);
}
},
async setListingStatus(listingId, status) {
try {
await xhrPost(`/api/listings/${listingId}/status`, { status });
} catch (Exception) {
console.error(`Error while trying to set status for listing ${listingId}. Error:`, Exception);
throw Exception;
}
},
async setListingNotes(listingId, notes) {
try {
await xhrPost(`/api/listings/${listingId}/notes`, { notes });
} catch (Exception) {
console.error(`Error while trying to set notes for listing ${listingId}. Error:`, Exception);
throw Exception;
}
},
},
userSettings: {
async getUserSettings() {
@@ -349,6 +365,34 @@ export const useFredyState = create(
throw Exception;
}
},
async setListingDeletionPreference(listing_deletion_preference) {
try {
await xhrPost('/api/user/settings/listing-deletion-preference', { listing_deletion_preference });
set((state) => ({
userSettings: {
...state.userSettings,
settings: { ...state.userSettings.settings, listing_deletion_preference },
},
}));
} catch (Exception) {
console.error('Error while trying to update listing deletion preference. Error:', Exception);
throw Exception;
}
},
async setLanguage(language) {
try {
await xhrPost('/api/user/settings/language', { language });
set((state) => ({
userSettings: {
...state.userSettings,
settings: { ...state.userSettings.settings, language },
},
}));
} catch (Exception) {
console.error('Error while trying to update language setting. Error:', Exception);
throw Exception;
}
},
},
};

View File

@@ -3,8 +3,8 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
export function format(ts, showSeconds = true) {
return new Intl.DateTimeFormat('default', {
export function format(ts, showSeconds = true, locale = 'default') {
return new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: 'numeric',
day: 'numeric',

View File

@@ -25,8 +25,11 @@ import Headline from '../../components/headline/Headline.jsx';
import './Dashboard.less';
import { xhrPost } from '../../services/xhr.js';
import { format } from '../../services/time/timeService.js';
import { useTranslation, useLocale } from '../../services/i18n/i18n.jsx';
export default function Dashboard() {
const t = useTranslation();
const locale = useLocale();
const actions = useActions();
const dashboard = useSelector((state) => state.dashboard.data);
React.useEffect(() => {
@@ -38,111 +41,111 @@ export default function Dashboard() {
return (
<div className="dashboard">
<Headline text="Dashboard" />
<Headline text={t('dashboard.title')} />
<div className="dashboard__section-label">General</div>
<div className="dashboard__section-label">{t('dashboard.sectionGeneral')}</div>
<Row gutter={[16, 16]} className="dashboard__row">
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
<KpiCard
title="Search Interval"
title={t('dashboard.searchInterval')}
value={`${dashboard?.general?.interval} min`}
icon={<IconClock />}
description="Time interval for job execution"
description={t('dashboard.searchIntervalDesc')}
/>
</Col>
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
<KpiCard
title="Last Search"
title={t('dashboard.lastSearch')}
value={
dashboard?.general?.lastRun == null || dashboard?.general?.lastRun === 0
? '---'
: format(dashboard?.general?.lastRun)
: format(dashboard?.general?.lastRun, true, locale)
}
icon={<IconDoubleChevronLeft />}
description="Last execution timestamp"
description={t('dashboard.lastSearchDesc')}
/>
</Col>
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
<KpiCard
title="Next Search"
title={t('dashboard.nextSearch')}
value={
dashboard?.general?.nextRun == null || dashboard?.general?.nextRun === 0
? '---'
: format(dashboard?.general?.nextRun)
: format(dashboard?.general?.nextRun, true, locale)
}
icon={<IconDoubleChevronRight />}
description="Next execution timestamp"
description={t('dashboard.nextSearchDesc')}
/>
</Col>
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
<KpiCard title="Search Now" icon={<IconSearch />} description="Run a search now">
<KpiCard title={t('dashboard.searchNow')} icon={<IconSearch />} description={t('dashboard.searchNowDesc')}>
<Button
size="small"
style={{ marginTop: '.2rem' }}
icon={<IconPlayCircle />}
aria-label="Start now"
aria-label={t('common.startNow')}
onClick={async () => {
try {
await xhrPost('/api/jobs/startAll', null);
Toast.success('Successfully triggered Fredy search.');
Toast.success(t('dashboard.searchNowStarted'));
} catch {
Toast.error('Failed to trigger search');
Toast.error(t('dashboard.searchNowFailed'));
}
}}
>
Search now
{t('dashboard.searchNowButton')}
</Button>
</KpiCard>
</Col>
</Row>
<div className="dashboard__section-label">Overview</div>
<div className="dashboard__section-label">{t('dashboard.sectionOverview')}</div>
<Row gutter={[16, 16]} className="dashboard__row">
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
<KpiCard
title="Jobs"
title={t('dashboard.kpiJobs')}
color="blue"
value={!kpis.totalJobs ? '---' : kpis.totalJobs}
icon={<IconTerminal />}
description="Total number of jobs"
description={t('dashboard.kpiJobsDesc')}
/>
</Col>
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
<KpiCard
title="Listings"
title={t('dashboard.kpiListings')}
color="orange"
value={!kpis.totalListings ? '---' : kpis.totalListings}
icon={<IconStarStroked />}
description="Total listings found"
description={t('dashboard.kpiListingsDesc')}
/>
</Col>
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
<KpiCard
title="Active Listings"
title={t('dashboard.kpiActiveListings')}
color="green"
value={!kpis.numberOfActiveListings ? '---' : kpis.numberOfActiveListings}
icon={<IconStar />}
description="Total active listings"
description={t('dashboard.kpiActiveListingsDesc')}
/>
</Col>
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
<KpiCard
title="Median Price"
title={t('dashboard.kpiMedianPrice')}
color="purple"
value={`${
!kpis.medianPriceOfListings
? '---'
: new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(
: new Intl.NumberFormat(locale, { style: 'currency', currency: 'EUR' }).format(
kpis.medianPriceOfListings,
)
}`}
icon={<IconNoteMoney />}
description="Median Price of listings"
description={t('dashboard.kpiMedianPriceDesc')}
/>
</Col>
</Row>
<div className="dashboard__section-label">Provider Insights</div>
<div className="dashboard__section-label">{t('dashboard.sectionProviderInsights')}</div>
<div className="dashboard__pie-wrapper">
<PieChartCard data={pieData} />
</div>

View File

@@ -6,6 +6,7 @@
import React, { useEffect, useState, useMemo } from 'react';
import { useActions, useSelector, useIsLoading } from '../../services/state/store';
import { useTranslation, availableLanguages } from '../../services/i18n/i18n.jsx';
import {
Tabs,
@@ -18,6 +19,9 @@ import {
AutoComplete,
Select,
Banner,
Radio,
RadioGroup,
Typography,
} from '@douyinfe/semi-ui-19';
import { InputNumber } from '@douyinfe/semi-ui-19';
import { xhrPost, xhrGet } from '../../services/xhr';
@@ -33,6 +37,8 @@ import { debounce } from '../../utils';
import Headline from '../../components/headline/Headline.jsx';
import './GeneralSettings.less';
const { Text } = Typography;
function formatFromTimestamp(ts) {
const date = new Date(ts);
return `${date.getHours()}:${date.getMinutes() > 9 ? date.getMinutes() : '0' + date.getMinutes()}`;
@@ -51,11 +57,15 @@ function formatFromTBackend(time) {
const GeneralSettings = function GeneralSettings() {
const actions = useActions();
const t = useTranslation();
const [loading, setLoading] = React.useState(true);
const settings = useSelector((state) => state.generalSettings.settings);
const currentUser = useSelector((state) => state.user.currentUser);
const language = useSelector((state) => state.userSettings.settings.language);
const [interval, setInterval] = React.useState('');
const [proxyUrl, setProxyUrl] = React.useState('');
const [port, setPort] = React.useState('');
const [workingHourFrom, setWorkingHourFrom] = React.useState(null);
const [workingHourTo, setWorkingHourTo] = React.useState(null);
@@ -72,10 +82,14 @@ const GeneralSettings = function GeneralSettings() {
// User settings state
const homeAddress = useSelector((state) => state.userSettings.settings.home_address);
const providerDetails = useSelector((state) => state.userSettings.settings.provider_details);
const listingDeletionPreference = useSelector((state) => state.userSettings.settings.listing_deletion_preference);
const allProviders = useSelector((state) => state.provider);
const [address, setAddress] = useState(homeAddress?.address || '');
const [coords, setCoords] = useState(homeAddress?.coords || null);
const [listingDeleteHard, setListingDeleteHard] = useState(false);
const [listingDeleteSkipPrompt, setListingDeleteSkipPrompt] = useState(false);
const saving = useIsLoading(actions.userSettings.setHomeAddress);
const savingLanguage = useIsLoading(actions.userSettings.setLanguage);
const [dataSource, setDataSource] = useState([]);
React.useEffect(() => {
@@ -90,6 +104,7 @@ const GeneralSettings = function GeneralSettings() {
React.useEffect(() => {
async function init() {
setInterval(settings?.interval);
setProxyUrl(settings?.proxyUrl ?? '');
setPort(settings?.port);
setWorkingHourFrom(settings?.workingHours?.from);
setWorkingHourTo(settings?.workingHours?.to);
@@ -107,31 +122,37 @@ const GeneralSettings = function GeneralSettings() {
setCoords(homeAddress?.coords || null);
}, [homeAddress]);
useEffect(() => {
setListingDeleteHard(listingDeletionPreference?.hardDelete ?? false);
setListingDeleteSkipPrompt(listingDeletionPreference?.skipPrompt ?? false);
}, [listingDeletionPreference]);
const nullOrEmpty = (val) => val == null || val.length === 0;
const handleStore = async () => {
if (nullOrEmpty(interval)) {
Toast.error('Interval may not be empty.');
Toast.error(t('settings.toastIntervalEmpty'));
return;
}
if (nullOrEmpty(port)) {
Toast.error('Port may not be empty.');
Toast.error(t('settings.toastPortEmpty'));
return;
}
if (
(!nullOrEmpty(workingHourFrom) && nullOrEmpty(workingHourTo)) ||
(nullOrEmpty(workingHourFrom) && !nullOrEmpty(workingHourTo))
) {
Toast.error('Working hours to and from must be set if either to or from has been set before.');
Toast.error(t('settings.toastWorkingHoursIncomplete'));
return;
}
if (nullOrEmpty(sqlitePath)) {
Toast.error('SQLite db path cannot be empty.');
Toast.error(t('settings.toastSqlitePathEmpty'));
return;
}
try {
await xhrPost('/api/admin/generalSettings', {
interval,
proxyUrl: proxyUrl?.trim() ?? '',
port,
workingHours: {
from: workingHourFrom,
@@ -147,11 +168,11 @@ const GeneralSettings = function GeneralSettings() {
if (exception?.json?.message != null) {
Toast.error(exception.json.message);
} else {
Toast.error('Error while trying to store settings.');
Toast.error(t('settings.toastSaveError'));
}
return;
}
Toast.success('Settings stored successfully. We will reload your browser in 3 seconds.');
Toast.success(t('settings.toastSavedReloading'));
setTimeout(() => {
location.reload();
}, 3000);
@@ -162,7 +183,7 @@ const GeneralSettings = function GeneralSettings() {
await downloadBackupZip();
} catch (e) {
console.error(e);
Toast.error('Unexpected error while downloading backup.');
Toast.error(t('settings.backupDownloadError'));
}
}, []);
@@ -173,7 +194,7 @@ const GeneralSettings = function GeneralSettings() {
setRestoreModalVisible(true);
} catch (e) {
console.error(e);
Toast.error('Failed to analyze backup.');
Toast.error(t('settings.backupAnalyzeError'));
}
}, []);
@@ -182,10 +203,10 @@ const GeneralSettings = function GeneralSettings() {
try {
setRestoreBusy(true);
await clientRestore(selectedRestoreFile, force);
Toast.success('Restore completed. Please restart the Fredy backend now!');
Toast.success(t('settings.backupRestoreCompleted'));
} catch (e) {
console.error(e);
Toast.error(e?.message || 'Unexpected error while restoring backup.');
Toast.error(e?.message || t('settings.backupRestoreError'));
} finally {
setRestoreBusy(false);
}
@@ -214,10 +235,14 @@ const GeneralSettings = function GeneralSettings() {
try {
const responseJson = await actions.userSettings.setHomeAddress(address);
setCoords(responseJson.coords);
await actions.userSettings.setListingDeletionPreference({
skipPrompt: listingDeleteSkipPrompt,
hardDelete: listingDeleteHard,
});
await actions.userSettings.getUserSettings();
Toast.success('Settings saved. Distance calculations are running in the background.');
Toast.success(t('settings.userSettingsSaved'));
} catch (error) {
Toast.error(error.json?.error || 'Error while saving settings');
Toast.error(error.json?.error || t('settings.userSettingsSaveError'));
}
};
@@ -245,7 +270,7 @@ const GeneralSettings = function GeneralSettings() {
return (
<div className="generalSettings">
<Headline text="Settings" />
<Headline text={t('settings.title')} />
{!loading && (
<>
<Tabs type="line">
@@ -253,17 +278,17 @@ const GeneralSettings = function GeneralSettings() {
tab={
<span>
<IconSignal size="small" style={{ marginRight: 6 }} />
System
{t('settings.tabSystem')}
</span>
}
itemKey="system"
>
<div className="generalSettings__tab-content">
<SegmentPart name="Port" helpText="The port on which Fredy is running.">
<SegmentPart name={t('settings.port')} helpText={t('settings.portHelp')}>
<InputNumber
min={0}
max={99999}
placeholder="Port"
placeholder={t('settings.portPlaceholder')}
value={port}
formatter={(value) => `${value}`.replace(/\D/g, '')}
onChange={(value) => setPort(value)}
@@ -271,53 +296,46 @@ const GeneralSettings = function GeneralSettings() {
/>
</SegmentPart>
<SegmentPart
name="Base URL"
helpText="Public URL where Fredy is reachable (e.g. http://192.168.1.10:9998). Used for 'Open in Fredy' links in notifications."
>
<Input type="text" placeholder="Base-Url" value={baseUrl} onChange={(value) => setBaseUrl(value)} />
<SegmentPart name={t('settings.baseUrl')} helpText={t('settings.baseUrlHelp')}>
<Input
type="text"
placeholder={t('settings.baseUrlPlaceholder')}
value={baseUrl}
onChange={(value) => setBaseUrl(value)}
/>
</SegmentPart>
<SegmentPart
name="SQLite Database Path"
helpText="The directory where Fredy stores its SQLite database files."
>
<SegmentPart name={t('settings.sqlitePath')} helpText={t('settings.sqlitePathHelp')}>
<Banner
fullMode={false}
type="warning"
closeIcon={null}
style={{ marginBottom: '12px' }}
description="Changing this path may result in data loss. Restart Fredy immediately after saving."
description={t('settings.sqlitePathWarning')}
/>
<Input
type="text"
placeholder="Database folder path"
placeholder={t('settings.sqlitePathPlaceholder')}
value={sqlitePath}
onChange={(value) => setSqlitePath(value)}
/>
</SegmentPart>
<SegmentPart
name="Analytics"
helpText="Anonymous usage data to help improve Fredy - provider names, adapter names, OS, Node version, and architecture."
>
<SegmentPart name={t('settings.analytics')} helpText={t('settings.analyticsHelp')}>
<Checkbox checked={analyticsEnabled} onChange={(e) => setAnalyticsEnabled(e.target.checked)}>
Enable analytics
{t('settings.analyticsEnable')}
</Checkbox>
</SegmentPart>
<SegmentPart
name="Demo Mode"
helpText="In demo mode, Fredy will not search for real estates and all data resets to defaults at midnight."
>
<SegmentPart name={t('settings.demoMode')} helpText={t('settings.demoModeHelp')}>
<Checkbox checked={demoMode} onChange={(e) => setDemoMode(e.target.checked)}>
Enable demo mode
{t('settings.demoModeEnable')}
</Checkbox>
</SegmentPart>
<div className="generalSettings__save-row">
<Button type="primary" theme="solid" onClick={handleStore} icon={<IconSave />}>
Save
{t('settings.save')}
</Button>
</div>
</div>
@@ -327,36 +345,30 @@ const GeneralSettings = function GeneralSettings() {
tab={
<span>
<IconRefresh size="small" style={{ marginRight: 6 }} />
Execution
{t('settings.tabExecution')}
</span>
}
itemKey="execution"
>
<div className="generalSettings__tab-content">
<SegmentPart
name="Search Interval"
helpText="Interval in minutes for running queries against configured services. Do not go below 5 minutes to avoid being detected as a bot."
>
<SegmentPart name={t('settings.searchInterval')} helpText={t('settings.searchIntervalHelp')}>
<InputNumber
min={5}
max={1440}
placeholder="Interval in minutes"
placeholder={t('settings.searchIntervalPlaceholder')}
value={interval}
formatter={(value) => `${value}`.replace(/\D/g, '')}
onChange={(value) => setInterval(value)}
suffix={'minutes'}
suffix={t('settings.searchIntervalSuffix')}
style={{ maxWidth: 200 }}
/>
</SegmentPart>
<SegmentPart
name="Working Hours"
helpText="Fredy will only search for listings during these hours. Leave empty to search around the clock."
>
<SegmentPart name={t('settings.workingHours')} helpText={t('settings.workingHoursHelp')}>
<div className="generalSettings__timePickerContainer">
<TimePicker
format={'HH:mm'}
insetLabel="From"
insetLabel={t('settings.workingHoursFrom')}
value={formatFromTBackend(workingHourFrom)}
placeholder=""
onChange={(val) => {
@@ -365,7 +377,7 @@ const GeneralSettings = function GeneralSettings() {
/>
<TimePicker
format={'HH:mm'}
insetLabel="Until"
insetLabel={t('settings.workingHoursUntil')}
value={formatFromTBackend(workingHourTo)}
placeholder=""
onChange={(val) => {
@@ -375,9 +387,18 @@ const GeneralSettings = function GeneralSettings() {
</div>
</SegmentPart>
<SegmentPart name={t('settings.proxyUrl')} helpText={t('settings.proxyUrlHelp')}>
<Input
type="text"
placeholder={t('settings.proxyUrlPlaceholder')}
value={proxyUrl}
onChange={(value) => setProxyUrl(value)}
/>
</SegmentPart>
<div className="generalSettings__save-row">
<Button type="primary" theme="solid" onClick={handleStore} icon={<IconSave />}>
Save
{t('settings.save')}
</Button>
</div>
</div>
@@ -387,42 +408,55 @@ const GeneralSettings = function GeneralSettings() {
tab={
<span>
<IconHome size="small" style={{ marginRight: 6 }} />
User Settings
{t('settings.tabUserSettings')}
</span>
}
itemKey="userSettings"
>
<div className="generalSettings__tab-content">
<SegmentPart
name="Home Address"
helpText="Used to calculate distances between your location and each listing. Updating this recalculates distances for all active listings."
>
<SegmentPart name={t('settings.language')} helpText={t('settings.languageHelp')}>
<Select
style={{ width: 240 }}
value={language ?? 'en'}
disabled={savingLanguage}
optionList={availableLanguages.map((lang) => ({
label: `${lang.flag} ${lang.name}`,
value: lang.code,
}))}
onChange={async (code) => {
try {
await actions.userSettings.setLanguage(code);
} catch {
Toast.error(t('settings.languageSaveError'));
}
}}
/>
</SegmentPart>
<SegmentPart name={t('settings.homeAddress')} helpText={t('settings.homeAddressHelp')}>
<AutoComplete
data={dataSource}
value={address}
showClear
onChange={(v) => setAddress(v)}
onSearch={searchAddress}
placeholder="Enter your home address"
placeholder={t('settings.homeAddressPlaceholder')}
style={{ width: '100%' }}
/>
{coords && coords.lat === -1 && (
<Banner
type="danger"
description="Address found but could not be geocoded accurately."
description={t('settings.homeAddressGeoError')}
closeIcon={null}
style={{ marginTop: 8 }}
/>
)}
</SegmentPart>
<SegmentPart
name="Provider Details"
helpText="Fetch additional details (description, attributes, agent info) for listings. Needs an extra API call per listing."
>
<SegmentPart name={t('settings.providerDetails')} helpText={t('settings.providerDetailsHelp')}>
<Banner
type="warning"
description="Enabling this significantly increases API requests to providers that have implemented this feature, raising the chance of rate limiting or blocking. Use at your own risk."
description={t('settings.providerDetailsWarning')}
closeIcon={null}
style={{ marginBottom: 12 }}
/>
@@ -431,18 +465,51 @@ const GeneralSettings = function GeneralSettings() {
style={{ width: '100%' }}
value={Array.isArray(providerDetails) ? providerDetails : []}
optionList={(allProviders ?? []).map((p) => ({ label: p.name, value: p.id }))}
placeholder="Select providers to fetch details from..."
placeholder={t('settings.providerDetailsPlaceholder')}
onChange={async (selected) => {
try {
await actions.userSettings.setProviderDetails(selected);
Toast.success('Provider details setting updated.');
Toast.success(t('settings.providerDetailsUpdated'));
} catch {
Toast.error('Failed to update setting.');
Toast.error(t('settings.providerDetailsUpdateError'));
}
}}
/>
</SegmentPart>
<SegmentPart name={t('settings.listingDeletion')} helpText={t('settings.listingDeletionHelp')}>
<RadioGroup
value={listingDeleteHard ? 'hard' : 'soft'}
onChange={(e) => setListingDeleteHard(e.target.value === 'hard')}
>
<Radio value="soft">
<div>
<Text strong>{t('settings.listingDeletionSoftLabel')}</Text>
<br />
<Text type="secondary">{t('settings.listingDeletionSoftDesc')}</Text>
</div>
</Radio>
<Radio value="hard">
<div>
<Text strong>{t('settings.listingDeletionHardLabel')}</Text>
<br />
<Text type="secondary">
{t('settings.listingDeletionHardDesc')}
<br />
<Text type="warning">{t('settings.listingDeletionHardConsequence')}</Text>
</Text>
</div>
</Radio>
</RadioGroup>
<Checkbox
checked={listingDeleteSkipPrompt}
onChange={(e) => setListingDeleteSkipPrompt(e.target.checked)}
style={{ marginTop: 12 }}
>
{t('settings.listingDeletionSkipPrompt')}
</Checkbox>
</SegmentPart>
<div className="generalSettings__save-row">
<Button
icon={<IconSave />}
@@ -451,7 +518,7 @@ const GeneralSettings = function GeneralSettings() {
onClick={handleSaveUserSettings}
loading={saving}
>
Save
{t('settings.save')}
</Button>
</div>
</div>
@@ -461,19 +528,30 @@ const GeneralSettings = function GeneralSettings() {
tab={
<span>
<IconFolder size="small" style={{ marginRight: 6 }} />
Backup & Restore
{t('settings.tabBackup')}
</span>
}
itemKey="backup"
>
<div className="generalSettings__tab-content">
<SegmentPart
name="Backup & Restore"
helpText="Download a zipped backup of your database or restore from a backup zip."
>
{demoMode && !currentUser?.isAdmin && (
<Banner
fullMode={false}
type="warning"
closeIcon={null}
style={{ marginBottom: '12px' }}
description={t('settings.backupDemoWarning')}
/>
)}
<SegmentPart name={t('settings.backupSectionName')} helpText={t('settings.backupHelp')}>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
<Button theme="solid" icon={<IconSave />} onClick={handleDownloadBackup}>
Download Backup
<Button
theme="solid"
icon={<IconSave />}
onClick={handleDownloadBackup}
disabled={demoMode && !currentUser?.isAdmin}
>
{t('settings.backupDownload')}
</Button>
<input
type="file"
@@ -482,8 +560,13 @@ const GeneralSettings = function GeneralSettings() {
style={{ display: 'none' }}
onChange={handleSelectRestoreFile}
/>
<Button onClick={handleOpenFilePicker} theme="light" icon={<IconFolder />}>
Restore from Zip
<Button
onClick={handleOpenFilePicker}
theme="light"
icon={<IconFolder />}
disabled={demoMode && !currentUser?.isAdmin}
>
{t('settings.backupRestoreFromZip')}
</Button>
</div>
</SegmentPart>
@@ -495,11 +578,11 @@ const GeneralSettings = function GeneralSettings() {
{restoreModalVisible && (
<Modal
title="Restore database"
title={t('settings.restoreModalTitle')}
visible={restoreModalVisible}
onCancel={() => setRestoreModalVisible(false)}
onOk={() => performRestore(!precheckInfo?.compatible)}
okText={precheckInfo?.compatible ? 'Restore now' : 'Restore anyway'}
okText={precheckInfo?.compatible ? t('settings.restoreNow') : t('settings.restoreAnyway')}
okType={precheckInfo?.compatible ? 'primary' : 'danger'}
confirmLoading={restoreBusy}
>
@@ -508,7 +591,7 @@ const GeneralSettings = function GeneralSettings() {
type="danger"
fullMode={false}
closeIcon={null}
title={<div style={{ fontWeight: 600, fontSize: '14px' }}>Problem detected</div>}
title={<div style={{ fontWeight: 600, fontSize: '14px' }}>{t('settings.restoreProblemDetected')}</div>}
description={<div>{precheckInfo?.message}</div>}
/>
)}
@@ -517,7 +600,7 @@ const GeneralSettings = function GeneralSettings() {
type="warning"
fullMode={false}
closeIcon={null}
title={<div style={{ fontWeight: 600, fontSize: '14px' }}>Automatic migrations will be applied</div>}
title={<div style={{ fontWeight: 600, fontSize: '14px' }}>{t('settings.restoreMigrationsApplied')}</div>}
description={<div>{precheckInfo?.message}</div>}
/>
)}
@@ -526,13 +609,15 @@ const GeneralSettings = function GeneralSettings() {
type="success"
fullMode={false}
closeIcon={null}
title={<div style={{ fontWeight: 600, fontSize: '14px' }}>Backup is compatible</div>}
title={<div style={{ fontWeight: 600, fontSize: '14px' }}>{t('settings.restoreCompatible')}</div>}
description={<div>{precheckInfo?.message}</div>}
/>
)}
<div style={{ marginTop: '0.5rem', fontSize: '12px', color: 'var(--semi-color-text-2)' }}>
Backup migration: {precheckInfo?.backupMigration ?? 'unknown'} | Required migration:{' '}
{precheckInfo?.requiredMigration ?? 'unknown'}
{t('settings.restoreMigrationInfo', {
backupMigration: precheckInfo?.backupMigration ?? 'unknown',
requiredMigration: precheckInfo?.requiredMigration ?? 'unknown',
})}
</div>
</Modal>
)}

View File

@@ -9,16 +9,18 @@ import { IconPlusCircle } from '@douyinfe/semi-icons';
import JobGrid from '../../components/grid/jobs/JobGrid.jsx';
import Headline from '../../components/headline/Headline.jsx';
import './Jobs.less';
import { useTranslation } from '../../services/i18n/i18n.jsx';
export default function Jobs() {
const t = useTranslation();
const navigate = useNavigate();
return (
<div className="jobs">
<Headline
text="Jobs"
text={t('jobs.title')}
actions={
<Button type="primary" theme="solid" icon={<IconPlusCircle />} onClick={() => navigate('/jobs/new')}>
New Job
{t('jobs.newJob')}
</Button>
}
/>

View File

@@ -27,14 +27,17 @@ import {
IconUser,
IconFilter,
} from '@douyinfe/semi-icons';
const SPEC_FILTERS = [
{ key: 'maxPrice', translation: 'Max Price' },
{ key: 'minSize', translation: 'Min Size (m²)' },
{ key: 'minRooms', translation: 'Min Rooms' },
];
import { useTranslation } from '../../../services/i18n/i18n.jsx';
export default function JobMutator() {
const t = useTranslation();
const SPEC_FILTERS = [
{ key: 'maxPrice', translation: t('jobs.mutation.filterMaxPrice') },
{ key: 'minSize', translation: t('jobs.mutation.filterMinSize') },
{ key: 'minRooms', translation: t('jobs.mutation.filterMinRooms') },
];
const jobs = useSelector((state) => state.jobsData.jobs);
const shareableUserList = useSelector((state) => state.jobsData.shareableUserList);
const params = useParams();
@@ -105,7 +108,7 @@ export default function JobMutator() {
jobId: jobToBeEdit?.id || null,
});
await actions.jobsData.getJobs();
Toast.success('Job successfully saved...');
Toast.success(t('jobs.mutation.saved'));
navigate('/jobs');
} catch (Exception) {
console.error(Exception.json.message);
@@ -146,7 +149,7 @@ export default function JobMutator() {
)}
<Headline
text={jobToBeEdit ? 'Edit Job' : 'Create new Job'}
text={jobToBeEdit ? t('jobs.mutation.editTitle') : t('jobs.mutation.createTitle')}
actions={
<Button
icon={<IconArrowLeft />}
@@ -154,17 +157,17 @@ export default function JobMutator() {
theme="borderless"
style={{ color: '#909090' }}
>
Back
{t('jobs.mutation.back')}
</Button>
}
/>
<form>
<SegmentPart name="Name" Icon={IconPaperclip}>
<SegmentPart name={t('jobs.mutation.sectionName')} Icon={IconPaperclip}>
<Input
autoFocus
type="text"
maxLength={40}
placeholder="Name"
placeholder={t('jobs.mutation.namePlaceholder')}
width={6}
value={name}
onChange={(value) => setName(value)}
@@ -172,13 +175,9 @@ export default function JobMutator() {
</SegmentPart>
<Divider margin="1rem" />
<SegmentPart
name="Providers"
name={t('jobs.mutation.sectionProviders')}
Icon={IconBriefcase}
helpText={`
A provider is essentially the service (e.g. ImmoScout24, Kleinanzeigen) that Fredy searches for new listings.
Fredy will open a new tab pointing to the website of this provider. You have to adjust your search parameter
and click on "Search". If the results are being shown, copy the browser URL in here.
`}
helpText={t('jobs.mutation.providersHelp')}
>
<Button
type="primary"
@@ -189,7 +188,7 @@ export default function JobMutator() {
setProviderCreationVisibility(true);
}}
>
Add new Provider
{t('jobs.mutation.addProvider')}
</Button>
<ProviderTable
@@ -206,8 +205,8 @@ export default function JobMutator() {
<Divider margin="1rem" />
<SegmentPart
Icon={IconBell}
name="Notification Adapters"
helpText="Fredy supports multiple ways to notify you about new findings. These are called notification adapter. You can chose between email, Telegram etc."
name={t('jobs.mutation.sectionNotifications')}
helpText={t('jobs.mutation.notificationsHelp')}
>
<Button
type="primary"
@@ -215,7 +214,7 @@ export default function JobMutator() {
icon={<IconPlusCircle />}
onClick={() => setNotificationCreationVisibility(true)}
>
Add new Notification Adapter
{t('jobs.mutation.addNotification')}
</Button>
<NotificationAdapterTable
@@ -233,20 +232,20 @@ export default function JobMutator() {
<Divider margin="1rem" />
<SegmentPart
Icon={IconFilter}
name="Blacklist"
helpText="If a listing contains one of these words, it will be filtered out. Type in a word, then hit enter."
name={t('jobs.mutation.sectionBlacklist')}
helpText={t('jobs.mutation.blacklistHelp')}
>
<TagInput
value={blacklist || []}
placeholder="Add a word for filtering..."
placeholder={t('jobs.mutation.blacklistPlaceholder')}
onChange={(v) => setBlacklist([...v])}
/>
</SegmentPart>
<Divider margin="1rem" />
<SegmentPart
Icon={IconFilter}
name="Criteria Filter"
helpText="Filter listings by specific criteria. Only numbers are allowed. You can leave fields empty if you don't want to filter by them."
name={t('jobs.mutation.sectionCriteriaFilter')}
helpText={t('jobs.mutation.criteriaFilterHelp')}
>
<div className="jobMutation__specFilter">
{SPEC_FILTERS.map((filter) => (
@@ -254,7 +253,7 @@ export default function JobMutator() {
<div className="jobMutation__specFilterLabel">{filter.translation}</div>
<Input
type="number"
placeholder="Add a number"
placeholder={t('jobs.mutation.criteriaNumberPlaceholder')}
value={specFilter?.[filter.key]}
onChange={(value) => handleSpecFilterChange(filter.key, value)}
/>
@@ -265,24 +264,20 @@ export default function JobMutator() {
<Divider margin="1rem" />
<SegmentPart
Icon={IconFilter}
name="Area Filter"
helpText="Define multiple geographic areas on the map to filter listings. Start drawing by clicking on the square symbol in the top left corner of the map. Click on the map to add points of the polygon. Select the first point to close the polygon. After that, click on a free area of the map to apply this polygon (the color will change from yellow to blue). To delete a polygon, select it first and then click on the trash symbol."
name={t('jobs.mutation.sectionAreaFilter')}
helpText={t('jobs.mutation.areaFilterHelp')}
>
<AreaFilter spatialFilter={spatialFilter} onChange={handleSpatialFilterChange} />
</SegmentPart>
<Divider margin="1rem" />
<SegmentPart
Icon={IconUser}
name="Sharing with user"
helpText="You can share this job with other users. They will be able to see the listings, but only (as the creator) you can edit the job. Admins are filtered from this list as they have access to everything."
>
<SegmentPart Icon={IconUser} name={t('jobs.mutation.sectionSharing')} helpText={t('jobs.mutation.sharingHelp')}>
{shareableUserList.length === 0 ? (
<div>No users found to share this Job to. Please create additional non-admin user.</div>
<div>{t('jobs.mutation.sharingNoUsers')}</div>
) : (
<Select
filter
multiple
placeholder="Search user"
placeholder={t('jobs.mutation.sharingSearchPlaceholder')}
autoClearSearchValue={false}
defaultValue={shareWithUsers}
onChange={(value) => setShareWithUsers(value)}
@@ -298,17 +293,17 @@ export default function JobMutator() {
<Divider margin="1rem" />
<SegmentPart
Icon={IconPlayCircle}
name="Job activation"
helpText="Whether or not the job is activated. Inactive jobs will be ignored when Fredy checks for new listings."
name={t('jobs.mutation.sectionActivation')}
helpText={t('jobs.mutation.activationHelp')}
>
<Switch className="jobMutation__spaceTop" onChange={(checked) => setEnabled(checked)} checked={enabled} />
</SegmentPart>
<Divider margin="1rem" />
<Button type="danger" style={{ marginRight: '1rem' }} onClick={() => navigate('/jobs')}>
Cancel
{t('jobs.mutation.cancel')}
</Button>
<Button type="primary" icon={<IconPlusCircle />} disabled={!isSavingEnabled()} onClick={mutateJob}>
Save
{t('jobs.mutation.save')}
</Button>
</form>
</Fragment>

View File

@@ -13,6 +13,7 @@ import { Banner, Button, Form, Modal, Select, Switch } from '@douyinfe/semi-ui-1
import './NotificationAdapterMutator.less';
import { useScreenWidth } from '../../../../../hooks/screenWidth.js';
import { useTranslation } from '../../../../../services/i18n/i18n.jsx';
const sortAdapter = (a, b) => {
if (a.name < b.name) {
@@ -24,11 +25,11 @@ const sortAdapter = (a, b) => {
return 0;
};
const validate = (selectedAdapter) => {
const validate = (selectedAdapter, t) => {
const results = [];
for (let uiElement of Object.values(selectedAdapter.fields || [])) {
if (uiElement.value == null && !uiElement.optional && uiElement.type !== 'boolean') {
results.push('All fields are mandatory and must be set.');
results.push(t('notification.validationAllMandatory'));
continue;
}
if (uiElement.type === 'boolean' && typeof uiElement.value !== 'boolean') {
@@ -37,16 +38,16 @@ const validate = (selectedAdapter) => {
if (uiElement.type === 'number') {
const numberValue = parseFloat(uiElement.value);
if (isNaN(numberValue) || numberValue < 0) {
results.push('A number field cannot contain anything else and must be > 0.');
results.push(t('notification.validationNumberField'));
continue;
}
}
if (uiElement.type === 'boolean' && typeof uiElement.value !== 'boolean') {
results.push('A boolean field cannot be of a different type.');
results.push(t('notification.validationBooleanField'));
continue;
}
if (typeof uiElement.value === 'string' && uiElement.value.length === 0 && !uiElement.optional) {
results.push('All fields are mandatory and must be set.');
results.push(t('notification.validationAllMandatory'));
}
}
@@ -70,10 +71,13 @@ export default function NotificationAdapterMutator({
editNotificationAdapter,
onData,
} = {}) {
const t = useTranslation();
const adapter = useSelector((state) => state.notificationAdapter);
const preFilledSelectedAdapter =
editNotificationAdapter == null ? null : adapter.find((a) => a.id === editNotificationAdapter.id);
editNotificationAdapter == null
? null
: adapter.filter((a) => a != null).find((a) => a.id === editNotificationAdapter.id);
spreadPrefilledAdapterWithValues(preFilledSelectedAdapter, editNotificationAdapter?.fields);
@@ -86,7 +90,7 @@ export default function NotificationAdapterMutator({
const onSubmit = (doStore) => {
if (doStore) {
const validationResults = validate(selectedAdapter);
const validationResults = validate(selectedAdapter, t);
if (validationResults.length > 0) {
setValidationMessage(validationResults.join('<br/>'));
return;
@@ -112,7 +116,7 @@ export default function NotificationAdapterMutator({
setValidationMessage(null);
setSuccessMessage(null);
const validationResults = validate(selectedAdapter);
const validationResults = validate(selectedAdapter, t);
if (validationResults.length > 0) {
setValidationMessage(validationResults.join('<br/>'));
return;
@@ -125,11 +129,9 @@ export default function NotificationAdapterMutator({
},
})
.then(() => {
setSuccessMessage('It seems like it worked! Please check your service.');
setSuccessMessage(t('notification.trySuccess'));
})
.catch((error) =>
setValidationMessage(`This did not work :-( I've received the following error: ${error.json.message}`),
);
.catch((error) => setValidationMessage(t('notification.tryError', { error: error.json.message })));
};
const setValue = (selectedAdapter, uiElement, key, value) => {
@@ -193,43 +195,35 @@ export default function NotificationAdapterMutator({
return (
<Modal
title={title != null ? title : 'Adding a new Notification Adapter'}
title={title != null ? title : t('notification.defaultTitle')}
visible={visible}
style={{ width: isMobile ? '95%' : '50rem' }}
onCancel={() => onSubmit(false)}
footer={
<div>
<Button type="secondary" disabled={selectedAdapter == null} style={{ float: 'left' }} onClick={onTry}>
Try
{t('notification.try')}
</Button>
<Button theme="light" type="tertiary" onClick={() => onSubmit(false)}>
Cancel
{t('notification.cancel')}
</Button>
<Button theme="solid" type="primary" onClick={() => onSubmit(true)}>
Save
{t('notification.save')}
</Button>
</div>
}
>
{description != null ? (
<p>{description}</p>
) : (
<p>
When Fredy finds new listings, we like to report them to you. To do so, notification adapter can be
configured. <br />
There are multiple ways how Fredy can send new listings to you. Chose your weapon...
</p>
)}
{description != null ? <p>{description}</p> : <p>{t('notification.description')}</p>}
<Select
filter
placeholder="Select a notification adapter"
placeholder={t('notification.selectPlaceholder')}
className="providerMutator__fields"
value={selectedAdapter == null ? '' : selectedAdapter.id}
optionList={adapter
.filter((a) => a != null)
.map((a) => {
return {
otherKey: a.id,
value: a.id,
label: a.name,
};
@@ -238,7 +232,7 @@ export default function NotificationAdapterMutator({
.filter((option) =>
editNotificationAdapter != null
? true
: selected.find((selectedOption) => selectedOption.id === option.key) == null,
: selected.find((selectedOption) => selectedOption.id === option.value) == null,
)
.sort(sortAdapter)}
onChange={(value) => {
@@ -263,7 +257,11 @@ export default function NotificationAdapterMutator({
fullMode={false}
type="danger"
closeIcon={null}
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>Error</div>}
title={
<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>
{t('notification.errorTitle')}
</div>
}
style={{ marginBottom: '1rem' }}
description={<p dangerouslySetInnerHTML={{ __html: validationMessage }} />}
/>
@@ -273,7 +271,11 @@ export default function NotificationAdapterMutator({
fullMode={false}
type="success"
closeIcon={null}
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>Yay!</div>}
title={
<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>
{t('notification.successTitle')}
</div>
}
style={{ marginBottom: '1rem' }}
description={<p dangerouslySetInnerHTML={{ __html: successMessage }} />}
/>

View File

@@ -8,9 +8,10 @@ import { useState, useEffect } from 'react';
import { Banner, Modal, Select, Input } from '@douyinfe/semi-ui-19';
import { transform } from '../../../../../services/transformer/providerTransformer';
import { useSelector } from '../../../../../services/state/store';
import { IconLikeHeart } from '@douyinfe/semi-icons';
import './ProviderMutator.less';
import { useScreenWidth } from '../../../../../hooks/screenWidth.js';
import { useTranslation } from '../../../../../services/i18n/i18n.jsx';
const sortProvider = (a, b) => {
if (a.key < b.key) {
@@ -33,6 +34,7 @@ export default function ProviderMutator({
onEditData,
providerToEdit,
} = {}) {
const t = useTranslation();
const provider = useSelector((state) => state.provider);
const [selectedProvider, setSelectedProvider] = useState(null);
const [providerUrl, setProviderUrl] = useState(null);
@@ -53,16 +55,16 @@ export default function ProviderMutator({
const validate = () => {
if (selectedProvider == null || selectedProvider.length === 0 || providerUrl == null || providerUrl.length === 0) {
return 'Please select a provider and copy the browser url into the textfield after configuring your search parameter.';
return t('provider.validationSelectAndUrl');
}
try {
const url = new URL(providerUrl);
if (selectedProvider.baseUrl.indexOf(url.origin) === -1) {
return 'The url you have copied is not valid.';
return t('provider.validationInvalidUrl');
}
/* eslint-disable no-unused-vars */
} catch (ignored) {
return 'The url you have copied is not valid.';
return t('provider.validationInvalidUrl');
}
return null;
};
@@ -104,46 +106,36 @@ export default function ProviderMutator({
return (
<Modal
title={providerToEdit ? 'Editing an existing Provider' : 'Adding a new Provider'}
title={providerToEdit ? t('provider.editTitle') : t('provider.defaultTitle')}
visible={visible}
onOk={() => onSubmit(true)}
onCancel={() => onSubmit(false)}
style={{ width: isMobile ? '95%' : '50rem' }}
okText="Save"
okText={t('provider.save')}
>
{validationMessage != null && (
<Banner
fullMode={false}
type="danger"
closeIcon={null}
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>Error</div>}
title={
<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>{t('provider.errorTitle')}</div>
}
style={{ marginBottom: '1rem' }}
description={validationMessage}
/>
)}
{providerToEdit != null ? (
<p>
You can now edit the <strong>{providerToEdit.name}</strong> provider's URL in the input field below.
</p>
<p>{t('provider.editDescription', { name: providerToEdit.name })}</p>
) : (
<>
<p>
Provider are the <IconLikeHeart style={{ color: '#ff0000' }} /> of Fredy. We're supporting multiple Provider
such as Immowelt, Kalaydo etc. Select a provider from the list below.
<br />
Fredy will then open the provider's url in a new tab.
</p>
<p>
You will need to configure your search parameter like you would do when you do a regular search on the
provider's website.
<br />
When the search results are shown on the website, copy the url and paste it into the textfield below.
</p>
<p>{t('provider.description')}</p>
<p>{t('provider.descriptionStep2')}</p>
</>
)}
<Select
filter
placeholder="Select a provider"
placeholder={t('provider.selectPlaceholder')}
className="providerMutator__fields"
disabled={providerToEdit != null}
optionList={provider
@@ -167,7 +159,7 @@ export default function ProviderMutator({
<br />
<Input
type="text"
placeholder="Provider Url"
placeholder={t('provider.urlPlaceholder')}
width={10}
className="providerMutator__fields"
value={providerUrl}

View File

@@ -20,6 +20,8 @@ import {
Banner,
Spin,
Toast,
TextArea,
Tooltip,
} from '@douyinfe/semi-ui-19';
import {
IconArrowLeft,
@@ -39,12 +41,15 @@ import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import no_image from '../../assets/no_image.png';
import * as timeService from '../../services/time/timeService.js';
import { formatEuroPrice } from '../../services/price/priceService.js';
import { distanceMeters, getBoundsFromCoords } from './mapUtils.js';
import { xhrPost, xhrDelete } from '../../services/xhr.js';
import ListingDeletionModal from '../../components/ListingDeletionModal.jsx';
import Headline from '../../components/headline/Headline.jsx';
import StatusControl from '../../components/listings/StatusControl.jsx';
import './ListingDetail.less';
import { useTranslation, useLocale } from '../../services/i18n/i18n.jsx';
const { Title, Text } = Typography;
@@ -53,15 +58,22 @@ const STYLES = {
};
export default function ListingDetail() {
const t = useTranslation();
const locale = useLocale();
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 userSettings = useSelector((state) => state.userSettings.settings);
const homeAddress = userSettings?.home_address;
const listingDeletionPref = userSettings?.listing_deletion_preference;
const defaultDeleteType = listingDeletionPref?.hardDelete ? 'hard' : 'soft';
const mapContainer = useRef(null);
const map = useRef(null);
const [loading, setLoading] = useState(true);
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
const [notesDraft, setNotesDraft] = useState('');
const [notesSaving, setNotesSaving] = useState(false);
useEffect(() => {
async function fetchListing() {
@@ -70,7 +82,7 @@ export default function ListingDetail() {
await actions.listingsData.getListing(listingId);
} catch (e) {
console.error('Failed to load listing details:', e);
Toast.error('Failed to load listing details');
Toast.error(t('listing.detail.toastLoadError'));
navigate('/listings');
} finally {
setLoading(false);
@@ -79,6 +91,10 @@ export default function ListingDetail() {
fetchListing();
}, [listingId]);
useEffect(() => {
setNotesDraft(listing?.notes ?? '');
}, [listing?.id, listing?.notes]);
const hasGeo =
listing?.latitude != null && listing?.longitude != null && listing?.latitude !== -1 && listing?.longitude !== -1;
@@ -101,13 +117,21 @@ export default function ListingDetail() {
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>`))
.setPopup(
new maplibregl.Popup({ offset: 25 }).setHTML(
`<h4>${t('listing.detail.mapPopupListingLocation')}</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>`))
.setPopup(
new maplibregl.Popup({ offset: 25 }).setHTML(
`<h4>${t('listing.detail.mapPopupHomeAddress')}</h4><p>${homeAddress.address}</p>`,
),
)
.addTo(map.current);
const bounds = getBoundsFromCoords([
@@ -242,13 +266,16 @@ export default function ListingDetail() {
};
}, [listing, loading, homeAddress]);
const confirmDeletion = async (hardDelete) => {
const confirmDeletion = async (hardDelete, remember) => {
try {
if (remember) {
await actions.userSettings.setListingDeletionPreference({ skipPrompt: true, hardDelete });
}
await xhrDelete('/api/listings/', { ids: [listing.id], hardDelete });
Toast.success('Listing successfully removed');
Toast.success(t('listing.detail.toastDeleted'));
navigate('/listings');
} catch (e) {
Toast.error(e.message || 'Error deleting listing');
Toast.error(e.message || t('listing.detail.toastDeleteError'));
} finally {
setDeleteModalVisible(false);
}
@@ -257,11 +284,39 @@ export default function ListingDetail() {
const handleWatch = async () => {
try {
await xhrPost('/api/listings/watch', { listingId: listing.id });
Toast.success(listing.isWatched === 1 ? 'Removed from Watchlist' : 'Added to Watchlist');
Toast.success(
listing.isWatched === 1 ? t('listing.detail.toastWatchlistRemoved') : t('listing.detail.toastWatchlistAdded'),
);
actions.listingsData.getListing(listingId);
} catch (e) {
console.error('Failed to operate Watchlist:', e);
Toast.error('Failed to operate Watchlist');
Toast.error(t('listing.detail.toastWatchlistError'));
}
};
const handleStatusChange = async (next) => {
try {
await actions.listingsData.setListingStatus(listing.id, next);
await actions.listingsData.getListing(listingId);
Toast.success(next ? t('listings.toastStatusMarked', { status: next }) : t('listings.toastStatusCleared'));
} catch (e) {
console.error('Failed to update status:', e);
Toast.error(t('listings.toastStatusUpdateError'));
}
};
const handleSaveNotes = async () => {
if (!listing) return;
setNotesSaving(true);
try {
await actions.listingsData.setListingNotes(listing.id, notesDraft);
await actions.listingsData.getListing(listingId);
Toast.success(t('listing.detail.toastNotesSaved'));
} catch (e) {
console.error('Failed to save notes:', e);
Toast.error(t('listing.detail.toastNotesError'));
} finally {
setNotesSaving(false);
}
};
@@ -275,42 +330,74 @@ export default function ListingDetail() {
if (!listing) return null;
const statusKeyMap = {
applied: 'listing.detail.statusApplied',
accepted: 'listing.detail.statusAccepted',
rejected: 'listing.detail.statusRejected',
};
const statusLabel = listing.status?.status ? t(statusKeyMap[listing.status.status] ?? listing.status.status) : null;
const data = [
{ key: 'Price', value: `${listing.price}`, Icon: <IconCart /> },
{
key: 'Size',
value: listing.size ? `${listing.size}` : 'N/A',
key: t('listing.detail.fieldPrice'),
value: listing.price ? (
<span className="listing-detail__price">{formatEuroPrice(listing.price)}</span>
) : (
t('common.na')
),
Icon: <IconCart />,
helpText: t('listing.detail.fieldPriceHelp'),
},
{
key: t('listing.detail.fieldSize'),
value: listing.size ? `${listing.size}` : t('common.na'),
Icon: <IconExpand />,
helpText: t('listing.detail.fieldSizeHelp'),
},
{
key: 'Rooms',
value: listing.rooms ? `${listing.rooms} Rooms` : 'N/A',
key: t('listing.detail.fieldRooms'),
value: listing.rooms ? t('listing.detail.fieldRoomsValue', { count: listing.rooms }) : t('common.na'),
Icon: <IconGridView />,
helpText: t('listing.detail.fieldRoomsHelp'),
},
{
key: 'Job',
key: t('listing.detail.fieldJob'),
value: listing.job_name,
Icon: <IconBriefcase />,
helpText: t('listing.detail.fieldJobHelp'),
},
{
key: 'Provider',
key: t('listing.detail.fieldProvider'),
value: listing.provider ? listing.provider.charAt(0).toUpperCase() + listing.provider.slice(1) : 'Unknown',
Icon: <IconBriefcase />,
helpText: t('listing.detail.fieldProviderHelp'),
},
{
key: 'Added',
value: timeService.format(listing.created_at),
key: t('listing.detail.fieldAdded'),
value: timeService.format(listing.created_at, true, locale),
Icon: <IconClock />,
helpText: t('listing.detail.fieldAddedHelp'),
},
];
if (statusLabel) {
data.push({
key: t('listing.detail.fieldStatus'),
value: listing.status?.setAt
? `${statusLabel} ${t('listing.detail.statusSetAt', { date: timeService.format(listing.status.setAt, true, locale) })}`
: statusLabel,
Icon: <IconActivity />,
helpText: t('listing.detail.fieldStatusHelp'),
});
}
return (
<div className="listing-detail">
<Headline
text={listing?.title || 'Listing Detail'}
text={listing?.title || t('listing.detail.defaultTitle')}
actions={
<Button icon={<IconArrowLeft />} onClick={() => navigate(-1)} theme="borderless" style={{ color: '#909090' }}>
Back
{t('listing.detail.back')}
</Button>
}
/>
@@ -329,7 +416,7 @@ export default function ListingDetail() {
{listing.address}
</a>
) : (
<Text type="secondary">No address provided</Text>
<Text type="secondary">{t('listing.detail.noAddress')}</Text>
)}
</Space>
<Space wrap className="listing-detail__header-actions">
@@ -339,19 +426,26 @@ export default function ListingDetail() {
theme="borderless"
className={`listing-detail__watch-btn${listing.isWatched === 1 ? ' listing-detail__watch-btn--active' : ''}`}
>
{listing.isWatched === 1 ? 'Watched' : 'Watch'}
{listing.isWatched === 1 ? t('listing.detail.watched') : t('listing.detail.watch')}
</Button>
<StatusControl status={listing.status?.status ?? null} onChange={handleStatusChange} />
<a href={listing.link} target="_blank" rel="noopener noreferrer" className="listing-detail__open-btn">
<IconLink style={{ marginRight: 6 }} />
Open listing
{t('listing.detail.openListing')}
</a>
<Button
icon={<IconDelete />}
onClick={() => setDeleteModalVisible(true)}
onClick={() => {
if (listingDeletionPref?.skipPrompt) {
confirmDeletion(listingDeletionPref.hardDelete);
return;
}
setDeleteModalVisible(true);
}}
theme="light"
type="danger"
>
Delete
{t('listing.detail.delete')}
</Button>
</Space>
</div>
@@ -363,33 +457,61 @@ export default function ListingDetail() {
>
<Image
src={listing.image_url ?? no_image}
fallback={<img src={no_image} alt="No image available" />}
fallback={<img src={no_image} alt={t('listing.detail.noImageAlt')} />}
style={{ width: '100%', height: '100%' }}
preview={!!listing.image_url}
/>
</div>
<div className="listing-detail__notes">
<Title heading={4} className="listing-detail__notes-title">
{t('listing.detail.notesTitle')}
</Title>
<TextArea
value={notesDraft}
onChange={(val) => setNotesDraft(val)}
placeholder={t('listing.detail.notesPlaceholder')}
rows={5}
autosize={{ minRows: 4, maxRows: 12 }}
className="listing-detail__notes-textarea"
showClear
/>
<Space className="listing-detail__notes-actions">
<Button
theme="solid"
type="primary"
loading={notesSaving}
disabled={notesSaving || (notesDraft ?? '') === (listing.notes ?? '')}
onClick={handleSaveNotes}
>
{t('listing.detail.storeNotes')}
</Button>
</Space>
</div>
</Col>
<Col span={24} lg={12}>
<div className="listing-detail__info-section">
<Title heading={4} style={{ marginBottom: '1rem' }}>
Details
{t('listing.detail.detailsTitle')}
</Title>
<Descriptions column={1}>
{data.map((item, index) => (
<Descriptions.Item key={index}>
<Space>
{item.Icon}
{item.value}
</Space>
<Tooltip content={item.helpText} position="left">
<span className="listing-detail__details-item">
{item.Icon}
{item.value}
</span>
</Tooltip>
</Descriptions.Item>
))}
</Descriptions>
<Divider margin="1.5rem" />
<Title heading={4} style={{ marginBottom: '1rem' }}>
Description
{t('listing.detail.descriptionTitle')}
</Title>
<Text type="secondary" style={{ whiteSpace: 'pre-wrap' }}>
{listing.description || 'No description available.'}
{listing.description || t('listing.detail.noDescription')}
</Text>
{listing.distance_to_destination && (
@@ -397,7 +519,7 @@ export default function ListingDetail() {
<Divider margin="1.5rem" />
<Space align="center">
<IconActivity style={{ fontSize: '18px', color: 'var(--semi-color-primary)' }} />
<Text strong>Distance to home:</Text>
<Text strong>{t('listing.detail.distanceToHome')}</Text>
<Tag color="blue">{listing.distance_to_destination} m</Tag>
</Space>
</>
@@ -408,12 +530,12 @@ export default function ListingDetail() {
</Card>
<div className="listing-detail__map-wrapper">
<Title heading={3}>Location</Title>
<Title heading={3}>{t('listing.detail.locationTitle')}</Title>
{!hasGeo ? (
<Banner
type="warning"
bordered
description="This listing has no valid geocoordinates, so we cannot show it on the map."
description={t('listing.detail.noGeoWarning')}
style={{ marginTop: '1rem' }}
/>
) : (
@@ -423,6 +545,7 @@ export default function ListingDetail() {
<ListingDeletionModal
visible={deleteModalVisible}
defaultDeleteType={defaultDeleteType}
onConfirm={confirmDeletion}
onCancel={() => setDeleteModalVisible(false)}
/>

View File

@@ -89,6 +89,49 @@
}
}
&__notes {
padding: 1.5rem;
border-top: 1px solid var(--semi-color-border);
}
&__notes-title {
margin-bottom: 1rem !important;
}
&__notes-actions {
margin-top: 0.75rem;
width: 100%;
justify-content: flex-end;
}
&__notes-textarea {
background: #2a2a2a !important;
border: 1px solid @color-border-bright !important;
border-radius: @radius-input !important;
box-shadow: none !important;
outline: none !important;
transition: border-color @transition-fast, background @transition-fast !important;
textarea {
background: transparent !important;
border: 0 !important;
box-shadow: none !important;
outline: none !important;
color: @color-text !important;
font-family: @font-ui !important;
font-size: @text-base !important;
}
&.semi-input-textarea-focus,
&:focus,
&:focus-within {
border-color: @color-accent !important;
box-shadow: none !important;
outline: none !important;
background: #2f2f2f !important;
}
}
&__watch-btn {
color: @color-muted !important;
border: 1px solid @color-border-bright !important;
@@ -128,6 +171,13 @@
padding: 1.5rem;
}
&__details-item {
cursor: help;
display: inline-flex;
align-items: center;
gap: 8px;
}
&__map-container {
height: 400px;
width: 100%;
@@ -149,6 +199,10 @@
font-size: 0.9rem;
padding: 0.2rem 0.6rem;
}
&__price {
font-variant-numeric: tabular-nums;
}
}
.listing-detail-popup {

View File

@@ -5,12 +5,18 @@
import ListingsOverview from '../../components/listings/ListingsOverview.jsx';
import Headline from '../../components/headline/Headline.jsx';
import { useTranslation } from '../../services/i18n/i18n.jsx';
export default function Listings() {
/**
* @param {{ mode?: 'all' | 'watchlist' }} props
*/
export default function Listings({ mode = 'all' }) {
const t = useTranslation();
const title = mode === 'watchlist' ? t('listings.watchlistTitle') : t('listings.title');
return (
<>
<Headline text="Listings" />
<ListingsOverview />
<Headline text={title} />
<ListingsOverview mode={mode} />
</>
);
}

View File

@@ -22,12 +22,14 @@ import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import ListingDeletionModal from '../../components/ListingDeletionModal.jsx';
import Map from '../../components/map/Map.jsx';
import Headline from '../../components/headline/Headline.jsx';
import { useTranslation } from '../../services/i18n/i18n.jsx';
const RangeSlider = _RangeSlider?.default ?? _RangeSlider;
const { Text } = Typography;
export default function MapView() {
const t = useTranslation();
const mapContainer = useRef(null);
const map = useRef(null);
const markers = useRef([]);
@@ -37,7 +39,10 @@ export default function MapView() {
const sp = useSearchParams();
const [searchParams, setSearchParams] = sp;
const listings = useSelector((state) => state.listingsData.mapListings);
const homeAddress = useSelector((state) => state.userSettings.settings.home_address);
const userSettings = useSelector((state) => state.userSettings.settings);
const homeAddress = userSettings?.home_address;
const listingDeletionPref = userSettings?.listing_deletion_preference;
const defaultDeleteType = listingDeletionPref?.hardDelete ? 'hard' : 'soft';
const jobs = useSelector((state) => state.jobsData.jobs);
const [jobId, setJobId] = useSearchParamState(sp, 'job', null, parseString);
@@ -52,20 +57,33 @@ export default function MapView() {
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
const [listingToDelete, setListingToDelete] = useState(null);
const deleteListingRef = useRef(null);
const confirmListingDeletion = async (hardDelete) => {
const confirmListingDeletion = async (hardDelete, remember, id = listingToDelete) => {
try {
await xhrDelete('/api/listings/', { ids: [listingToDelete], hardDelete });
Toast.success('Listing successfully removed');
if (remember) {
await actions.userSettings.setListingDeletionPreference({ skipPrompt: true, hardDelete });
}
await xhrDelete('/api/listings/', { ids: [id], hardDelete });
Toast.success(t('map.toastDeleted'));
fetchListings();
} catch (error) {
Toast.error(error.message || 'Error deleting listing');
Toast.error(error.message || t('map.toastDeleteError'));
} finally {
setDeleteModalVisible(false);
setListingToDelete(null);
}
};
deleteListingRef.current = (id) => {
if (listingDeletionPref?.skipPrompt) {
confirmListingDeletion(listingDeletionPref.hardDelete, false, id);
return;
}
setListingToDelete(id);
setDeleteModalVisible(true);
};
useEffect(() => {
// Only reset to full range when no URL override is set
if (urlPriceMax === null) {
@@ -88,10 +106,7 @@ export default function MapView() {
};
useEffect(() => {
window.deleteListing = (id) => {
setListingToDelete(id);
setDeleteModalVisible(true);
};
window.deleteListing = (id) => deleteListingRef.current(id);
window.viewDetails = (id) => {
navigate(`/listings/listing/${id}`);
@@ -223,7 +238,7 @@ export default function MapView() {
.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>`,
`<div class="map-popup-content"><h4>${t('map.popupHomeAddress')}</h4><p>${homeAddress.address}</p></div>`,
),
)
.addTo(map.current);
@@ -300,11 +315,11 @@ export default function MapView() {
/>
<h4>${listing.title}</h4>
<div class="info">
<span><strong>Price:</strong> ${listing.price ? listing.price + ' €' : 'N/A'}</span>
<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>
<span><strong>Size:</strong> ${listing.size != null ? `${listing.size}` : 'N/A'}</span>
<span><strong>${t('map.popupPrice')}</strong> ${listing.price ? listing.price + ' €' : t('common.na')}</span>
<span><strong>${t('map.popupAddress')}</strong> ${listing.address || t('common.na')}</span>
<span><strong>${t('map.popupJob')}</strong> ${listing.job_name || t('common.na')}</span>
<span><strong>${t('map.popupProvider')}</strong> ${capitalizedProvider}</span>
<span><strong>${t('map.popupSize')}</strong> ${listing.size != null ? `${listing.size}` : t('common.na')}</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">
@@ -313,14 +328,14 @@ export default function MapView() {
</div>
<button
class="map-popup-content__detailsButton"
title="View Details"
title="${t('map.popupViewDetails')}"
onclick="viewDetails('${listing.id}')"
>
${renderToString(<IconEyeOpened />)}
</button>
<button
class="map-popup-content__deleteButton"
title="Remove"
title="${t('map.popupRemove')}"
onclick="deleteListing('${listing.id}')"
>
${renderToString(<IconDelete />)}
@@ -356,7 +371,7 @@ export default function MapView() {
return (
<>
<Headline text="Map View" />
<Headline text={t('map.title')} />
<div className="map-view-container">
{!homeAddress && (
<Banner
@@ -367,8 +382,9 @@ export default function MapView() {
style={{ marginBottom: '8px' }}
description={
<span>
No home address set. Configure it in <Link to="/userSettings">user settings</Link> to use the distance
filter.
{t('map.noHomeAddressBefore')}
<Link to="/userSettings">{t('map.noHomeAddressLink')}</Link>
{t('map.noHomeAddressAfter')}
</span>
}
/>
@@ -380,7 +396,7 @@ export default function MapView() {
bordered
closeIcon={null}
style={{ marginBottom: '8px' }}
description="Only listings with valid addresses are shown on this map."
description={t('map.onlyValidAddresses')}
/>
<div className="map-view-container__map-wrapper">
@@ -395,10 +411,10 @@ export default function MapView() {
<div className="map-view-container__floating-panel">
<div className="map-view-container__panel-row">
<Text size="small" strong style={{ color: '#8892a4' }}>
Job
{t('map.filterJobLabel')}
</Text>
<Select
placeholder="All jobs"
placeholder={t('map.filterJobPlaceholder')}
showClear
size="small"
onChange={(val) => setJobId(val)}
@@ -415,16 +431,16 @@ export default function MapView() {
<div className="map-view-container__panel-row">
<Text size="small" strong style={{ color: '#8892a4' }}>
Distance
{t('map.filterDistanceLabel')}
</Text>
<Select
placeholder="None"
placeholder={t('map.filterDistanceNone')}
size="small"
onChange={(val) => setDistanceFilter(val)}
value={distanceFilter}
style={{ width: 100 }}
>
<Select.Option value={0}>None</Select.Option>
<Select.Option value={0}>{t('map.filterDistanceNone')}</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>
@@ -435,7 +451,7 @@ export default function MapView() {
<div className="map-view-container__panel-row">
<Text size="small" strong style={{ color: '#8892a4' }}>
Price ()
{t('map.filterPriceLabel')}
</Text>
<div className="map-view-container__price-slider">
<div className="map__rangesliderLabels">
@@ -448,17 +464,17 @@ export default function MapView() {
<div className="map-view-container__panel-row">
<Text size="small" strong style={{ color: '#8892a4' }}>
Style
{t('map.filterStyleLabel')}
</Text>
<Select size="small" value={style} onChange={(val) => handleMapStyle(val)} style={{ width: 110 }}>
<Select.Option value="STANDARD">Standard</Select.Option>
<Select.Option value="SATELLITE">Satellite</Select.Option>
<Select.Option value="STANDARD">{t('map.filterStyleStandard')}</Select.Option>
<Select.Option value="SATELLITE">{t('map.filterStyleSatellite')}</Select.Option>
</Select>
</div>
<div className="map-view-container__panel-row">
<Text size="small" strong style={{ color: '#8892a4' }}>
3D Buildings
{t('map.filter3dBuildings')}
</Text>
<Switch
size="small"
@@ -472,6 +488,7 @@ export default function MapView() {
<ListingDeletionModal
visible={deleteModalVisible}
defaultDeleteType={defaultDeleteType}
onConfirm={confirmListingDeletion}
onCancel={() => {
setDeleteModalVisible(false);

View File

@@ -8,8 +8,10 @@ import { IconHorn } from '@douyinfe/semi-icons';
import { SegmentPart } from '../../../components/segment/SegmentPart.jsx';
import { Banner, Button, Checkbox, Space, Typography } from '@douyinfe/semi-ui-19';
import NotificationAdapterMutator from '../../jobs/mutation/components/notificationAdapter/NotificationAdapterMutator.jsx';
import { useTranslation } from '../../../services/i18n/i18n.jsx';
export default function WatchlistManagement() {
const t = useTranslation();
const [notificationChooserVisible, setNotificationChooserVisible] = useState(false);
const [notificationAdapterData, setNotificationAdapterData] = useState([]);
//TODO: Set default
@@ -17,39 +19,37 @@ export default function WatchlistManagement() {
const [priceChanges, setPriceChanges] = useState(false);
return (
<div>
<SegmentPart
name="Notification for Watch List"
helpText="You can get notified for changes on listings from your watch list."
Icon={IconHorn}
>
<SegmentPart name={t('watchlist.sectionName')} helpText={t('watchlist.sectionHelp')} Icon={IconHorn}>
<Banner
fullMode={false}
type="info"
closeIcon={null}
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>Note</div>}
description="Youll receive notifications only for listings that are on your watch list. To add listings to it, open the 'Listings' section and tag the ones you want to follow."
title={
<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>{t('watchlist.noteTitle')}</div>
}
description={t('watchlist.noteDescription')}
/>
<Space />
<Typography.Title heading={5} style={{ marginTop: '1rem' }}>
Notify me when:
{t('watchlist.notifyMeWhen')}
</Typography.Title>
<Checkbox checked={activityChanges} onChange={(e) => setActivityChanges(e.target.checked)}>
Listing state changes (e.g. listing becomes inactive)
{t('watchlist.activityChanges')}
</Checkbox>
<Checkbox checked={priceChanges} onChange={(e) => setPriceChanges(e.target.checked)}>
Listing price changes
{t('watchlist.priceChanges')}
</Checkbox>
<Space />
<Typography.Title heading={5} style={{ marginTop: '1rem' }}>
Notify me with:
{t('watchlist.notifyMeWith')}
</Typography.Title>
<Button onClick={() => setNotificationChooserVisible(true)}>Select notification method</Button>
<Button onClick={() => setNotificationChooserVisible(true)}>{t('watchlist.selectNotificationMethod')}</Button>
<NotificationAdapterMutator
title="Add notification method"
description="When something has changed, Fredy will notify you using the selected notification adapter. Note, some adapter like SqLite are not available here."
title={t('watchlist.addNotificationTitle')}
description={t('watchlist.addNotificationDescription')}
visible={notificationChooserVisible}
onVisibilityChanged={(visible) => {
setNotificationChooserVisible(visible);

View File

@@ -14,8 +14,10 @@ import { Input, Button, Banner } from '@douyinfe/semi-ui-19';
import './login.less';
import { IconUser, IconLock } from '@douyinfe/semi-icons';
import { useTranslation } from '../../services/i18n/i18n.jsx';
export default function Login() {
const t = useTranslation();
const actions = useActions();
const [username, setUserName] = React.useState('');
const [password, setPassword] = React.useState('');
@@ -33,7 +35,7 @@ export default function Login() {
const tryLogin = async () => {
if (!username?.trim() || !password) {
setError('Username and password are mandatory.');
setError(t('login.errorMandatory'));
return;
}
setError(null);
@@ -45,7 +47,7 @@ export default function Login() {
});
/* eslint-disable no-unused-vars */
} catch (ignored) {
setError('Login unsuccessful. Please check your username and password.');
setError(t('login.errorInvalid'));
return;
}
@@ -67,7 +69,7 @@ export default function Login() {
type="info"
bordered
closeIcon={null}
description="This is the demo version of Fredy. Use 'demo' as both the username and password to log in."
description={t('login.demoBanner')}
style={{ marginBottom: '1.5rem' }}
/>
)}
@@ -78,7 +80,7 @@ export default function Login() {
<Input
size="large"
prefix={<IconUser />}
placeholder="Username"
placeholder={t('login.usernamePlaceholder')}
value={username}
showClear
autoFocus
@@ -97,7 +99,7 @@ export default function Login() {
mode="password"
prefix={<IconLock />}
value={password}
placeholder="Password"
placeholder={t('login.passwordPlaceholder')}
onChange={(value) => setPassword(value)}
onKeyPress={async (e) => {
if (e.key === 'Enter') {
@@ -108,7 +110,7 @@ export default function Login() {
</div>
<Button block type="primary" onClick={tryLogin} theme="solid" style={{ marginTop: '1rem' }}>
Login
{t('login.loginButton')}
</Button>
</form>
</div>

View File

@@ -4,10 +4,13 @@
*/
import { Modal } from '@douyinfe/semi-ui-19';
import { useTranslation } from '../../services/i18n/i18n.jsx';
const UserRemovalModal = function UserRemovalModal({ onOk, onCancel }) {
const t = useTranslation();
return (
<Modal title="Removing user" visible={true} closable={false} onOk={onOk} onCancel={onCancel}>
<p>Removing this user will also remove all associated jobs.</p>
<Modal title={t('users.removalModal.title')} visible={true} closable={false} onOk={onOk} onCancel={onCancel}>
<p>{t('users.removalModal.message')}</p>
</Modal>
);
};

Some files were not shown because too many files have changed in this diff Show More